The Wasm Component Model on Fastly Compute
http-downstream: Who's Actually Asking
August 21, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
"What's the client's IP address?" is the kind of question you'd expect request itself to answer—request.client_ip(), right there next to get-header-value. It doesn't. resource request only knows about the request it is: method, URL, headers, cache override. Everything about where that request came from and how it arrived lives somewhere else entirely: a separate interface, http-downstream, whose functions take a request as a parameter instead of hanging methods off of one.
Why a separate interface at all
http-downstream is enormous—close to forty functions once you count the TLS fingerprinting, bot detection, and VPN/proxy signals this post is skipping for now. All of them share the same split: request stays a small, general-purpose resource with the same shape whether it originated downstream from a client or was built fresh in your own code (as every request has been in this series so far, via request.new()). http-downstream's functions are specifically about a request that arrived from outside, and most of them just wouldn't mean anything for one you constructed yourself. Keeping that distinction as a separate interface, rather than optional fields or methods that trap on a synthetic request, means the type signature borrow<request> doesn't tell you where a request came from—only a function that explicitly asks "was this one downstream" can answer that.
This section covers the identity and observability slice: who's asking, from where, and how many times has this happened before. TLS handshake details, bot detection, and VPN/proxy signals are next time; reusable sandboxes' next-request/await-request, which also live in this interface, are a separate topic entirely and covered later.
The functions this section covers
interface http-downstream {
use types.{error, ip-address};
use http-req.{
request, client-cert-verify-result, error-with-detail, cache-override, pending-request,
request-with-body,
};
...
/// Returns the client request's header names exactly as they were originally received.
///
/// This includes both the original header name characters' cases, as well as the original order
/// of the received headers.
///
/// The first `cursor` names are skipped. The remaining names are encoded successively with
/// a NUL byte after each into a list of bytes at most `max-len` long. If any of the remaining
/// names don't fit, the returned `option<u32>` is the index of the first name that didn't fit,
/// or `none` if all the remaining names fit. If `max-len` is too small to fit any name,
/// an `error.buffer-len` error is returned, providing a recommended buffer size.
downstream-original-header-names: func(
ds-request: borrow<request>,
max-len: u64,
cursor: u32,
) -> result<tuple<string, option<u32>>, error>;
/// Returns the number of headers in the client request as originally received.
downstream-original-header-count: func(
ds-request: borrow<request>
) -> result<u32, error>;
/// Returns the IP address of the client making the HTTP request, if known.
downstream-client-ip-addr: func(
ds-request: borrow<request>
) -> option<ip-address>;
/// Returns the IP address on which this server received the HTTP request, if known.
downstream-server-ip-addr: func(
ds-request: borrow<request>
) -> option<ip-address>;
/// Gets the HTTP/2 fingerprint of client request if available.
downstream-client-h2-fingerprint: func(
ds-request: borrow<request>,
max-len: u64
) -> result<string, error>;
/// Gets the id of the current request if available.
downstream-client-request-id: func(
ds-request: borrow<request>,
max-len: u64
) -> result<string, error>;
/// Gets the fingerprint of client request headers if available.
downstream-client-oh-fingerprint: func(
ds-request: borrow<request>,
max-len: u64
) -> result<string, error>;
/// Returns whether the request was tagged as contributing to a DDoS attack.
downstream-client-ddos-detected: func(
ds-request: borrow<request>
) -> result<bool, error>;
...
/// Gets the compliance region that the client IP address is in.
downstream-compliance-region: func(
ds-request: borrow<request>,
max-len: u64
) -> result<option<string>, error>;
/// Returns whether or not the original client request arrived with a
/// Fastly-Key belonging to a user with the rights to purge content on this
/// service.
fastly-key-is-valid: func(
ds-request: borrow<request>,
) -> result<bool, error>;
...
/// Returns the number of times we have previously visited this service
/// as part of a service chain. This data is only reliable as long as the
/// message stays within Fastly infrastructure; if it leaves and returns,
/// it can be modified or spoofed.
downstream-visits-this-service: func() -> result<u64, error>;
/// Returns the number of times we have previously visited this POP
/// as part of a service chain. This data is only reliable as long as the
/// message stays within Fastly infrastructure; if it leaves and returns,
/// it can be modified or spoofed.
downstream-visits-this-pop: func() -> result<u64, error>;
}
The first ... skips next-request/await-request and their supporting types—the reusable-sandboxes feature, unrelated to what this section covers despite living in the same interface. The second and third skip the TLS fingerprinting and bot/VPN detection functions this post isn't getting to yet.
Three shapes worth noticing before diving in:
downstream-original-header-namesreturnsstring, notlist<u8>, even though it's NUL-delimited the same wayget-header-values(from Synthesizing a Response) is. That's not the doc-comment imprecision seen in the last two posts—it's a real, deliberate difference: header values can be arbitrary bytes, but header names are restricted to a much smaller character set by the HTTP spec itself, sostringis a safe promise to make about them specifically.downstream-client-ip-addranddownstream-server-ip-addrreturn plainoption<ip-address>—noerrorcase at all. There's nothing that can fail about asking "what IP is this"; either the platform knows or it doesn't.downstream-visits-this-serviceanddownstream-visits-this-popdon't take arequestparameter, unlike every other function here. They're not about a specific request at all—they're counters tracking this sandbox's position in a service chain, which is why they're free-standing.
Reading it directly
Full working code: full example on GitHub (opens in a new window).
fn call_growable<T>(
mut f: impl FnMut(u64) -> Result<T, http_downstream::Error>,
) -> Result<T, http_downstream::Error> {
let mut max_len: u64 = 256;
loop {
match f(max_len) {
Ok(v) => return Ok(v),
Err(http_downstream::Error::BufferLen(needed)) => max_len = needed,
Err(e) => return Err(e),
}
}
}
fn header_names(request: &http_incoming::Request) -> Result<Vec<String>, http_downstream::Error> {
let mut names = Vec::new();
let mut cursor: u32 = 0;
let mut max_len: u64 = 256;
loop {
match http_downstream::downstream_original_header_names(request, max_len, cursor) {
Ok((text, more)) => {
names.extend(
text.split('\0')
.filter(|chunk| !chunk.is_empty())
.map(|chunk| chunk.to_string()),
);
match more {
Some(next_cursor) => cursor = next_cursor,
None => return Ok(names),
}
}
Err(http_downstream::Error::BufferLen(needed)) => max_len = needed,
Err(e) => return Err(e),
}
}
}
let names = header_names(&request).map_err(|_| ())?;
let count = http_downstream::downstream_original_header_count(&request).map_err(|_| ())?;
let client_ip = format_ip(http_downstream::downstream_client_ip_addr(&request));
let server_ip = format_ip(http_downstream::downstream_server_ip_addr(&request));
let h2_fingerprint = call_growable(|max_len| {
http_downstream::downstream_client_h2_fingerprint(&request, max_len)
});
let request_id = call_growable(|max_len| {
http_downstream::downstream_client_request_id(&request, max_len)
});
let oh_fingerprint = call_growable(|max_len| {
http_downstream::downstream_client_oh_fingerprint(&request, max_len)
});
let ddos_detected = http_downstream::downstream_client_ddos_detected(&request);
let compliance_region = call_growable(|max_len| {
http_downstream::downstream_compliance_region(&request, max_len)
});
let fastly_key_valid = http_downstream::fastly_key_is_valid(&request);
let visits_service = http_downstream::downstream_visits_this_service();
let visits_pop = http_downstream::downstream_visits_this_pop();
Against Viceroy, a plain curl request with default headers:
downstream-original-header-count: 3
downstream-original-header-names: ["host", "user-agent", "accept"]
downstream-client-ip-addr: 127.0.0.1
downstream-server-ip-addr: 127.0.0.1
downstream-client-h2-fingerprint: Err(Error::GenericError)
downstream-client-request-id: Ok("00000000000000000000000000000000")
downstream-client-oh-fingerprint: Err(Error::GenericError)
downstream-client-ddos-detected: Ok(false)
downstream-compliance-region: Ok(Some("none"))
fastly-key-is-valid: Ok(false)
downstream-visits-this-service: Err(Error::Unsupported)
downstream-visits-this-pop: Err(Error::Unsupported)
A few things worth reading carefully rather than skimming past:
- The header names and count are real, live data—
curl's three default headers, in the order it actually sent them. Unlike geo or device detection, this part ofhttp-downstreamisn't backed by a Fastly-hosted database, so Viceroy has nothing to fake here. downstream-client-ip-addranddownstream-server-ip-addrboth show127.0.0.1, which checks out: Viceroy is a local loopback server, so the client and the server really are the same machine.h2-fingerprintandoh-fingerprintfail withGenericError—unsurprising over plain local HTTP/1.1, since there's no HTTP/2 handshake or the kind of connection-level signal these fingerprints are built from.downstream-compliance-regioncame backSome("none")—not thenoneoption case, but a literal string spelled"none", sitting insideSome. That's Viceroy's placeholder value standing in for "no real compliance data available locally," and it's easy to misread as the absence case if you're not looking closely at theSome(...)around it.downstream-visits-this-serviceanddownstream-visits-this-popboth fail withError::Unsupported—the same variant flagged, but not exercised, back in Dynamic Backends by Hand. Makes sense here too: there's no real service chain to have visited when everything is running on one local machine.
Beyond the WIT
request staying deliberately thin, with everything downstream-specific pushed into a second interface keyed on borrow<request>, is the same lesson body taught back in The Body Isn't Part of the Request (or the Response): this ABI keeps optional or origin-specific data out of a resource's core shape rather than bolting it on as fields that are sometimes meaningless. That has a real consequence for any binding sitting on top of it: a request object built by application code and one that arrived from Fastly can't be the same wrapper type if client_ip/ClientIp or request_id/RequestId are supposed to be plain properties or fields on it, because those would need to silently do nothing, or fail, on a request that was never downstream at all. The honest mapping keeps the split the ABI already drew rather than flattening it away for convenience—a distinct downstream-request type, or a separate accessor object, rather than one request type with half its members conditionally meaningful depending on how the request came to exist. It's a smaller, less dramatic version of the same resource-shape question this series keeps coming back to: what the ABI chooses to make structurally impossible to misuse is worth preserving in a binding, not designed around.