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

Modeling Your Domain Error

This is the framework’s central bargain, and idea #5 from the mental model: the framework owns every generic failure; you model exactly one thing — the parsed domain error your API returns in an error-status body. Everything else — a dropped connection, a body that won’t parse, an unexpected status, an error response that isn’t your API’s error shape — the framework already has a variant for. This chapter shows what you write, and what you get for free.

The division of labor

ResponseError<E> is the framework’s response-failure type, and E — your domain error — fills exactly one slot in it. The other variants are already populated for you:

VariantWhenWho fills it
Api { error, annotations }your API returned a parseable error bodyyou (E)
Decoding { status, source }a body failed to parseframework
UnexpectedStatus(StatusCodeError)a 1xx/3xx, or an unmodellable statusframework
Unrecognized(UnrecognizedError)an error status whose body isn’t your error shapeframework
Bytestream(BytestreamError)the body stream itself failedframework

A parse failure is Decoding, bridged from the wire model’s CodecError. And a 4xx whose body you can’t parse is not a fabricated Api; it’s an honest Unrecognized that preserves the raw status and bytes. You never have to invent an error for a shape you didn’t expect.

The four-piece recipe

Modeling a domain error is four small pieces. grok’s is the canonical minimal case.

1. A public, code-bearing envelope — what callers inspect. #[non_exhaustive] so you can add fields later:

#[derive(Debug, Clone, Error)]
#[non_exhaustive]
#[error("Grok API error (HTTP {http_status}): {message}")]
pub struct GrokErrorEnvelope {
    pub code: Option<String>,   // machine-readable discriminant, when present
    pub message: String,
    pub http_status: u16,       // injected from the response, not the body
}

2. An all-Option wire proxy that derives the wire decode, plus a from_wire that lifts it — returning Option so a body that isn’t your error becomes Unrecognized:

#[derive(Debug, WireModel, WireDecode)]
pub(crate) struct GrokErrorWire {
    #[wire(default)] pub code: Option<String>,
    #[wire(default)] pub error: Option<String>,
}

impl GrokErrorEnvelope {
    pub(crate) fn from_wire(wire: GrokErrorWire, http_status: u16) -> Option<Self> {
        if wire.code.is_none() && wire.error.is_none() {
            return None; // not a Grok error body → fall back to Unrecognized
        }
        Some(Self { code: wire.code, message: wire.error.unwrap_or_default(), http_status })
    }
}

The proxy decodes through the wire model, exactly like your success types — the error path is not a special case. Every field is optional so a partial or unexpected body never fails to decode; whether it’s really your error is from_wire’s judgment.

3. A #[non_exhaustive] domain enum — the E your endpoints name:

#[derive(Debug, Error)]
#[non_exhaustive]
pub enum GrokError {
    #[error("{0}")]
    Api(GrokErrorEnvelope),
}

4. Two trait impls wiring it into the ladder:

impl FromErrorEnvelope<GrokErrorEnvelope> for GrokError {
    fn from_envelope(envelope: GrokErrorEnvelope) -> Self { Self::Api(envelope) }
}
impl ProvideErrorMetadata for GrokErrorEnvelope {
    fn code(&self) -> Option<&str> { self.code.as_deref() }
    fn message(&self) -> Option<&str> {
        (!self.message.is_empty()).then_some(self.message.as_str())
    }
}

FromErrorEnvelope is what the decoder’s status ladder calls to build your E; ProvideErrorMetadata surfaces the code and message uniformly so they’re readable as metadata even by a caller who doesn’t downcast to your type.

Both traits carry provided methods you override only when the API earns it. FromErrorEnvelope::from_status(status) builds an error from the status alone, for error responses with no parseable body (a bodyless HEAD, say); it returns None by default, which is what makes the decoder fall back to Unrecognized. ProvideErrorMetadata additionally offers request_id() and retry_kind() — grok implements neither, because its errors carry no request id and its retry behavior is fully implied by the HTTP status. Implement them when your service does better.

The modeling ladder: as much as the API deserves

Domain-error modeling is additive — start at the floor and add only what the API actually distinguishes:

  • Floor — a single Api(Envelope) with a raw code: Option<String>, like grok. xAI publishes no stable code vocabulary, so a raw string is the honest model.
  • Named codes — when an API documents a fixed set, promote code to an enum so callers match instead of comparing strings.
  • Per-operation enums — when different operations return genuinely different error shapes, give each its own E. aws_s3 goes all the way here: seventeen per-operation error enums plus a shared S3Error for every operation the S3 model does not enumerate, all built from a single S3ErrorEnvelope (and its one wire proxy), because S3’s operations really do fail in operation-specific ways.

Model the floor first; climb only when the API rewards it. Two variations on the floor are worth knowing. An envelope that nests — Google’s { "error": { "code", "message", "status" } } — is modeled as an outer wire struct holding the inner one, with from_wire reading through the nesting (google_places’s GoogleErrorEnvelope and GoogleRpcStatus). And a service with no discriminant at all — a plain-text body on a 5xx — skips FromErrorEnvelope and builds the error directly with ResponseError::api(MyError::Server { status, body }) from the decoder, which is what icecast_connect does with the first 512 characters of the body.

What the caller gets

A consumer never sees ResponseError<E> directly — a query() returns Result<Output, QueryError>, a type-erased error that works the same regardless of E. From it they can:

  • Triage without downcastingerr.kind() returns an ErrorKind (Authentication, InvalidRequest, Transport, RetriesExhausted, StatusClient, StatusServer, Api, Decoding, Io, Cancelled, …), and the is_*() family reads ergonomically: err.is_rate_limited(), err.is_retryable(), err.is_transport(). Cancelled is the request’s own cancel token firing, and it is the one kind that is not a condition of the network: the token stays fired, so is_retryable() is false and the retry runner stops on the first attempt. The knobs that fire without ending the call for good keep Transport and read back through err.is_timed_out() / err.is_stalled(); a knob the client could not enforce at all is InvalidRequest, with err.refusal() naming it.
  • Read metadata genericallyerr.metadata() surfaces the code/message your ProvideErrorMetadata exposed, no downcast needed; it is populated when the decoder captured it through the envelope ladder, and None for non-Api errors. err.status() answers for an UnexpectedStatus, an Unrecognized, or a Decoding built with a status — for a modeled Api error the status lives in your envelope (envelope.http_status), so status() is None there and the domain type is where to read it. err.response_head() hands back the whole observed head when a query failed after one arrived.
  • is_retryable() reads more than the kind. The ladder captures a retry classification with every modeled error — Throttling for a 429, Transient for 408, 500, 502, 503, and 504, or whatever the envelope’s own retry_kind() says — so an Api error can be retryable too; is_rate_limited() is that classification or a 429 status.
  • Recover the typed errorerr.api_error::<GrokError>() downcasts and returns Option<&GrokError> for callers who do want the full domain type:
match api.query(endpoint).await {
    Ok(response) => { /* … */ }
    Err(err) => {
        if err.is_rate_limited() { /* back off and retry */ }
        if let Some(GrokError::Api(envelope)) = err.api_error::<GrokError>() {
            eprintln!("HTTP {}: {} (code {:?})", envelope.http_status, envelope.message, envelope.code);
        }
    }
}

That two-level design — cheap kind()/is_*() triage for everyone, api_error::<E>() recovery for those who need it — is why you model only the one envelope: the framework turns it into both. The machine that drives this ladder — deciding status-first, calling from_envelope, falling back to Unrecognized — is the decoder, next.