Skip to content

Instantly share code, notes, and snippets.

@vikanezrimaya
Last active September 6, 2022 11:28
Show Gist options
  • Save vikanezrimaya/037101dc7b28de37ef03a47569213236 to your computer and use it in GitHub Desktop.
Save vikanezrimaya/037101dc7b28de37ef03a47569213236 to your computer and use it in GitHub Desktop.
Hyper: Proposal for a server-side HTTP 103 Early Hints API

Is there still no community consensus on the API surface? I am interested in experimenting with this.

Yep, this still needs an accepted proposal. I think the general leaning is to take the ideas for push and adapt them for informational, and then run through any potential downsides with that design. The more clarity there is in the design proposal, the easier it would be to merge a PR afterwards.

Anyone is welcome to write up a proposal, of course!

HTTP Early Hints: Server-Side API proposal

HTTP 103 Early Hints is an informational status code designed to speed up page loads by allowing the server to hint at headers that will be present in the final response and that may influence the page content (most commonly Link: rel=preload headers). RFC8297 describes it as follows:

The 103 (Early Hints) informational status code indicates to the client that the server is likely to send a final response with the header fields included in the informational response.

Typically, a server will include the header fields sent in a 103 (Early Hints) response in the final response as well. However, there might be cases when this is not desirable, such as when the server learns that the header fields in the 103 (Early Hints) response are not correct before the final response is sent.

A client can speculatively evaluate the header fields included in a 103 (Early Hints) response while waiting for the final response. For example, a client might recognize a Link header field value containing the relation type "preload" and start fetching the target resource. However, these header fields only provide hints to the client; they do not replace the header fields on the final response.

Aside from performance optimizations, such evaluation of the 103 (Early Hints) response's header fields MUST NOT affect how the final response is processed. A client MUST NOT interpret the 103 (Early Hints) response header fields as if they applied to the informational response itself (e.g., as metadata about the 103 (Early Hints) response).

A server MAY use a 103 (Early Hints) response to indicate only some of the header fields that are expected to be found in the final response. A client SHOULD NOT interpret the nonexistence of a header field in a 103 (Early Hints) response as a speculation that the header field is unlikely to be part of the final response.

Multiple HTTP 103 Early Hints responses are permitted for a single request, and all of them can be used in anticipation of the final header list, e.g. to preload resources in Link rel="preload" headers or check their cache status.

Prior Art

HTTP/2 Server Push

An older version of this idea existed in HTTP/2 as "Server Push". It was, however, mostly unused and eventually abandoned, with Chromium, one of the major browser codebases, dropping support for it. In fact, HTTP 103 Early Hints was mentioned as one of the worthy potential replacements for this feature.

Previous proposal for HTTP/2 Server Push in hyper

As Server Push and HTTP 103 Early Hints are somewhat similar in that they elicit multiple responses for a single request, prior art for developing an API for Server Push can be used in Hyper to design an API for supporting emitting HTTP 103 responses.

One of the proposals was to add a hyper::push module that took &mut Request and allowed to push simulated requests into it to be executed in the background and pushed using the HTTP/2 Server Push mechanic:

async fn handle(mut req: Request<Body>) -> Result<Response<Body>, E> {
    match hyper::push::pusher(&mut req) {
        Ok(mut pusher) => {
            let promise = Request::builder()
                .uri("/app.js")
                .body(())
                .unwrap();
            if let Err(e) = pusher.push_request(promise).await {
                eprintln!("push failed: {}", e);
            }
        },
        Err(e) => eprintln!("http2 pusher unavailable: {}", e),
    }

    Ok(Response::new(index_page_stream()))
}

There were some questions tied around HTTP/2 (and HTTP/3) streams, which I assume would not be too relevant for Early Hints, since as the spec doesn't define any special behavior for HTTP/2 or HTTP/3, one would assume that the Early Hints response is to be sent on the same stream that would be used to eventually send the final response.

Designs for an Early Hints API

The following are some of the interesting designs for the new API, most of them suggested by other community members (and subsequently gathered and described by me here, enumerating their pros and cons).

API based on the old Server Push proposal

(original idea by @seanmonstar, rewritten by @vikanezrimaya)

As the Early Hints mechanic doesn't operate on requests, "push promises" or even complete responses full of data, the API surface in the original Server Push proposal needs to be changed slighly so instead of a simulated request the API user could generate a response with the HTTP 103 status code and send it via the new object:

