errlanes
Errors communicate that something went wrong up the call stack. What went wrong can vary widely: a caller supplied an invalid amount, a user lacks permission, a database transaction hit a deadlock, or the application encountered corrupt state. Each calls for a different response, often from a different part of the application. Representing that information so callers can decide what to do becomes harder as errors pass through several layers.
This crate takes an opinionated approach: categorize errors into four lanes, each with a distinct meaning for the caller. The layer that understands an error assigns its lane; callers can then handle or propagate it without having to interpret the original error again.
The lanes are represented by two carrier enums. Leaving lane selection aside for a moment, their structure looks like this:
use ;
Fault carries the three outcomes whose handling does not depend on a domain
error type. Fail adds Rejected, carrying an application-defined type R so
callers can distinguish individual domain cases. Both preserve the underlying
source for diagnosis.
Which carrier a function returns is itself information: a Fault says that
nothing about this call is the caller's to correct.
Faults and lane selection
A particular operation will usually produce only some of these outcomes.
For example, a storage function might return Transient or Fatal but have no
reason to deny access. Its signature can express that with
Fault<lanes!(Transient, Fatal)>.
The lanes! macro selects which fault lanes a type permits. That selection
tells callers what they need to handle. It can change as an error travels up
the call stack: a caller may introduce an additional outcome or take
responsibility for handling one.
A disabled lane is uninhabited, so no value can ever occupy it. A by-value match therefore names exactly the lanes that were selected:
use ;
// With a single lane left, there is nothing to match at all.
# let _ = describe;
# let _ = unwrap_fatal;
Lane order does not matter, and lanes!() with no names disables all fault
lanes. lanes! names only the three fault lanes; the Rejected lane is selected
by which carrier a function uses, Fail or Fault.
Matching a borrowed failure still requires an arm for every variant, so use an
accessor instead. as_denied, as_transient, and as_fatal are available
only when the profile enables that lane, Fail adds as_rejected, and
lane() works on any profile.
Example: Authorization adds a lane
Consider an inner operation that can only fail fatally. An outer function checks whether the subject is allowed to perform it before calling it. The outer function can therefore return Denied as well as Fatal:
use ;
>
>
assert_eq!;
assert!;
The authorization failure enters the Denied lane through ?. The inner
operation's result uses .widen()? to fit the outer function's larger set of
lanes. This method comes from ResultExt, and its destination is inferred
from the return type. An inner Fatal remains Fatal, with its kind, context and
source intact.
Example: Retrying handles a lane
The reverse situation occurs when an inner operation can return Transient,
but its caller owns retrying the operation. After handling Transient, that
caller only needs to expose Fatal to its own callers. narrow_transient turns
the attempt count into an outcome: a transient that never succeeded becomes
Fatal(Exhausted), keeping the last transient as its source.
use ;
const BUDGET: u32 = 3;
>
// A transient that later succeeds never reaches the caller.
let mut attempts = 0;
let value = execute;
assert_eq!;
assert_eq!;
// One that never succeeds is spent, and says so.
let spent = execute.unwrap_err;
let Fatal = spent;
assert_eq!;
Here, execute takes the inner operation as a closure so it can call it again.
Its return type omits Transient because the loop handles that case: narrowing
consumed the lane, so lanes!(Transient, Fatal) went in and lanes!(Fatal)
came out. Widening alone cannot remove a lane; the caller must account for the
outcome. Because a narrowed profile needs somewhere to put an exhaustion,
lanes!(Transient) on its own is neither narrowable nor retryable.
The loop illustrates who handles Transient. In practice, that owner also decides whether repeating the operation is safe and when to stop retrying. Widening only ever adds lanes and keeps every value; narrowing removes one lane and says what its value becomes.
Transient, and the narrower question
is_transient() answers "is this unit of work safe to re-run, later, from the
outside?" — every TransientKind says yes, a lost connection or a pool
timeout as much as a deadlock. is_contention() answers a narrower one: "did
Postgres confirm this attempt lost a race?" — Deadlock (40P01) or
SerializationFailure (40001), and nothing else. Both predicates exist on
TransientKind, Transient, Fault and Fail.
The narrow one is for the two places the broad one is wrong. Retrying at
COMMIT: only a server-confirmed abort guarantees the transaction rolled
back; any other error there is ambiguous, since the server may have committed
before the client saw it. And a batch search deciding whether to split a
failing range: contention says nothing about the data, only about the
interleaving, so splitting cannot isolate anything and may provoke the same
cycle again — whereas an OptimisticConflict names one stale row and is
worth splitting for, which is why it is transient but not contention.
Narrowing a lane
widen has a dual. Where widen adds lanes losslessly and never drops one,
narrow_<lane> removes exactly one lane and turns its value into a Fatal —
the only lane left standing once nobody can act on the removed one. There is
one narrowing per lane that can reach a boundary with nobody left to help it:
| narrowed lane | what the value is afterwards | method | FatalKind |
|---|---|---|---|
Transient |
a transient that ran out of retries | narrow_transient(attempts) |
Exhausted |
Denied |
still a denial, fatal only because nobody can be told | narrow_denied() |
Denied |
Rejected(D) |
a caller-correctable outcome with no caller, which is a bug | narrow_rejected() |
Invariant |
Each FatalKind names what the value truly is afterwards, never a guessed
cause — the same rule Exhausted already followed before it had two
siblings. A narrowing is always a method call, never a From: a ? that
silently dropped a lane is exactly what the widening rule already forbids, so
narrowing cannot happen by accident either. The same three verbs are also
available directly on a Result through ResultExt, so a call site that is
already holding one does not have to .map_err() into the value-level form.
Handling a rejection where it occurs
narrow_rejected is for a boundary with no caller left to correct the
rejection. Where the caller is right there, rejected() hands it the
rejection as a value instead: Result<T, Fail<D, L>> becomes
Result<Result<T, D>, Fault<L>>. The outer ? keeps the faults propagating;
the inner Result is the domain outcome, matched on the spot.
use ;
;
>
>
assert!;
There is deliberately no Option-returning as_rejected() on a Result: it
would answer None for both a success and a fault, the one place a lane could
be dropped without being named. On the error value itself, where there is no
success to conflate, Fail::as_rejected and Fail::rejected remain.
At a Box<dyn Error> boundary
Fatal and Transient store their source as Arc<dyn Error + Send + Sync>
so a Fault or Fail can cross a thread or task spawn — this is load-bearing
and not a bound to work around. A boundary that only has a plain Box<dyn Error> (a trait object with no Send/Sync bound, e.g. from a caller whose
own trait does not require it) therefore cannot move that error into a lane
payload's source. Fault::classify is the one call for that case: it only
ever borrows the error, so no Send/Sync bound is needed to call it.
use Fault;
let boxed: = Boxnew;
let fault: Fault = classify;
assert!;
Fault::classify tries, in order: a lane payload anywhere in the error's
source() chain, carried through with its kind, context and (now cloned out,
so Send + Sync again) its own source intact; then, for each blessed foreign
type whose classify-* feature is enabled (sqlx::Error, serde_json::Error,
reqwest::Error, in that order), the first one found anywhere in the chain,
classified exactly as that feature's impl Classify would; then
Fatal(Dependency), with the error's Display chain as its context, so an
error this function cannot otherwise classify still surfaces as something a
boundary pages on, never silently as nothing.
Call Fault::classify before the error crosses an .await or a spawn, not
after — a Box<dyn Error> with no Send bound on its trait object cannot be
held across either:
// Inside a boundary whose own trait returns `Box<dyn Error>`, not
// `Box<dyn Error + Send + Sync>`:
let fault: Fault> = match runner.run.await ;
// `fault` is `Send + Sync` from here on, and can cross a spawn.
persist.await?;
Local errors
Every local error type says, through one trait, how it enters the lanes:
A type is one of three shapes, never more than one:
| type | Rejected |
Lanes |
how it is written |
|---|---|---|---|
| a domain outcome | itself | none | #[derive(errlanes::Rejection)] |
| a fault wrapper | Infallible |
the lane(s) it names | #[derive(errlanes::Classify)] #[classify(fatal(Kind))] |
| a mixed wrapper | a domain outcome | the lane(s) its faulty variants name | #[derive(errlanes::Classify)], variant by variant |
A type is a Rejection or it implements Classify directly, never both —
the blanket impl<R: Rejection> Classify for R makes a second, direct impl
conflict (E0119). The split is principled: a rejection's Display may embed
caller input and is never operator-facing; a fault's Display is exactly what
an operator-facing message must show.
A pure domain outcome — one that is never a fault, only ever rejected — is the
common case and the one most code writes. derive(Rejection) gives each
outcome a stable code() and a recording level(); Classify then comes
from the blanket above, not from a second derive:
use ;
let rejection = InvalidAmount;
let public_code: &'static str = rejection.code.into;
assert_eq!;
assert_eq!;
The enum variant lets Rust callers match the case. The code lets an API expose
a stable identity and lets telemetry group occurrences of the same outcome,
even if its human-readable message changes. Codes remain typed inside the
application and can be converted to strings at the API boundary. Never build
either from Display: a rejection's message may embed caller-supplied input.
The level tells the recording boundary how severely to log the rejection.
Info is the default because rejections are expected domain outcomes. A case
that needs different operational visibility can override it with, for example,
#[rejection(code = "INVALID_AMOUNT", level = "warn")]. Its lane is still Rejected.
Display and Error
#[derive(errlanes::Rejection)] and #[derive(errlanes::Classify)] emit
std::fmt::Display and std::error::Error themselves — no thiserror, no
second derive. A rejection's Display defaults to its code (the one thing
above that is never caller-supplied input); a laned wrapper's Display
defaults to its type or variant name in snake_case. source() is the
#[source]-marked field, a field named source, or a delegate/from
payload — never a bare field, since that would need guessing.
use Rejection;
assert_eq!;
assert_eq!;
A type that already has Display/Error from elsewhere — thiserror, or a
hand-written impl — keeps them with error = manual: errlanes then emits
neither, and ignores any #[error]/#[source] it finds.
;
assert_eq!;
Fault wrappers and mixed wrappers
The other two shapes are written with #[derive(errlanes::Classify)] instead.
A fault wrapper names exactly one lane and never rejects — a struct wrapping a
foreign error is the common case:
use ;
;
>
assert!;
#[classify(fatal(CorruptState), from)] gives Stored one static lane — every
value is Fatal(CorruptState), with the whole Stored value as its source —
and from emits impl From<std::io::Error> for Stored for the single tuple
field. .classify::<Stored>() is how a one-off call site wraps a foreign
error without the enclosing function itself returning Stored (see "Errors
from other crates" below).
A mixed wrapper combines both: some variants delegate to a payload that is
itself Classify (a Rejection, a fault wrapper, or another mixed wrapper),
others name a static lane directly. Rejected and Lanes are inferred from
whichever variants are present — never named by hand:
use ;
;
>
assert!;
Rejections alongside faults
An operation can reject a request for a domain reason and also encounter a
fault while carrying it out. Fail<R, L> expresses both: R names the domain
rejections, and L selects the fault lanes.
For example, paying can reject an invalid amount before reaching storage, while storage can fail fatally:
use ;
>
>
assert!;
assert!;
The validation case converts into Fail::Rejected. The storage function
returns a Fault, which ? converts into the compatible Fail while
retaining its lane. A function that only validates input can return
Result<T, Validation> directly; it has no fault lanes to combine with its
rejections, and the caller's ? places it in the Rejected lane.
Errors from other crates
sqlx::Error, serde_json::Error, and anything else outside this crate never
implements Classify directly — errlanes cannot know what a 404 from one
caller's upstream means versus another's. The fix is the same one always: wrap
it in a local type, and give that a lane. A one-off call site does this with
.classify::<W>(), which turns a Result<T, Foreign> into a Result<T, W>:
use ;
;
let wrapped: = decode.;
assert_eq!;
A function whose own error type already implements Classify gets this for
free through From, with bare ? — no .classify() needed at the call site:
Same foreign error, two crates, two meanings. A third-party price feed's
404 on a known route is a domain outcome; its timeouts and 5xx retry; its
401/403 are our credentials, not the caller's, so they are narrowed.
A different crate wrapping the exact same reqwest::Error from an in-house
service can decide every failure there is a deployment problem instead:
; // our own service: any failure is ours
async >
delegate forwards to the field's own Classify; narrow(Denied) then
removes just the denied lane (an upstream 401/403 becomes Fatal(Denied)
instead) before the arm's contribution is folded into the enclosing type's
Lanes. Rejected and Lanes are always inferred this way — never named on
the derive. The payload is the variant's only field, or — among several named
fields — the one marked #[source], or named source. from additionally
needs the payload to be the only field, since From has nothing to fill
siblings with.
classify-sqlx, classify-serde-json, and classify-reqwest are
impl Classify for the three foreign types errlanes blesses on your behalf —
always Rejected = Infallible, the narrowest Lanes each warrants (see the
module docs for the exact table). With the feature enabled, bare ? works on
the foreign type directly, and a wrapper's delegate arm can name it like any
other Classify payload. classify-reqwest's Denied means the subject of
this call is unauthorized: an upstream 401/403 returned to a service
account is usually a credential or configuration fault at our layer, not the
caller's — narrow it with #[classify(delegate, narrow(Denied))] (→
Fatal(Denied)), or match the status in a hand-written wrapper to get
Fatal(Config) instead. A proxy forwarding the caller's own token to upstream
is the case that keeps Denied as-is.
Rejections across domain boundaries
As an error moves up the call stack, it may cross into a domain with its own rejection enum. A payment operation, for example, may need to expose a validation rejection as one of its own cases. The fault lanes already have shared meanings, but the two domain enums need an explicit conversion.
derive(Lift) generates that conversion. The enum-level #[lift(Validation)]
names the source, and an annotation on each destination variant says which
source case it represents:
use ;
>
let rejection = outer.unwrap_err.rejected.unwrap;
assert!;
assert_eq!;
.widen()? converts the validation rejection into the payment rejection and
places it in the Rejected lane of the outer result. ResultExt::widen accepts a
bare rejection, as here, or a Fail or Fault result; faults retain their
payloads, and successful values pass through unchanged.
By default, a lift must account for every source case. The derive generates
an exhaustive mapping and a From implementation. If Validation gains another
case, this mapping must be updated before it compiles. Unit, tuple and named
payloads forward automatically; #[lift(Source::Variant, with = mapper)]
handles a genuine payload transformation.
#[lift(Source::Variant, field = name)] is the narrower case in between:
the source variant is a single-field tuple variant, and the destination keeps
only one named field out of its payload rather than the whole thing —
Case(payload) => Ok(Self::Dest(payload.name)). Prefer it over with when
the destination is a straight projection of one field (an attributed id out
of a constraint-violation payload is the motivating case); reach for with
once the mapping needs to inspect more than one field, change shape, or
compute something the source didn't carry directly. field and with are
mutually exclusive on the same arm. Because the destination no longer mirrors
the source variant's fields, there is nothing of the right shape to forward
code/level from automatically — same as with — so a field lift always
needs its own #[rejection(code = "...")].
Conversion and metadata are separate concerns. Lift generates the conversion;
Rejection generates the code and level. Either derive can be used alone.
When both are present, a simple lift annotation also identifies where to get
the metadata. The renamed AmountNotPositive case therefore keeps the source
code INVALID_AMOUNT and its level. A destination can declare its own
#[rejection(code = "...")] when the mapping gives the error a new domain meaning.
Sometimes only a subset of the source cases makes sense as a rejection in the
destination domain. If the remaining cases would indicate a violated invariant,
#[lift(Source, unhandled = fatal)] allows a partial mapping. An unmapped
rejection becomes Fatal with the original rejection as its source, so the
destination must permit Fatal, which the compiler checks. Whether a mapping is
total or partial is declared once, on the destination enum; the call site is
the same .widen()? either way. A partial mapping does not generate From,
because there is no infallible conversion to be had.
A source need not be an enum. A struct rejection — the shape a wrapped foreign
error naturally takes (see "Errors from other crates" above) — lifts as a
whole value: #[lift(Payload)] with no variant suffix names the registered
struct directly, and the entire value becomes the one field of the
destination variant that also writes #[lift(Payload)]:
use ;
;
let bad = "x"..unwrap_err;
let rejection: JobRejection = Payload.into;
assert!;
Moving between signatures
A failure changes shape as it travels: the lane set grows when a caller can produce outcomes the callee could not, and the rejection type changes when a failure crosses into a domain with its own vocabulary. Two operations cover every case:
| From | To | Use |
|---|---|---|
| the same carrier | itself | ? |
a lane marker (Transient, Fatal, Denied) |
any carrier enabling it | ? |
Fault<S> |
Fail<R, D> |
? |
a bare rejection C |
Fail<R, D>, given R: From<C> |
? |
a bare rejection C |
Fail<R, D>, given a partial #[lift(C)] on R |
.widen()? |
Fail<C, S> |
Fail<R, D> |
.widen()? |
Fault<S> |
Fault<D> |
.widen()? |
Result<T, Foreign> |
Result<T, W> |
.classify::<W>(), given W: Classify + From<Foreign> |
W: Classify |
Fail<D, M> |
?, given D lifts W::Rejected totally and W::Lanes ⊆ M |
W: Classify |
Fail<D, M> |
.widen()?, if D only lifts W::Rejected partially |
W: Classify<Rejected = Infallible> |
Fault<M> |
?, given W::Lanes ⊆ M |
W: Classify<Rejected = Infallible, Lanes = lanes!(Fatal)> |
bare Fatal |
? (same for a lone Transient) |
Result<T, Fail<D, L>> |
Result<T, Fault<L>> |
.narrow_rejected() |
Result<T, Fail<D, L>> |
Result<Result<T, D>, Fault<L>> |
.rejected(), to handle the rejection at the call site |
Result<T, Fail<D, L>> |
Result<T, Fail<D2, L>> |
.map_rejected(f), to enrich a rejection with call-site data; a type-level remap is .widen() |
Result<T, Fault<L>> or Result<T, Fail<D, L>> |
lanes narrowed, WithoutTransient<L> |
.narrow_transient(attempts) |
Result<T, Fault<L>> or Result<T, Fail<D, L>> |
lanes narrowed, WithoutDenied<L> |
.narrow_denied() |
Fault<L> |
Fault<WithoutTransient<L>> |
.narrow_transient(attempts) (on the value itself, e.g. a retry loop's match arm) |
Fault<L> |
Fault<WithoutDenied<L>> |
.narrow_denied() (on the value itself) |
Fail<D, L> |
Fault<L> |
.narrow_rejected() (on the value itself) |
? handles anything that needs no decision. .widen() is for the one case that
does, changing the rejection type, and its destination is inferred from the
return type. A total #[lift(C)] supplies the R: From<C> that lets a bare
rejection propagate with ?; a partial lift does not, so a bare rejection
crosses it with .widen()? like any other source. Widening only ever adds
lanes: dropping one that the source can still produce is a compile error,
because somebody has to handle it. A narrowing goes the other way on purpose:
each removes exactly one lane by name, never by inference, which is why it is
always a method call and not a From (see "Narrowing a lane" above).
Composing rejection families
An outer domain may also want to expose every case from an inner rejection
family, including cases added in the future. In that situation, there is no
individual mapping decision to make for each case.
#[errlanes::compose] expresses that relationship directly:
use Rejection;
let payment = from;
assert!;
assert_eq!;
The placeholder Amount(Validation) expands into real variants such as
AmountInvalidAmount. Its name supplies the prefix; the imported cases keep
their payloads, codes, levels, formatting, and sources. The attribute supplies
both Rejection and Lift, including total From conversions, so the
resulting family supports .widen()? just like the explicit mapping above,
and it can sit alongside explicit lifts from other sources.
Composition includes new source cases automatically. Explicit lifts are useful when each addition needs review or individual cases need different names. When an outer layer adds no semantics of its own, it can simply reuse the inner rejection type.
The Rust snippets above are checked by the crate's doctests. The opening enum sketch compiles, and the examples execute their assertions: