The Decoder Contract and the Status Ladder
The decoder is the machine on the way up the funnel: it turns a Response (a
response head plus a Bytestream body) back into your typed Output, or into the
domain error you just modeled. It has one load-bearing rule —
check the status first, then parse — and understanding that rule is most of what
you need to write or read one.
Marker-based dispatch
An endpoint names its decoder through the Decoder associated type — usually
BodyDecoder, the standard body decoder:
impl Endpoint for GetApiKey {
type Decoder = BodyDecoder;
// ...
}
impl DecodeBodyFn for GetApiKey {
fn decode_fn() -> BodyFnDecoder<Self> { GrokResponse::decode }
}
Decoder is a marker type, not the decode logic itself, and that indirection is
deliberate. The contract the pipeline actually drives is ResponseDecoder<Endpt>,
which the marker implements: it snapshots State from the endpoint before
body(self) consumes it, records what the response will decode into on the
request head in prepare_request, and receives the response — with the door for
any follow-up send — in decode. If decoders were selected by blanket impls, the
impls for “decode a body”, “decode a stream”, and “decode a paged response” would
overlap and the compiler would reject them; a marker per lane lets each endpoint
pick one unambiguously. An endpoint that needs the whole contract implements
ResponseDecoder on itself and declares type Decoder = Self.
The Decoder System in Depth is the contract in full;
this chapter is the buffered-body lane every JSON client lives in.
The status ladder
Every body decoder walks the same three-rung ladder. This is grok’s decode, and
it is the rule:
pub fn decode<T: for<'de> WireDecode<'de>, E: FromErrorEnvelope<GrokErrorEnvelope> + StdError>(
body: Vec<u8>, header: ResponseHead, config: &GrokConfig, _context: ApiContext,
) -> Result<T, ResponseError<E>> {
Self::check_response_status(&header)?; // rung 3: reject 1xx/3xx
if header.status.is_client_error() || header.status.is_server_error() {
return Err(build_api_error::<E>(&body, config, header.status)); // rung 2
}
config.json_lib().decode_from_slice::<T>(&body).map_err(Into::into) // rung 1
}
- 2xx → decode
T. Parse the body into your output type. A parse failure here is an honestResponseError::Decoding(theCodecErrorbridges via.map_err(Into::into)) — never a fabricatedApi. A 200 that doesn’t match your type is a decode bug, and the error says so. - 4xx/5xx → build the domain error. Hand off to
build_api_error, which produces yourApi(E)from the envelope; failing that, from the status alone throughFromErrorEnvelope::from_status(the rung a bodylessHEADneeds); failing that, anUnrecognizedthat preserves the raw status and bytes. - 1xx/3xx →
UnexpectedStatus. A status the decoder can’t model — including a redirect, surfaced asStatusCodeError::Redirection { status_code, location }.ResponseError::unexpected_status_with_headers(status, &headers)builds it with theLocationlifted off the head, so a client’s status check need not.
build_api_error is where the codec is named, and the only place:
pub(crate) fn build_api_error<E: FromErrorEnvelope<GrokErrorEnvelope> + StdError>(
body: &[u8], config: &GrokConfig, status: StatusCode,
) -> ResponseError<E> {
ResponseError::from_envelope_or_unrecognized(status, body, |bytes| {
let wire = config.json_lib().decode_from_slice::<GrokErrorWire>(bytes).ok()?;
GrokErrorEnvelope::from_wire(wire, status.as_u16())
})
}
from_envelope_or_unrecognized is the shared core ladder — the code calls it the
degradation ladder: you pass a closure that tries to parse the error body, and it
returns Api on Some, tries from_status on None, and falls to Unrecognized
last. A parsed envelope also carries its ErrorAnnotations (the code, the message,
the retry classification) across erasure into QueryError. The codec lives inside
the closure, so the core ladder stays codec-agnostic — the same reason
config accessors, not the core trait, hold json_lib().
The shared per-crate decoder
Notice GrokResponse is a zero-sized struct whose decode/empty are plain
associated functions. That’s the pattern: one shared decoder per crate, its
methods coerced to function pointers by DecodeBodyFn::decode_fn. Every endpoint
that returns JSON names GrokResponse::decode; every 204/no-content endpoint names
GrokResponse::empty (Output = (), success short-circuits, errors still ladder).
The status logic is written once and reused across the whole client.
The body-decode trait family
BodyDecoder dispatches to whichever of four traits you implement — pick exactly
one per endpoint:
| Trait | Use when |
|---|---|
DecodeBody | you want to write the decode inline on the endpoint |
DecodeBodyFn | you share a crate-wide decoder function (the common case) |
DecodeWrappedBodyFn | the wire body is a wrapper and Output is its inner field — decode the wrapper, return the inner |
TryDecodeWrappedBodyFn | same, but the unwrap is fallible |
The wrapped variants are how response-unwrapping works without a second type in your public API; endpoint shapes shows them in use.
Every one of the four carries a defaulted response_specs(config) hook. Declaring
it — Some(ResponseSpecs::wire::<Shipment, ParcelErrorWire>(config.json_lib())) for
the common JSON shape — states what a 2xx and a 4xx/5xx decode into before the
request is sent, and BodyDecoder writes the answer onto the request head. That is
what lets the recorder mask a response body’s sensitive fields structurally and
compare it under the model’s classes at replay; a decoder that declares nothing
falls back to comparing bytes through the content-type registry. Declare it once
per client, on the shared strategy below, when the convention fixes the codec and
the envelope.
Multi-format decoders
A client speaking more than one format writes one decode_<fmt> method per
format, each naming that format’s codec. aws_s3 decodes XML bodies:
pub fn decode_xml<T: for<'de> WireDecode<'de>, E: FromErrorEnvelope<S3ErrorEnvelope> + StdError>(
body: Vec<u8>, header: ResponseHead, config: &AmazonS3Config, _context: ApiContext,
) -> Result<T, ResponseError<E>> {
Self::check_response_status(&header)?;
if header.status.is_client_error() || header.status.is_server_error() {
return Err(build_api_error::<E>(&body, config, header.status));
}
config.xml_lib().decode_from_slice::<T>(&body).map_err(Into::into) // xml_lib, not json_lib
}
Same ladder, different codec accessor. The status handling is identical; only the
decode_from_slice call names a different format.
Strategy decoders and the orphan rule
BodyDecoder is BodyDecoder<S = ViaEndpoint>, generic over a
DecodeBodyStrategy. The default strategy, ViaEndpoint, forwards to the
endpoint’s own companion impl — the four traits above. Strategy types exist because
Rust’s orphan rule (E0210) forbids the blanket impls you’d otherwise reach for: a
strategy is a concrete type that carries the decode behaviour for a whole family of
endpoints, sidestepping the overlap while staying reusable. A client with a uniform
response convention names one per convention and aliases it —
pub type PaychexSingle = BodyDecoder<PaychexSingleResponse>; — so its endpoints
write type Decoder = PaychexSingle; and no companion impl at all. The strategy is
also where response_specs is best declared once. The Decoder System in
Depth shows the shape.
Two more response parsers live beside the decoders in capi_base_decoders:
ResponseBoundary::from_content_type(&bytes)?.split(body) splits a buffered
multipart/* response into its parts (the batching chapter’s envelopes go through
it), and LinkHeader::of(&headers).next(&base) reads pagination relations off a
response’s Link headers.
Two footguns
- A non-body decoder silently drops the error body. Stream, binary, and
WebSocket decoders don’t run the body ladder, so on a 4xx they’d lose your error
detail unless you override their
decode_errorhook to run the samebuild_api_error. grok’s streaming and download decoders do exactly that — reuse the sharedbuild_api_errorrather than re-deriving it. The chunked decoder has no such hook at all. (See Streaming.) - A
Decodingbuilt from?carries no status.From<CodecError>maps toResponseError::decoding(source), whosestatusisNone, because the conversion runs with no status in scope. That is fine on the 2xx rung — the status is known to be a success — but a hand-rolled decode error on any other path should be built withdecoding_with_status(status, source)so a caller’serr.status()still answers.
Decoders keep calling into json_lib() and decode_from_slice without ever saying
what those are. That’s the next part: the wire model — the layer
that turns bytes into your types and back, and the #[wire(...)] vocabulary that
shapes what these decoders parse into.