async fn handle(mut req: Request<Body>) -> Result<Response<Body>, E> {
    let preload = r#"<https://example.com/style.css>; rel="preload"; as="style";"#;
    // NOTE: Mutably borrows the request, takes out the pusher
    // from extensions, and drops the mutable borrow
    match hyper::informational::early_hints_pusher(&mut req) {
        Ok(mut pusher) => {
            let hints = Response::builder()
                .status(StatusCode::EARLY_HINTS)
                .header("Link", preload)
                .body(())
                .unwrap();
                
            // The function should check responses passed to it to
            // only accept HTTP 103 responses without a body
            if let Err(e) = pusher.send_hints(hints).await {
                tracing::warn!("Sending HTTP 103 failed: {}", e);
            }
        },
        Err(e) => tracing::warn!("Failed to construct Early Hints pusher: {}", e);
    }
    
    // Send the same header in the final response so the browser knows
    // it is relevant to the final response
    Ok(Response::builder()
        .header("Link", preload)
        .body(index_page_stream())
        .unwrap())
}

Pros

  • Separates sending Early Hints from normal responses
  • Could be easily disabled by returning Err(e) instead of a pusher instance if the User Agent is known to not support it
  • Doesn't seem to require updating frameworks except a Hyper version bump; one could take advantage of the new functionality instantly

Cons

  • Requires creating a separate module
    • Hidden bonus: the module can be extended to handle more informational (1xx) responses

Appendix A: Abandoned ideas

Alternate idea: providing a "pusher" as a new argument

(by @acfoltzer)

We could add a variant of hyper::service::service_fn() whose closure takes an additional sender argument, i.e., FnMut(Request<R>, mpsc::Sender<Response<()>>) -> S. The service implementation would be free to send many interim responses, and would return the final response in the same way that existing service functions do. For example:

async fn my_service_fn(
    req: Request<Body>,
    mut interim_sender: mpsc::Sender<Response<()>>,
) -> Result<Response<Body>> {
    let early_hints = Response::builder()
        .status(103)
        .header("Link", "</style.css>; rel=preload; as=style")
        .header("Link", "</script.js>; rel=preload; as=script")
        .body(())?;
    interim_sender.send(early_hints).await?;
    let resp = todo!("build the final response")?;
    Ok(resp)
}

Pros

  • Doesn't require changing existing code

Cons

  • Frameworks such as axum may need to be updated to take advantage of this
  • Breaks backward compatibility: Requires updating the tower_service::Service trait

Sending a stream of responses

(original idea by @acfoltzer, new examples by @vikanezrimaya)

It might feel a little bit clunky, but may possibly allow some nice things combined with solutions such as the try_stream! macro from the async_stream crate:

async fn handle(mut req: Request<Body>) -> impl Stream<Item = Result<Response<Body>, E>> {
    try_stream! {
        let preload = r#"<https://example.com/style.css>; rel="preload"; as="style";"#;
        yield Ok(Response::builder()
            .status(StatusCode::EARLY_HINTS)
            .header("Link", preload)
            .body(())
            .unwrap();
            
        // Perform an expensive computation to get the page content
        // While this is done, the User Agent may act on the hint
        // and get the resources needed to render the page
        tokio::time::sleep(Duration::from_seconds(1)).await;
        
        yield Ok(Response::builder()
            .status(StatusCode::OK)
            .header("Link", preload)
            .body(index_page_stream())
            .unwrap())
    }
}

Of course, such a stream could also be created manually, if so required:

async fn handle(mut req: Request<Body>) -> impl Stream<Item = Result<Response<Body>, E>> {
    use futures::{stream, future};

    let preload = r#"<https://example.com/style.css>, rel="preload""#;

    stream::once(future::ok(Response::builder()
                 .status(StatusCode::EARLY_HINTS)
                 .header("Link", preload)
                 .body(())
                 .unwrap()))
        .chain(stream::once(async {
           // Perform an expensive computation... 
           tokio::time::sleep(Duration::from_seconds(1)).await;
           
           Ok(Response::builder()
               .status(StatusCode::OK)
               .header("Link", preload)
               .body(index_page_stream())
               .unwrap())
        }))
}

Pros

  • Combined with the try_stream! macro seems fairly intuitive
  • Can also be used without external dependencies

Cons

  • Needs updates from frameworks such as axum for users to take advantage of the new functionality
  • Breaks backwards compatibility: need to change tower_service::Service to accept call() -> impl Stream
  • The concept of async streams is unstable; can't depend on them in Hyper 1.0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment