Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 honest ResponseError::Decoding (the CodecError bridges via .map_err(Into::into)) — never a fabricated Api. 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 your Api(E) from the envelope; failing that, from the status alone through FromErrorEnvelope::from_status (the rung a bodyless HEAD needs); failing that, an Unrecognized that preserves the raw status and bytes.
  • 1xx/3xx → UnexpectedStatus. A status the decoder can’t model — including a redirect, surfaced as StatusCodeError::Redirection { status_code, location }. ResponseError::unexpected_status_with_headers(status, &headers) builds it with the Location lifted 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:

TraitUse when
DecodeBodyyou want to write the decode inline on the endpoint
DecodeBodyFnyou share a crate-wide decoder function (the common case)
DecodeWrappedBodyFnthe wire body is a wrapper and Output is its inner field — decode the wrapper, return the inner
TryDecodeWrappedBodyFnsame, 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_error hook to run the same build_api_error. grok’s streaming and download decoders do exactly that — reuse the shared build_api_error rather than re-deriving it. The chunked decoder has no such hook at all. (See Streaming.)
  • A Decoding built from ? carries no status. From<CodecError> maps to ResponseError::decoding(source), whose status is None, 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 with decoding_with_status(status, source) so a caller’s err.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.