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 Error Model in Full

Modeling Your Domain Error taught you to model a domain error and read it from a caller. This is the exhaustive reference behind it: every type in capi_core::error and the three that feed it from below. You model one slot — Api — and the framework owns everything else in the taxonomy.

The top: FrameworkError and QueryError

The complete internal error is generic over the client’s error and your domain error, one arm per stage of a query:

pub enum FrameworkError<ClientErr: StdError, ApiErr: StdError> {
    Authorization(AuthError),                  // the seal could not produce a credential
    Request(RequestError),                     // on the way down: building the request
    Transport(TransportError<ClientErr>),      // the send produced no response
    Response(ResponseError<ApiErr>),           // on the way up: reading the response
}

Callers never handle those two generics. query() returns a QueryError: the same enum with both slots erased to a boxed StdError — anyhow-backed, so each stays downcastable — plus one optional slot of its own:

pub struct QueryError {
    error: FrameworkError<BoxStdError, BoxStdError>,
    head: Option<Box<ResponseHead>>,           // the response this failure was observed against
}

The head is attached by whoever observed a response and chose to keep it — the head-capturing runner does, on its error arm — so a caller reads a Retry-After, a rate-limit block, or a trailing metadata block off a failure the way it would off a success. Nothing attaches one by default, and a failure with no response to observe has none.

AuthError is capi_authentication’s boxed error, reached as capi_core::authorization::AuthError: a credential that could not be minted, renewed, or placed. TransportError is capi_middleware’s, because the traversal reports it; SendError and Refusal, the outcomes an adapter reports, are capi_context’s, because the budget an adapter enforces lives on the context. Every one of them lands in this enum without changing shape.

RequestError — building the request

Failures on the way down, before a byte leaves:

VariantFrom
Convert(ConvertError)an IntoEndpoint conversion — recovered typed by convert_error::<E>()
InvalidUrl(UrlError)a URL that could not be built
Header(HttpHeaderError)a header that failed validation
Params(ParamsError)encoding the query string
Body(BodyError)constructing or encoding the request body
Backoffthe backoff loop of a retrying send could not initialize or run
Io(std::io::Error)I/O during preparation — std on native and WASI only
Rngthe random source could not seed
Infallible(Infallible)the From impl that lets an infallible conversion use ?

Every arm maps to ErrorKind::InvalidRequest. Nothing was sent, and the same request fails the same way again.

TransportError<ClientErr> — the send produced no response

An adapter’s send answers with SendError<Client::Error>, and this is what that becomes one layer up, arm for arm — nothing folded together, so a caller reads the outcome without knowing which adapter was underneath:

VariantMeaningkind()
TimedOut {}the whole-call deadline passed before the response head arrivedTransport
Stalled {}one wait on the peer outlived the per-wait stall boundTransport
Cancelled {}the request’s cancel token firedCancelled
Refused { refusal }the client cannot enforce a knob the request carries; nothing was sentInvalidRequest
Client(ClientErr)the client failed on its own terms — DNS, TLS, a protocol violationTransport
Middleware(anyhow::Error)a pipeline member, the seal, or a transmit invariant refusedTransport

The first four are the framework’s own account of the send: an adapter reports them for what it observed and never spells them in its own error type. The enum is exhaustive, so a consumer mapping it is told when an arm joins, while those four arms are each #[non_exhaustive], so a fact can join one without a major version — build them with TransportError::timed_out() / stalled() / cancelled() / refused(r), and match them with ...

Refusal is one type shared by every adapter and wrapper, one variant per refused knob — Deadline, Stall, Cancel, each naming the refusing client — and #[non_exhaustive], since the vocabulary grows with the knobs clients can decline. It is the runtime backstop behind the compile-time markers: TimeoutCapable, StallCapable, and CancelCapable cover every request routed through a client, and a refusal fires only where a budget reaches an adapter by a path no marker guards — a channel-carried follow-up send, a hand-built context. A refusal is raised before any exchange, and retrying the same send against the same client refuses again; the fix is at the construction site the variant names.

ResponseError<ApiErr> — reading the response

Failures on the way up. This is the type your Endpoint::Error fills one slot of:

VariantShapeMeaning
Api{ error: ApiErr, annotations: Option<Box<ErrorAnnotations>> }your modeled domain error, with the signals captured at decode time
Bytestream(BytestreamError)the body stream itself failed
Decoding{ status: Option<StatusCode>, source }a body failed to parse; status when the decoder knew it, None on the From<CodecError> path and for a MapOutputError
UnexpectedStatus(StatusCodeError)a status the decoder had no reading for
Unrecognized(UnrecognizedError)an error status whose body is not your error shape

Api and Decoding are struct variants — construct them with the helpers, not tuple syntax: ResponseError::api(..), api_annotated(..), decoding(..), decoding_with_status(..), unexpected_status(..), unexpected_status_with_headers(..), unrecognized(status, body), and from_envelope_or_unrecognized(..), the status ladder’s rung. There is no serde variant: every codec failure routes through CodecError.

BytestreamError carries the same three budget outcomes the send path does — TimedOut, Stalled, Cancelled — for the case where the knob fired while the body was still arriving, and they classify identically on both paths, because where the transfer stopped says nothing about what stopped it. A body that stopped arriving (is_incomplete(): an unexpected EOF, a length mismatch, a truncated codec stream) is Transport and retryable, because another attempt may get all of it. A body that arrived and was wrong is Io — it will arrive wrong again.

pub enum StatusCodeError {
    Client(StatusCode),                                        // 4xx
    Redirection { status_code: StatusCode, location: String }, // 3xx, Location when present
    Server(StatusCode),                                        // 5xx
    Other(StatusCode),                                         // a 1xx or 2xx the decoder rejected
}

ErrorKind — the triage enum

