The Wasm Component Model on Fastly Compute

Framing Headers: Who Decides Where a Body Ends

EDIT (2026-10-02): corrected which host broke the doc comment's promises about a mismatched Content-Length. Viceroy did truncate a body that was too long; only the real service didn't.

The natural guess is that Content-Length is a header like any other: set it, and it goes out with the response. Set it to 999 on a six-byte body and see what happens:

Terminal output
HTTP/1.1 200 OK
content-length: 6

hello

The header you set is gone, and a correct one took its place. Unless you ask otherwise, the two headers that say where an HTTP/1.1 message body ends aren't yours to set. This post covers how to ask, what happens when you get it wrong, and three more request and response functions that have to do with what actually goes over the wire: automatic decompression, the address a backend response came from, and keepalive.

Automatic, unless you say otherwise

http-types defines the choice:

WIT
/// Determines how the framing headers (`Content-Length`/`Transfer-Encoding`) are set for a
/// request or response.
enum framing-headers-mode {
  /// Determine the framing headers automatically based on the message body, and discard any
  /// framing headers already set in the message. This is the default behavior.
  ///
  /// In automatic mode, a `Content-Length` is used when the size of the body can be determined
  /// before it is sent. Requests/responses sent in streaming mode, where headers are sent
  /// immediately but the content of the body is streamed later, will receive a
  /// `Transfer-Encoding: chunked` to accommodate the dynamic generation of the body.
  automatic,

  /// Use the exact framing headers set in the message, falling back to `automatic` if invalid.
  ///
  /// In “from headers” mode, any `Content-Length` or `Transfer-Encoding` headers will be honored.
  /// You must ensure that those headers have correct values permitted by the
  /// [HTTP/1.1 specification]. If the provided headers are not permitted by the spec, the headers
  /// will revert to automatic mode and a log diagnostic will be issued about what was wrong. If a
  /// `Content-Length` is permitted by the spec, but the value doesn't match the size of the
  /// actual body, the body will either be truncated (if it is too long), or the connection will
  /// be hung up early (if it is too short).
  ///
  /// [HTTP/1.1 specification]: https://www.rfc-editor.org/rfc/rfc7230#section-3.3.1 (opens in a new window)
  manually-from-headers
}

Both request and response have a setter for it, with the same signature:

WIT
/// Sets how the framing headers `Content-Length` and `Transfer-Encoding` will be determined
/// when sending this response.
set-framing-headers-mode: func(mode: framing-headers-mode) -> result<_, error>;

automatic explains the 999. The body was six bytes and known before sending, so the platform discarded the Content-Length it was given and wrote its own.

manually-from-headers hands the job to you, and the doc comment says what happens for each way you might get it wrong:

  1. Headers the HTTP/1.1 specification doesn't permit, such as both Content-Length and Transfer-Encoding at once, fall back to automatic mode.
  2. A Content-Length shorter than the body truncates the body.
  3. A Content-Length longer than the body hangs up the connection early.

Getting it wrong on purpose

Full working code: full example on GitHub (opens in a new window).

The example answers a different path for each case. The framing cases all send the same six-byte body, hello\n, with different headers:

Rust

mod bindings;

use std::net::IpAddr;

use bindings::{
    exports::fastly::compute::http_incoming,
    fastly::compute::{backend, http_body, http_req, http_resp, http_types, types},
};

use http_types::{ContentEncodings, FramingHeadersMode};

fn text_body(text: &str) -> Result<http_body::Body, ()> {
    let body = http_body::new().map_err(|_| ())?;
    http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
    Ok(body)
}

/// A six-byte body, sent with the framing mode and headers given.
fn framed(mode: FramingHeadersMode, headers: &[(&str, &str)]) -> Result<(), ()> {
    let response = http_resp::Response::new().map_err(|_| ())?;
    for (name, value) in headers {
        response.append_header(name, value.as_bytes()).map_err(|_| ())?;
    }
    response.set_framing_headers_mode(mode).map_err(|_| ())?;
    http_resp::send_downstream(response, text_body("hello\n")?).map_err(|_| ())
}

And the routes that call it:

Rust
use FramingHeadersMode::{Automatic, ManuallyFromHeaders};
match path {
    // A wrong Content-Length, which automatic mode throws away.
    "auto" => framed(Automatic, &[("content-length", "999")]),
    "manual" => framed(ManuallyFromHeaders, &[("content-length", "6")]),
    "manual-short" => framed(ManuallyFromHeaders, &[("content-length", "3")]),
    "manual-long" => framed(ManuallyFromHeaders, &[("content-length", "50")]),
    // Both framing headers at once, which HTTP/1.1 doesn't permit.
    "manual-invalid" => framed(
        ManuallyFromHeaders,
        &[("content-length", "6"), ("transfer-encoding", "chunked")],
    ),
    "stream" => streamed(),
    "gzip" => gzip(),
    "accept-encoding" => accept_encoding(),
    "no-keepalive" => no_keepalive(),
    _ => {
        let response = http_resp::Response::new().map_err(|_| ())?;
        response.set_status(404).map_err(|_| ())?;
        http_resp::send_downstream(response, text_body("not found\n")?).map_err(|_| ())
    }
}

I ran each path with curl --http1.1, under Viceroy and against the same code published to a real Compute service:

Path What was sent Viceroy Real service
/auto Content-Length: 999 content-length: 6, hello Content-Length: 6, hello
/manual Content-Length: 6 hello hello
/manual-short Content-Length: 3 hel No body bytes at all, then the connection closes
/manual-long Content-Length: 50 hello, then nothing until curl gives up hello, then nothing until curl gives up
/manual-invalid Both framing headers Empty reply Falls back: Content-Length: 6, hello

The first two rows are the easy part. Automatic mode replaced the wrong length on both hosts, and a correct manual length went out as given.

The invalid case is where the doc comment's first promise holds on the real service: with both headers set, it fell back to automatic framing and sent a correct Content-Length. Viceroy instead closed the connection without replying. Viceroy's support for manual framing has had a long-open tracking issue, fastly/Viceroy#179 (opens in a new window), which says this area is subtle "due to interactions with Hyper."

The two mismatched lengths are where the real service stops matching the doc comment. With a Content-Length of 3, Viceroy truncated the body to hel, as promised. The real service sent the headers, then no body bytes at all, and closed the connection. curl reported "end of response with 3 bytes missing." I tried it several times, with the same result each time. With a Content-Length of 50, both hosts sent the six bytes and then kept the connection open. On the real service it was still open two minutes later, when curl's timeout ended the wait. The doc comment says the connection gets hung up early. In practice, a client that trusts the length you gave it is left waiting for bytes that are never coming.

Over HTTP/2, which is what a plain curl to the real service negotiates, none of this applies. HTTP/2 marks the end of a body in its own framing, and a Content-Length that disagrees with it is a protocol error. Both mismatched cases failed immediately with stream errors instead.

Streaming gets chunked

The doc comment's other automatic case is streaming. send-downstream-streaming sends the headers before the body exists:

Rust
/// Starts the response, then writes the body in three pieces after it's
/// already on its way.
fn streamed() -> Result<(), ()> {
    let response = http_resp::Response::new().map_err(|_| ())?;
    let body = http_body::new().map_err(|_| ())?;
    http_resp::send_downstream_streaming(response, &body).map_err(|_| ())?;
    for piece in ["one\n", "two\n", "three\n"] {
        http_body::write(&body, piece.as_bytes()).map_err(|_| ())?;
    }
    http_body::close(body).map_err(|_| ())
}

The length can't be known in advance, so it goes out chunked. With curl --raw you can see the chunk sizes, in hexadecimal. From the real service:

Terminal output
HTTP/1.1 200
transfer-encoding: chunked

4
one

a
two
three

0

Viceroy sent one chunk per write. The real service combined the second and third writes into a single ten-byte chunk (a). Chunk boundaries are the platform's business, not the program's, and nothing downstream should depend on them.

Decompressing a backend's response

request has one more setting that changes what comes back from a backend:

WIT
/// Sets the content encodings to automatically decompress responses to this request.
///
/// If the response to this request is encoded by one of the encodings set by this method, the
/// response will be presented to the Compute program in decompressed form with the
/// `Content-Encoding` and `Content-Length` headers removed.
///
/// Not all of the flags defined in `content-encodings` are supported. Currently the only
/// supported flag is `content-encodings.gzip`.
set-auto-decompress-response: func(
  encodings: content-encodings,
) -> result<_, error>;

