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:
| Variant | From |
|---|---|
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 |
Backoff | the 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 |
Rng | the 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:
| Variant | Meaning | kind() |
|---|---|---|
TimedOut {} | the whole-call deadline passed before the response head arrived | Transport |
Stalled {} | one wait on the peer outlived the per-wait stall bound | Transport |
Cancelled {} | the request’s cancel token fired | Cancelled |
Refused { refusal } | the client cannot enforce a knob the request carries; nothing was sent | InvalidRequest |
Client(ClientErr) | the client failed on its own terms — DNS, TLS, a protocol violation | Transport |
Middleware(anyhow::Error) | a pipeline member, the seal, or a transmit invariant refused | Transport |
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:
| Variant | Shape | Meaning |
|---|---|---|
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:
| Kind | Arms that land here |
|---|---|
Authentication | Authorization(_) |
InvalidRequest | every Request(_); Transport(Refused) |
Transport | Transport(TimedOut | Stalled | Client | Middleware); Response(Bytestream(TimedOut | Stalled)); an incomplete Bytestream |
Cancelled | Transport(Cancelled); Response(Bytestream(Cancelled)) |
StatusClient | UnexpectedStatus(Client); Unrecognized with a 4xx |
StatusServer | UnexpectedStatus(Server); Unrecognized with a 5xx; Decoding whose recorded status is a 5xx |
StatusRedirect | UnexpectedStatus(Redirection) |
Api | Response(Api) |
Decoding | Decoding with no status, or a non-5xx one |
Io | any other Bytestream failure |
Other | UnexpectedStatus(Other); Unrecognized with a 1xx/2xx/3xx |
RetriesExhausted | reserved 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) -> Selfbuilds yourEfrom a parsed envelope;from_status(u16) -> Option<Self>maps a bare status, defaulting toNone. The ladder’s two rungs.ProvideErrorMetadata—code(),message(),request_id(), andretry_kind(), each defaulting toNone; implement the ones your API provides, and the capture site derives a status-basedRetryKindwhen the body offers none.ErrorMetadata—{ code, message, request_id: Option<String> }, surfaced byQueryError::metadata().ErrorAnnotations—{ metadata: Option<ErrorMetadata>, retry: Option<RetryKind> }, the box theApivariant carries when the decoder opted in throughfrom_envelope_or_unrecognizedorapi_annotated; empty otherwise, so the un-annotated path stays allocation-free.UnrecognizedError—{ status, body: Vec<u8> }withtext(), preserving the raw error body when it is not your shape.RetryKind—Transient(5xx, request timeout: back off),Throttling(429: wait the delay),Terminal;RetryKind::from_statusderives 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.