QueryError::kind() collapses everything to one of twelve kinds, and the mapping is spelled out arm by arm rather than wildcarded, so a new arm has to be classified rather than silently reading as a network failure:

KindArms that land here
AuthenticationAuthorization(_)
InvalidRequestevery Request(_); Transport(Refused)
TransportTransport(TimedOut | Stalled | Client | Middleware); Response(Bytestream(TimedOut | Stalled)); an incomplete Bytestream
CancelledTransport(Cancelled); Response(Bytestream(Cancelled))
StatusClientUnexpectedStatus(Client); Unrecognized with a 4xx
StatusServerUnexpectedStatus(Server); Unrecognized with a 5xx; Decoding whose recorded status is a 5xx
StatusRedirectUnexpectedStatus(Redirection)
ApiResponse(Api)
DecodingDecoding with no status, or a non-5xx one
Ioany other Bytestream failure
OtherUnexpectedStatus(Other); Unrecognized with a 1xx/2xx/3xx
RetriesExhaustedreserved for a retry strategy that spent its attempts; the shipped retry runner hands back the last attempt’s own error instead, so no framework arm maps here today
#[repr(i32)]
pub enum ErrorKind {
    Authentication = 0, InvalidRequest = 1, Transport = 2, RetriesExhausted = 3,
    StatusClient = 4, StatusServer = 5, StatusRedirect = 6, Api = 7, Decoding = 8,
    Io = 9, Other = 10, Cancelled = 11,
}

The discriminants are written out and append-only, because a foreign-function binding mirrors them in its own header: a kind keeps the number it has and a new kind takes the next one. The enum stays exhaustive, so a binding that maps every kind is told by its compiler when one joins.

Retryability is two questions. ErrorKind::is_retryable() is true for Transport, StatusServer, and RetriesExhausted; RetryKind::is_retryable() is true for Transient and Throttling. QueryError::is_retryable() is the disjunction — the kind says so, or the decoder captured a RetryKind that does — so a modeled Api error the service marked throttling is retryable even though its kind is not. Cancelled is deliberately not Transport: the token stays fired, so no further attempt can succeed, and the retry runner stops on the first attempt instead of sleeping through a backoff schedule. A Refused send lands in InvalidRequest for the same reason.

The CodecError bridge

Wire-model failures cross into the error model through From<CodecError> impls, never a serde variant: CodecError → ResponseError::Decoding on the decode path (with no status — the ? in a decoder does not know one), and CodecError → BodyError / ParamsError → RequestError on the encode paths. The codec is the single seam between the wire model and the taxonomy.

Metadata and envelope types

The types that carry a domain error’s detail to callers who do not downcast:

  • FromErrorEnvelope<Env>from_envelope(Env) -> Self builds your E from a parsed envelope; from_status(u16) -> Option<Self> maps a bare status, defaulting to None. The ladder’s two rungs.
  • ProvideErrorMetadatacode(), message(), request_id(), and retry_kind(), each defaulting to None; implement the ones your API provides, and the capture site derives a status-based RetryKind when the body offers none.
  • ErrorMetadata{ code, message, request_id: Option<String> }, surfaced by QueryError::metadata().
  • ErrorAnnotations{ metadata: Option<ErrorMetadata>, retry: Option<RetryKind> }, the box the Api variant carries when the decoder opted in through from_envelope_or_unrecognized or api_annotated; empty otherwise, so the un-annotated path stays allocation-free.
  • UnrecognizedError{ status, body: Vec<u8> } with text(), preserving the raw error body when it is not your shape.
  • RetryKindTransient (5xx, request timeout: back off), Throttling (429: wait the delay), Terminal; RetryKind::from_status derives one from a code.

The accessor family

From a QueryError, in order of how much you want. Every accessor borrows — Option<&E>, Option<&ResponseHead> — so a match arm can ask several questions of one value:

err.kind();                          // ErrorKind — the cheapest triage
err.is_retryable();                  // kind or captured RetryKind says so
err.retry_kind();                    // Option<RetryKind> — how, when the decoder captured it
err.is_api() / is_auth() / is_decode() / is_io() / is_transport() / is_invalid_request();
err.is_status_client() / is_status_server() / is_rate_limited();
err.is_timed_out();                  // the whole-call deadline passed, head or body
err.is_stalled();                    // one wait on the peer outlived the stall bound
err.is_cancelled();                  // the request's cancel token fired
err.refusal();                       // Option<&Refusal> — the knob the client declined
err.status();                        // Option<StatusCode> — unexpected, unrecognized, or a decoding failure that recorded one
err.metadata();                      // Option<&ErrorMetadata> — code, message, request id
err.response_head();                 // Option<&ResponseHead> — when a head-capturing runner kept it
err.api_error::<MyError>();          // Option<&MyError> — the typed domain error
err.client_error(&api);              // Option<&Client::Error> — the adapter's own, its type inferred from the interface
err.convert_error::<E>();            // Option<&E> — an IntoEndpoint conversion failure
err.map_output_error::<E>();         // Option<&E> — a MapOutput failure
err.downcast::<C, A>();              // the whole FrameworkError<C, A> back, by value
err.to_log_string();                 // the one-line form

status() is None for a modeled Api error: its status lives in the domain type, and metadata() / retry_kind() are the views onto it. The budget four are the only way to tell those outcomes from an ordinary network failure, since a timeout and a stall share ErrorKind::Transport with a dropped connection.

Practice: triage with kind() and the is_* predicates for control flow — retry, surface, ignore — read metadata() for logging, and downcast with api_error::<E>() only when you need the full domain type. That two-level design is why Modeling Your Domain Error has you model one envelope: the framework turns it into both the cheap view and the rich one.

Next: the design rationale — which of the framework’s rules are hard requirements and which are best practices.