content-encodings is a flags type, like Core Cache's lookup-state: a set of named bits, any combination of which can be on at once. This one lists six codings, from gzip and br through zstd, two dictionary-compressed variants, and aes128gcm. It's a general-purpose type shared across the ABI, and for this function only gzip means anything.

The example's /gzip path fetches http-me's gzipped JSON twice, once without decompression and once with it. From the real service:

Terminal output
auto-decompress false:
  content-encoding: gzip
  content-length: 246
  246 bytes, starting [1f, 8b, 08, 00]
  remote address: Some(2a04:4e42:15::347), port Some(443)
auto-decompress true:
  content-encoding: (none)
  content-length: (none)
  279 bytes, starting [7b, 0a, 20, 20]
  remote address: Some(2a04:4e42:15::347), port Some(443)

Without it, the body starts with 1f 8b, the gzip signature. With it, the body is plain JSON starting with {, and both headers are gone, exactly as the doc comment says.

The doc comment leaves out one thing. The example's /accept-encoding path turns decompression on without setting Accept-Encoding at all, and sends the request to http-me's echo endpoint. On the real service, the request that arrived had a header the program never set:

Terminal output
"accept-encoding": "gzip",

The platform asks the backend for gzip on your behalf. Fastly's Rust SDK documents this on its set_auto_decompress_gzip, which says it sets Accept-Encoding "regardless of the original value in the request." The SDK only passes the flag through, so it's describing what the host does. Viceroy doesn't add the header, and its echoed request had no Accept-Encoding at all.

Where the response came from

Those runs also printed an address:

WIT
/// Gets the destination IP address used for this response, if known.
get-remote-ip-addr: func() -> option<ip-address>;

/// Gets the destination port used for this response, if known.
get-remote-port: func() -> option<u16>;

The example uses send-uncached for these requests, and both hosts reported the backend's address. With plain send instead, the real service returned none for both. The Rust SDK's get_backend_addr documents the same thing: it's None "when the response is synthetic or cached." A response from send comes through the readthrough cache even when nothing was stored, so "if known" in the doc comment means it.

Keepalive

The last setting is on response alone:

WIT
/// Adjust the response's connection reuse mode.
set-http-keepalive-mode: func(mode: keepalive-mode) -> result<_, error>;
WIT
enum keepalive-mode {
  automatic,
  no-keepalive,
}

The enum has no doc comments. The matching HttpKeepaliveMode in the Rust SDK's companion crate, fastly-shared, describes no-keepalive as sending Connection: close in HTTP/1 and a GOAWAY frame in HTTP/2 and HTTP/3, to get the client to open a new connection for its next request.

The example's /no-keepalive path asks for it. The real service answered Error::Unsupported, and Viceroy answered Error::GenericError. The Rust SDK's set_http_keepalive_mode is hidden from its documentation, can't fail, and says Compute "may not always respect" the mode, falling back to automatic.

Beyond the WIT

A doc comment is a promise, and on a real service this post found two it doesn't keep. A body longer than its Content-Length didn't come out truncated. It didn't come out at all. And a body shorter than its Content-Length didn't hang up the connection. It left the client waiting.

What should a binding do with that? One reasonable answer is to never let the mismatch reach the host. When a binding offers manual framing, it usually knows the body's length before sending, because a non-streaming body has already been written. The binding can compare the Content-Length it's about to send against that length and fail in the guest, with an error that says what's wrong, instead of leaving the outcome to whatever the platform does with an inconsistent message. Only a streaming body needs the platform to enforce the length, and that's the case where a guest can't check anything in advance.

The auto-decompress header is a different kind of gap. It's behavior the platform has and the WIT doesn't mention, a side effect of a setting. A binding that exposes the setting owes its users the missing sentence, the way the Rust SDK's documentation supplies it, because a program that sets its own Accept-Encoding and then turns on decompression has had its header replaced without being told.

And set-http-keepalive-mode is a setting the platform is allowed to refuse, and did. A binding can pass the refusal up as an error, or do what the Rust SDK does and treat the mode as a hint that may be ignored. The hint model is honest about what the setting can promise, as long as the binding documents that, and the SDK does.

Next: bodies again. write has a sibling that writes to the front, and a body can carry headers of its own, after the data.