Async HTTP
— 2020-02-25

  1. streams
  2. shared abstractions
  3. error handling
  4. trait forwarding
  5. compatibility
  6. design philosophy
  7. acknowledgements
  8. what's next?
  9. conclusion

Today Friedel Ziegelmayer (Protocol Labs), Ryan Levick (Microsoft) 1, and myself would like to introduce a new set of HTTP libraries to make writing encrypted, async http/1.1 servers and clients easy and quick:

1

None of these projects are official projects by any one company.

2

This library builds on the excellent work done in native-tls.

With these libraries writing a streaming, encrypted HTTP client takes about 15 lines of code 3:

3

The TCP stream, HTTP stream, and TLS stream each expect a different subset of a URL. Most of the logic in this example is about constructing the right argument for each stream. We have an open issue that if implemented should make this even easier.

use async_std::io::prelude::*;
use async_std::net::TcpStream;
use http_types::{Method, Request, Url};

#[async_std::main]
async fn main() -> http_types::Result<()> {
    // open a tcp connection to a host
    let stream = TcpStream::connect("127.0.0.1:8080").await?;
    let peer_addr = stream.peer_addr()?;

    // create a request
    let url = Url::parse(&format!("https://{}/foo", peer_addr))?;
    let req = Request::new(Method::Get, url);

    // encrypt the tls connection
    let host = req.url().host_str()?;
    let stream = async_native_tls::connect(host, stream).await?;

    // send the request over the encrypted connection
    let res = async_h1::connect(stream, req).await?;
    println!("{:?}", res);
    Ok(())
}

And an async HTTP server is done in less than 30 lines, with much of the code being imports, async loops, print statements, and constructing the right URL parameters:

use async_std::net::{TcpStream, TcpListener};
use async_std::prelude::*;
use async_std::task;
use http_types::{Response, StatusCode};

#[async_std::main]
async fn main() -> http_types::Result<()> {
    // Open up a TCP connection and create a URL.
    let listener = TcpListener::bind(("127.0.0.1", 8080)).await?;
    let addr = format!("http://{}", listener.local_addr()?);
    println!("listening on {}", addr);

    // For each incoming TCP connection, spawn a task and call `accept`.
    let mut incoming = listener.incoming();
    while let Some(stream) = incoming.next().await {
        let stream = stream?;
        let addr = addr.clone();
        task::spawn(async {
            if let Err(err) = accept(addr, stream).await {
                eprintln!("{}", err);
            }
        });
    }
    Ok(())
}

// Take a TCP stream, and convert it into sequential HTTP request / response pairs.
async fn accept(addr: String, stream: TcpStream) -> http_types::Result<()> {
    println!("starting new connection from {}", stream.peer_addr()?);
    async_h1::accept(&addr, stream.clone(), |_req| async move {
        let mut res = Response::new(StatusCode::Ok);
        res.insert_header("Content-Type", "text/plain")?;
        res.set_body("Hello");
        Ok(res)
    })
    .await?;
    Ok(())
}

Creating an encrypted version of this would be only a few extra lines: load a certificate, and wrap the accept stream:

use async_std::prelude::*;
use async_std::net::TcpListener;
use async_std::fs::File;

let key = File::open("identity.pfx").await?;
let pass = "<password>";

let listener = TcpListener::bind("127.0.0.1:8080").await?;
let (stream, _) = listener.accept().await?;

let stream = async_native_tls::accept(key, pass, stream).await?;
// Continue handling the stream here

Streams

As you may have noticed, the libraries we've written are centered around streams. This is because streams in Rust provide an incredible degree of composability. For example, using surf to copy the body an HTTP request to a file is a combination of "make an HTTP request", and "copy to a file":

let req = surf::get("https://example.com").await?;
io::copy(req, File::open("example.txt")).await?;

With async-h1 and async-native-tls we wanted to not only make this possible at the framework layer; we wanted this to extend to the protocol layer as well. Because we believe that if it becomes easier to combine parts of a stack, it becomes easier for the ecosystem to make progress on the stack as a whole.

Putting it concretely, if you want to run async-h1 over a UnixStream to say, communicate with a local daemon, all you have to do is replace the TcpStream with a UnixStream and you're good to go. There's no need to start from scratch or fork existing projects.

Shared abstractions

As we mentioned at the start: the http-types crate has been extracted from the Tide and Surf frameworks. Prior to this we were using the hyperium/http crate, which provides abstractions for several HTTP idioms, but for example doesn't provide an HTTP body, cookies, mime types, and doesn't implement the url standard. These are all fine choices with distinct tradeoffs. But for us we were finding that we had a lot of duplicate code between Tide and Surf, which was becoming a maintenance burden 4.

4

If the past is any indication, it's likely someone will bring up the "You're splitting the ecosystem" meme when discussing this post. This is an argument to authority: rather than explore how things could be improved, we should relinquish to the status quo. But this conveniently fails to acknowledge that writing and sharing code in the public domain is collaboration. Our ideas are now available to try out and improve upon with no restrictions other than attribution. Sharing findings in the public domain is not a divisive act. Fanning controversy using "us vs them" rhetoric however, is.

So instead we've opted to create http-types, a shared abstraction that covers the full range of HTTP idioms, and provides a streaming body. We've found that this has made it easier to create a rich set of HTTP abstractions for us, to the point where we suspect both Tide and Surf's types will be minimal wrappers around http-types and http-service and http-client respectively. Only bringing in framework-specific additions such as middleware and routing.

Error Handling

Additionally http-types provides a status-code aware Error type, making it possible to associate status codes with errors. This work leans heavily on David Tolnay's excellent work on anyhow.

Under the hood http_types::Error is a combination of a boxed error

Additionally we also provide a ResultExt trait that exposes a status method on Result that serves as a shorthand to cast to this type. This is useful to quickly assign status codes to existing errors 5.

5

By default casting from a boxed error to http_types::Error will assume it's a 500: Internal Server Error. The .status method is mostly useful for assigning other status codes.

/// Get the length of a file in bytes.
/// Converts an `io::Error` to `http_types::Error`.
async fn file_length(p: Path) -> http_types::Result<usize> {
    let b = fs::read(p).await.status(501)?;
    Ok(b.len())
}

This is a first step for us to make error handling a first-class citizen in HTTP frameworks such as Tide and Surf. We're still exploring how the general pattern feels, and would love to hear your thoughts!

Trait forwarding

But we're not using AsRef exclusively for error handling; we use it extensively throughout our libraries. This has been inspired by rustwasm's approach to events in web-sys. Rather than define a new trait that's shared by objects, each DOM object implements AsRef<EventTarget>, allowing it to be referenced as an EventTarget.

In http-types, we implement AsRef and AsMut to convert between various types. For example a Request is a combination of a byte stream, headers, and a URL. So it implements AsRef<Url>, AsRef<Headers>, and AsyncRead. Similarly a Response is a combination of a byte stream, headers, and a status code. So it implements AsRef<StatusCode>, AsRef<Headers>, and AsyncRead 6.

6

Before adopting this pattern we played around for a bit with defining custom traits, or following the pattern of exposing headers / headers_mut in the interface. All of these felt rather awkward, and it was only after playing around with AsRawFd more that we realized AsRef was the perfect fit here. And seeing it used in practice in the web-sys crates solidified that intuition further.

This pattern is repeated throughout our crates. Worth noting is that we don't allow creating Headers as a standalone type, yet it still serves as a shared interface between Request, Response, and Trailers. That means that if you want to read or write to any of these types you can do:

fn set_cors(headers: impl AsMut<http_types::Headers>) {
    // set cors headers here
}

fn forwarded_for(headers: impl AsRef<http_types::Headers>) {
    // get the X-forwarded-for header
}

And this code will happily take a Request, Response, or Trailers and perform the actions without a problem:

let fwd1 = forwarded_for(&req);
let fwd2 = forwarded_for(&res);
let fwd3 = forwarded_for(&trailers);

The plan is for Tide and Surf's Request and Response pairs to also forward the same types. So whether you work with http-types directly, or targeting one of the frameworks the code you write to interface with them remains the same.

Compatibility

Something else that we're excited about is the level of compatibility that we have with this new stack. It's now possible to run servers as Lambda functions, HTTP clients in the browser, and whichever combination of Rust server, TLS, DNS, and transport you want.

In order for http-types to work with crates in the ecosystem that use hyperium/http, we provide From and Into implementations for all our types that can be enabled through a feature flag:

[dependencies]
http-types = { version = "*", features = ["hyperium_http"] }

Because hyperium/http doesn't provide a body implementation, compatibility won't always be frictionless. But this is as close as we could get it, and should cover many cases 7!

7

For us one of the most important cases to target is compatibility with isahc, an async HTTP library backed by curl. Because it also uses AsyncRead as its body, the compat layer makes working with it really nice.

This means that between http-client, http-service, and http-types we provide an incredibly flexible layer that's widely compatible.

Design philosophy

Most of the work on these projects has been driven by curiosity. Trying to answer questions such as: "What does an HTTP library look like if it's designed for async Rust? Are generics required to allow TCP and TLS to be configured? Can HTTP/1.1 be expressed as streams?"

What we've tried to build is a set of libraries that will lower the barrier to experimenting with async HTTP in Rust. We use few new traits, limit the amount of generics we provide, compile incredibly fast, and closely follow Rust's naming conventions.

We feel the end-result is a set of libraries that are easy to pick up, easy to hack on, and easy to maintain. async-h1's implementation is only a few hundred lines for the client and server each. Which means if you ever hit a bug or want to make a change, it's possible to make the changes without relying on resident experts. We hope this will empower people to try things out, and continue to improve beyond where we've gone.

Acknowledgements

We'd like to take a moment to appreciate and thank the Hyperium team for all the work they've done. While we have concrete reasons to embark on a different path than the one they're on, the reality is that this has only been possible because of the work they've done.

To name examples: async-h1 makes use of the excellent httparse library. And hyperium/http has long been a center component of Tide and Surf, and was the standard which we measured our work on http-types against.

Even though the purpose of this post is to showcase the innovations we're introducing to HTTP in Rust, it's worth acknowledging that our groups have more in common than might first appear. We all want HTTP in Rust to succeed. We care about usability. We care about performance. The difference lies in how to achieve achieve those goals.

In the spirit of healthy competition, we think it's worth taking a moment to acknowledge our colleagues on the other side, and appreciate the work that they've done. Because we couldn't have done this without them. Thanks!

What's next?

We'd love for you to get involved! Come hang out with us on Discord, or consider helping out with documentation, tests, or issues on GitHub. Our main goals right now are to improve test coverage, iron out the bumps based on user feedback, and improve protocol support 8.

We've talked about authoring an async-h2 crate, but we currently don't have the bandwidth to. We'd like to see Tide ship a 1.0 eventually, and continue to improve protocol support and ergonomics to make Rust's HTTP stack not just one of the fastest, but also the easiest to use of all languages.

Conclusion

In this post we've introduced three new HTTP libraries: async-h1, async-native-tls, and http-types. These libraries have been built natively for async byte streams, and provide a carefully balanced API that combines ergonomics with performance.

It's hard to capture all the details that have gone into these libraries. Over the past 6 months we've been writing and rewriting these libraries over and over. We feel proud to be able to share them with you, and think it provides an exciting direction for HTTP in Rust.

We hope you'll enjoy the work we've shared today, and we're excited for you to try it out!

8

For async-h1 we're missing chunked encoding for client streams. For async-native-tls TLS 1.3 is not there yet. And http-types doesn't have structured constructors for all headers yet.


Thanks to Joshua Gould and Florian Gilcher for providing feedback on this post. Tirr-c for helping out with the libraries. And Stjepan Glavina for helping debug some of the worst trait bound problems imaginable.