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:
| Variant | When | Who fills it |
|---|---|---|
Api { error, annotations } | your API returned a parseable error body | you (E) |
Decoding { status, source } | a body failed to parse | framework |
UnexpectedStatus(StatusCodeError) | a 1xx/3xx, or an unmodellable status | framework |
Unrecognized(UnrecognizedError) | an error status whose body isn’t your error shape | framework |
Bytestream(BytestreamError) | the body stream itself failed | framework |
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 rawcode: 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
codeto an enum so callersmatchinstead of comparing strings. - Per-operation enums — when different operations return genuinely different
error shapes, give each its own
E.aws_s3goes all the way here: seventeen per-operation error enums plus a sharedS3Errorfor every operation the S3 model does not enumerate, all built from a singleS3ErrorEnvelope(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 downcasting —
err.kind()returns anErrorKind(Authentication,InvalidRequest,Transport,RetriesExhausted,StatusClient,StatusServer,Api,Decoding,Io,Cancelled, …), and theis_*()family reads ergonomically:err.is_rate_limited(),err.is_retryable(),err.is_transport().Cancelledis 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, sois_retryable()is false and the retry runner stops on the first attempt. The knobs that fire without ending the call for good keepTransportand read back througherr.is_timed_out()/err.is_stalled(); a knob the client could not enforce at all isInvalidRequest, witherr.refusal()naming it. - Read metadata generically —
err.metadata()surfaces the code/message yourProvideErrorMetadataexposed, no downcast needed; it is populated when the decoder captured it through the envelope ladder, andNonefor non-Apierrors.err.status()answers for anUnexpectedStatus, anUnrecognized, or aDecodingbuilt with a status — for a modeledApierror the status lives in your envelope (envelope.http_status), sostatus()isNonethere 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 —Throttlingfor a 429,Transientfor 408, 500, 502, 503, and 504, or whatever the envelope’s ownretry_kind()says — so anApierror can be retryable too;is_rate_limited()is that classification or a 429 status.- Recover the typed error —
err.api_error::<GrokError>()downcasts and returnsOption<&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.