Expand description
Four error lanes — Rejected(D) / Denied / Transient / Fatal —
carried in the type instead of re-derived at every layer.
See the crate README for a step-by-step introduction with runnable examples.
§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. Their structure looks like this:
use errlanes::{Denied, Fatal, Transient};
enum Fault {
Denied(Denied), // The caller is not authorized.
Transient(Transient), // Retrying the operation may succeed.
Fatal(Fatal), // Operator attention is needed.
}
enum Fail<R> {
Rejected(R), // A domain outcome the caller can act on.
Denied(Denied),
Transient(Transient),
Fatal(Fatal),
}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 errlanes::{Fault, lanes};
fn describe(failure: Fault<lanes!(Transient, Fatal)>) -> &'static str {
match failure {
Fault::Transient(_) => "worth another attempt",
Fault::Fatal(_) => "needs a human",
}
}
// With a single lane left, there is nothing to match at all.
fn unwrap_fatal(failure: Fault<lanes!(Fatal)>) -> errlanes::Fatal {
let Fault::Fatal(fatal) = failure;
fatal
}§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 errlanes::{Denied, Fault, ResultExt, lanes};
fn authorize(subject: &str) -> Result<(), Denied> {
if subject == "admin" { Ok(()) } else { Err(Denied::default()) }
}
fn inner() -> Result<u64, Fault<lanes!(Fatal)>> {
Ok(42)
}
fn outer(subject: &str) -> Result<u64, Fault<lanes!(Denied, Fatal)>> {
authorize(subject)?;
let value = inner()?;
Ok(value)
}
assert_eq!(outer("admin").unwrap(), 42);
assert!(matches!(outer("guest"), Err(Fault::Denied(_))));The authorization failure enters the Denied lane through ?. The inner
operation’s result uses ? to fit the outer function’s larger set of
lanes. 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 errlanes::{Fault, Transient, TransientKind, lanes};
const BUDGET: u32 = 3;
fn execute(
mut attempt_once: impl FnMut() -> Result<u64, Fault<lanes!(Transient, Fatal)>>,
) -> Result<u64, Fault<lanes!(Fatal)>> {
let mut attempts = 0;
loop {
attempts += 1;
match attempt_once() {
Ok(value) => return Ok(value),
Err(failure) if failure.is_transient() && attempts < BUDGET => continue,
Err(failure) => return Err(failure.narrow_transient(attempts)),
}
}
}
// A transient that later succeeds never reaches the caller.
let mut attempts = 0;
let value = execute(|| {
attempts += 1;
if attempts == 1 {
Err(Transient::new(TransientKind::Deadlock).into())
} else {
Ok(42)
}
});
assert_eq!(value.unwrap(), 42);
assert_eq!(attempts, 2);
// One that never succeeds is spent, and says so.
let spent = execute(|| Err(Transient::new(TransientKind::Deadlock).into())).unwrap_err();
let Fault::Fatal(fatal) = spent;
assert_eq!(fatal.kind, errlanes::FatalKind::Exhausted);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.
§Narrowing a lane
As in the retry example, any lane can be narrowed once its owner has handled
it. narrow_<lane> removes exactly one lane and turns its value into a
Fatal, the only lane left once nobody can act on the removed one. A
narrowing is always a method call, never a From, so ? cannot drop a lane
by accident. The same methods are available on a Result through ResultExt.
| 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 |
§Handling the Rejected lane
A rejection is a domain outcome the caller can act on, so it is usually
handled rather than narrowed. rejected() hands the caller the rejection as
a value: 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 errlanes::{Fail, Fault, ResultExt, lanes};
#[derive(Debug, errlanes::Rejection)]
#[rejection(code = "TIMED_OUT")]
struct TimedOut;
fn await_completion() -> Result<u64, Fail<TimedOut, lanes!(Transient, Fatal)>> {
Err(Fail::Rejected(TimedOut))
}
fn poll_once() -> Result<Option<u64>, Fault<lanes!(Transient, Fatal)>> {
match await_completion().rejected()? {
Ok(outcome) => Ok(Some(outcome)),
Err(TimedOut) => Ok(None),
}
}
assert!(poll_once().unwrap().is_none());narrow_rejected() is for the opposite situation: a frame that has already
proved the precondition has no caller left to correct the rejection, so the
rejection is an invariant there.
§Rejections across domain boundaries
As an error moves up the call stack, it may cross into a domain with its own
rejection enum. The fault lanes already have shared meanings, but two domain
enums need an explicit conversion. derive(Lift) generates it: the enum-level
#[lift(Validation)] names the source, and an annotation on each destination
variant says which source case it represents:
use errlanes::{Fail, Rejection, ResultExt, lanes};
#[derive(Debug, errlanes::Rejection)]
enum Validation {
#[rejection(code = "INVALID_AMOUNT")]
InvalidAmount,
}
#[derive(Debug, errlanes::Rejection, errlanes::Lift)]
#[lift(Validation)]
enum Payment {
#[lift(Validation::InvalidAmount)]
AmountNotPositive,
}
fn validate(amount: u64) -> Result<(), Validation> {
if amount == 0 { Err(Validation::InvalidAmount) } else { Ok(()) }
}
fn outer() -> Result<(), Fail<Payment, lanes!(Denied, Fatal)>> {
validate(0)?;
Ok(())
}
let rejection = outer().unwrap_err().rejected().unwrap();
assert!(matches!(rejection, Payment::AmountNotPositive));
assert_eq!(Into::<&'static str>::into(rejection.code()), "INVALID_AMOUNT");By default a lift is exhaustive. The derive emits From<Validation>, so ?
places the rejection in the outer Rejected lane, and if Validation gains a
case the mapping must be updated before it compiles. Unit, tuple and named
payloads forward automatically; #[lift(Source::Variant, with = mapper)]
hands the mapper the whole source value and returns the destination,
into converts the payload through Into, and field = name
keeps one named field of it. A plain lift arm also forwards the source case’s
code and level, which is why AmountNotPositive above still reports
INVALID_AMOUNT; a with, into or field arm declares its own
#[rejection(code = "...")] instead, or #[rejection(delegate)] to take
them from the converted payload.
#[lift(Source::Variant { .. })] means the case became this leaf: the arm
binds nothing and drops the payload. The destination is a unit variant, or a
variant holding one unit leaf (AccountLocked(SettlementAccountLocked)); the
leaf’s own #[rejection(code = "...")] supplies the code. The braces go on
the innermost case (Outer::Mid(Mid::Leaf { .. })) and take only ..; they
combine with to = Variant but not with with, into, field or when,
which need the payload. Without the braces nothing is dropped implicitly.
#[lift(Source::Variant { a, .. })] names the fields to keep: the arm builds
the destination from them, converting each through Into, and drops the rest.
The destination is a variant with exactly those named fields, or one field
whose type is a struct with exactly those fields (AccountLocked(AccountLocked)
holding struct AccountLocked { account_id: AccountId }). Without the trailing
.. rustc requires the pattern to name every field of the source variant.
Names are bound as written; there is no rename.
Sometimes only a subset of the source cases makes sense in the destination
domain. #[lift(Source, unhandled = fatal)] allows a partial mapping: an
unmapped rejection becomes Fatal(Invariant) with the original as its source,
so the destination must enable Fatal and the call site is .lift()? rather
than ?. A named-field struct can select one source variant this way with
variant = Case, renaming fields with #[lift(from = source_field)]:
use errlanes::{Fail, ResultExt, lanes};
use std::time::Duration;
#[derive(Debug, errlanes::Rejection)]
enum SubscriptionRejection {
CaughtUpTimeout { checkpoint: u64, target: u64, waited: Duration },
NoSuchJob { key: String },
}
#[derive(Debug, errlanes::Rejection, errlanes::Lift)]
#[rejection(code = "EC_CAUGHT_UP_TIMEOUT")]
#[error("checkpoint {applied} had not reached {frontier} after {waited:?}")]
#[lift(SubscriptionRejection, variant = CaughtUpTimeout, unhandled = fatal)]
struct EcCaughtUpTimeout {
#[lift(from = checkpoint)]
applied: u64,
#[lift(from = target)]
frontier: u64,
waited: Duration,
}
let result: Result<(), Fail<EcCaughtUpTimeout, lanes!(Fatal)>> =
Err(SubscriptionRejection::CaughtUpTimeout {
checkpoint: 3,
target: 8,
waited: Duration::from_secs(2),
}).lift();
let Fail::Rejected(timeout) = result.unwrap_err() else { panic!("expected timeout") };
assert_eq!((timeout.applied, timeout.frontier), (3, 8));The From an into arm needs is derived on the payload type when its shape
is mechanical: #[lift(Source)] on a unit struct drops the value, on a
single-field tuple struct wraps it, and #[lift(Source, field = name)] (or
field = 0) keeps one field, converting it through Into.
use errlanes::{Lift, Rejection};
#[derive(Debug)]
struct LimitExceeded { limit: u64 }
#[derive(Debug, errlanes::Rejection)]
enum Limit {
#[rejection(code = "LIMIT_EXCEEDED")]
Exceeded(LimitExceeded),
}
#[derive(Debug, errlanes::Rejection, errlanes::Lift)]
#[rejection(code = "PAYMENT_EXCEEDS_OUTSTANDING")]
#[lift(LimitExceeded)]
struct PaymentExceedsOutstanding;
fn is_large(exceeded: &LimitExceeded) -> bool { exceeded.limit > 100 }
#[derive(Debug, errlanes::Rejection, errlanes::Lift)]
#[lift(Limit)]
enum Payment {
#[lift(Limit::Exceeded, when = is_large, into)]
#[rejection(code = "PAYMENT_EXCEEDS_OUTSTANDING")]
Outstanding(PaymentExceedsOutstanding),
#[lift(Limit::Exceeded)]
Exceeded(LimitExceeded),
}
let payment = Payment::lift(Limit::Exceeded(LimitExceeded { limit: 500 })).unwrap();
assert!(matches!(payment, Payment::Outstanding(_)));A struct source lifts as a whole value: #[lift(Payload)] with no variant
suffix, on both the enum and the variant that receives it. Adding into
(#[lift(Payload, into)]) converts the whole value through Into first; the
source may then be an enum too, and an unguarded whole-value into is the
entire conversion.
A case whose payload the destination already accepts forwards with an
enum-level #[lift(Source::Case, into)], and a case that belongs in a
composed-in variant, which has no declaration to annotate, is
#[lift(Source::Case, to = Variant)]:
use errlanes::Lift;
#[derive(Debug, errlanes::Rejection)]
enum Transition {
#[rejection(code = "STALE")]
Stale,
}
#[derive(Debug, errlanes::Rejection)]
enum Update {
#[rejection(delegate)]
Transition(Transition),
#[rejection(code = "INVALID")]
Invalid,
}
#[errlanes::compose(Transition)]
#[derive(Debug)]
#[lift(Update, unhandled = fatal)]
#[lift(Update::Transition, into)]
enum UpdateFamily {}
assert!(matches!(
UpdateFamily::lift(Update::Transition(Transition::Stale)),
Ok(UpdateFamily::Stale)
));
assert!(UpdateFamily::lift(Update::Invalid).is_err());When one source variant carries several cases, when = predicate guards an
arm: the predicate is a path to a fn(&Payload) -> bool over the variant’s
single payload, and a payload no guard accepts falls through to the next arm
for that variant, then to unhandled = fatal. A guarded source must be a
single-field tuple variant, guarded arms must precede the one unguarded arm
for the same variant, and a strict registration needs that unguarded arm as a
catch-all. A guarded plain arm forwards the source’s code and level, and
into, field and with arms take a guard too.
use errlanes::{Fail, ResultExt, lanes};
#[derive(Debug)]
struct LimitExceeded { limit: &'static str }
#[derive(Debug, errlanes::Rejection)]
enum Limit {
#[rejection(code = "LIMIT_EXCEEDED")]
Exceeded(LimitExceeded),
}
fn is_overdraft(exceeded: &LimitExceeded) -> bool {
exceeded.limit == "overdraft"
}
#[derive(Debug, errlanes::Rejection, errlanes::Lift)]
#[lift(Limit, unhandled = fatal)]
enum Checkout {
#[lift(Limit::Exceeded, when = is_overdraft)]
InsufficientFunds(LimitExceeded),
}
fn check(limit: &'static str) -> Result<(), Fail<Checkout, lanes!(Fatal)>> {
Err::<(), _>(Limit::Exceeded(LimitExceeded { limit })).lift()
}
assert!(matches!(
check("overdraft"),
Err(Fail::Rejected(Checkout::InsufficientFunds(_)))
));
assert!(matches!(check("velocity"), Err(Fail::Fatal(_))));A source case can reach into nested enums by wrapping each level in
parentheses: #[lift(Outer::A(Mid::B(Leaf::C)))]. The leaf takes every form
above, including when, and its payload is what the guard sees; every level
above the leaf must be a single-field tuple variant. A nested pattern never
covers the other variants of the intermediate enums, so nested arms require
unhandled = fatal.
use errlanes::Lift;
enum Batch { Apply(Apply), Other }
enum Apply { Velocity(Velocity), Locked }
enum Velocity { Exceeded(u8) }
fn is_floor(level: &u8) -> bool {
*level < 10
}
#[derive(errlanes::Lift)]
#[lift(Batch, unhandled = fatal)]
enum Settlement {
#[lift(Batch::Apply(Apply::Velocity(Velocity::Exceeded)), when = is_floor)]
BelowFloor(u8),
}
let exceeded = Batch::Apply(Apply::Velocity(Velocity::Exceeded(3)));
assert!(matches!(Settlement::lift(exceeded), Ok(Settlement::BelowFloor(3))));
assert!(Settlement::lift(Batch::Apply(Apply::Locked)).is_err());§One code, several causes
#[rejection(code = "..", description = "..")] on an enum is the code (and
description, and level if given) of every variant that declares neither its
own code nor delegate. A cause that the client cannot distinguish is a
variant under the family’s code, not a wrapper around the foreign enum.
#[error], #[source], #[rejection(from)] and #[lift] work on each
variant as before, and a #[lift] arm takes the enum’s code instead of
forwarding the source case’s. The generated code type has one variant per
distinct code, and CODES lists each code once.
use errlanes::{Fail, Rejection, RejectionCode, ResultExt, lanes};
#[derive(Debug, errlanes::Rejection)]
enum Cala {
#[rejection(code = "CALA_DUPLICATE_ID")]
DuplicateId(u64),
#[rejection(code = "CALA_CODE_ALREADY_EXISTS")]
CodeAlreadyExists(String),
#[rejection(code = "CALA_UNRELATED")]
Unrelated,
}
#[derive(Debug, errlanes::Rejection, errlanes::Lift)]
#[rejection(
code = "HOLDING_ACCOUNT_ALREADY_EXISTS",
description = "A holding account with this id or reference already exists."
)]
#[lift(Cala, unhandled = fatal)]
enum CreateAccountRejection {
#[error("holding account id {0} already exists")]
#[lift(Cala::DuplicateId)]
DuplicateId(u64),
#[error("holding account code {0} already exists")]
#[lift(Cala::CodeAlreadyExists)]
CodeAlreadyExists(String),
}
assert_eq!(CreateAccountRejectionCode::CODES.len(), 1);
let duplicate: Result<(), Fail<CreateAccountRejection, lanes!(Fatal)>> =
Err::<(), _>(Cala::CodeAlreadyExists("H1".into())).lift();
let rejected = duplicate.unwrap_err().rejected().unwrap();
assert_eq!(
Into::<&str>::into(rejected.code()),
"HOLDING_ACCOUNT_ALREADY_EXISTS"
);
assert_eq!(rejected.to_string(), "holding account code H1 already exists");Variants that still declare their own code, or delegate, keep it, so a
family can report one code for most causes and a distinct one for a few. One
code has one description: a variant that takes the shared code cannot declare
its own description. An enum-level code is a compile error when every
variant declares its own code or delegates, when the enum has no variants, or
next to code_prefix.
use errlanes::{Rejection, RejectionCode};
#[derive(Debug, errlanes::Rejection)]
#[rejection(code = "PARTNER_NOT_ACTIVE")]
#[error("the partner is not active")]
struct PartnerNotActive;
#[derive(Debug, errlanes::Rejection)]
#[rejection(
code = "PARTNER_API_KEY_CREDENTIALS_REJECTED",
description = "The presented API key credentials are not usable."
)]
enum ApiKeyCredentialsRejected {
#[rejection(from)]
#[error("the presented secret is not validly encoded")]
InvalidEncoding(#[source] std::str::Utf8Error),
#[rejection(from)]
#[error("the presented secret has the wrong length")]
InvalidLength(#[source] std::array::TryFromSliceError),
#[error("no api key matches the presented secret")]
Unknown,
#[error("the api key is not usable")]
Inactive,
#[rejection(delegate, from)]
#[error("{0}")]
PartnerNotActive(PartnerNotActive),
}
assert_eq!(ApiKeyCredentialsRejectedCode::CODES.len(), 2);
assert_eq!(
Into::<&str>::into(ApiKeyCredentialsRejected::Unknown.code()),
"PARTNER_API_KEY_CREDENTIALS_REJECTED"
);
assert_eq!(
Into::<&str>::into(ApiKeyCredentialsRejected::from(PartnerNotActive).code()),
"PARTNER_NOT_ACTIVE"
);§Errors from other crates
Every local error type says, through one trait, how it enters the lanes:
pub trait Classify: Error + Send + Sync + 'static {
type Rejected: RejectedSlot; // a `Rejection`, or `Infallible` if nothing is ever rejected
type Lanes: LaneProfile; // the narrowest profile the fault part can occupy
fn classify(self) -> Fail<Self::Rejected, Self::Lanes>;
}A Rejection gets Classify from a blanket impl, so a pure domain outcome
needs only #[derive(errlanes::Rejection)]. Everything else is written with
#[derive(errlanes::Classify)], and a type is one or the other, never both.
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 errlanes::{FatalKind, ResultExt};
#[derive(Debug, errlanes::Classify)]
#[classify(fatal(CorruptState), from)]
struct Stored(std::io::Error);
fn decode() -> Result<u8, std::io::Error> { Err(std::io::Error::other("x")) }
let wrapped: Result<u8, Stored> = decode().classify::<Stored>();
assert_eq!(wrapped.unwrap_err().0.to_string(), "x");A function whose own error type already implements Classify gets this for
free through From, with bare ? — no .classify() needed at the call site:
mod price_feed {
// foreign → wrapper by `?` (the `from` flag's `From` impl)
pub fn spot() -> Result<Quote, PriceFeed> {
let response = client.get(url).send()?;
Ok(response.json()?)
}
}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:
#[derive(Debug, errlanes::Rejection)]
pub enum PriceFeedRejection {
#[rejection(code = "PRICE_FEED_UNKNOWN_SYMBOL")]
UnknownSymbol(String),
}
#[derive(Debug, errlanes::Classify)]
pub enum PriceFeed {
#[classify(delegate)] Rejected(PriceFeedRejection),
#[classify(delegate, narrow(Denied))] Http(reqwest::Error),
}
impl From<reqwest::Error> for PriceFeed { /* 404 on /symbols/{s} -> Rejected(UnknownSymbol(s)), else Http(e) */ }
#[derive(Debug, errlanes::Classify)]
#[classify(fatal(Config), from)]
pub struct FxRates(reqwest::Error); // our own service: any failure is ours
async fn quote() -> Result<Quote, Fail<QuoteRejection, lanes!(Transient, Fatal)>> {
let spot = feed.get(url).send().await.classify::<PriceFeed>()?; // lifts PriceFeedRejection
let rate = rates.get(url).send().await.classify::<FxRates>()?; // always Fatal(Config)
Ok(spot * rate)
}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.
§At a Box<dyn Error> boundary
A Classify wrapper carries its lane in its
impl Classify, not in the value, so it must reach a carrier before it is
boxed; a boundary reading the box afterwards would only see the foreign error
underneath it. .into_fault()? lands a never-rejecting wrapper in its own
Fault<W::Lanes>, and .into_fail()? lands a rejecting one in its own
Fail<W::Rejected, W::Lanes>, so nothing has to be named even though ?
into a box leaves the destination unconstrained. Raw foreign errors and
carriers need no such step. On the way back out, Fault::classify(&*boxed)
borrows the error and returns the first lane payload or blessed foreign error
in its source() chain, or Fatal(Dependency) if it finds neither. A
rejection cannot cross a box as a rejection: handle or narrow it first.
use errlanes::{Fault, ResultExt};
#[derive(Debug, errlanes::Classify)]
#[classify(fatal(CorruptState), from)]
#[error("could not decode a stored row")]
struct StoredRow(#[source] std::io::Error);
fn decode() -> Result<u8, StoredRow> {
Err(StoredRow(std::io::Error::other("bad bytes")))
}
// Inside a boundary whose own trait returns a box:
fn run() -> Result<u8, Box<dyn std::error::Error + Send + Sync>> {
Ok(decode().into_fault()?)
}
let fault = Fault::classify(&*run().unwrap_err());
assert!(matches!(fault, Fault::Fatal(f) if f.kind == errlanes::FatalKind::CorruptState));§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. The appropriate conversion depends on what changes:
| From | To | Use |
|---|---|---|
| the same error type | itself | ? |
a lane payload (Transient, Fatal, Denied) | a Fault or Fail enabling it | ? |
Fault<S> | Fault<D> or Fail<R, D>, S ⊆ D | ? |
Fail<R, S> | Fail<R, D>, S ⊆ D | ? |
a bare rejection C | Fail<R, D>, given a total R: From<C> | ? |
W: Classify | Fail<R, D>, given a total R: From<W::Rejected> and W::Lanes ⊆ D | ? |
W: Classify<Rejected = Infallible> | Fault<D>, W::Lanes ⊆ D | ? |
a rejecting result (Fail, bare rejection, wrapper) | Fail<P, D>, with Fatal enabled and a total or partial P: Lift<R> | .lift()? |
| a never-rejecting result | its own Fault<W::Lanes> where ? has no destination to infer (a box) | .into_fault()? |
| a rejecting result | its own Fail<R, W::Lanes> where ? has no destination to infer | .into_fail()? |
Result<T, Foreign> | Result<T, W> | .classify::<W>(), given W: Classify + From<Foreign> |
Result<T, Fail<R, L>> | Result<T, Fault<L>> | .narrow_rejected() |
Result<T, Fail<R, L>> | Result<T, Fault<L>>, recovering some members and narrowing the rest | .narrow_rejected_with(|r| ..) |
Result<T, R>, a bare rejection | Result<T, Fatal> after the precondition is proven | .narrow_rejected()? |
Result<T, Fail<R, L>> | Result<Result<T, R>, Fault<L>> | .rejected() |
Result<T, Fail<R, L>> | Result<T, Fail<P, L>> | .map_rejected(f) for call-site data |
a Fault or Fail result | lanes narrowed | .narrow_transient(attempts) or .narrow_denied() |
? expands lanes without dropping one. It also carries a total mapping from
a bare rejection or Classify wrapper into Fail; a partial mapping requires
.lift()?. A Fail<R, S> changing its rejection to P also uses .lift()?,
whether the lift is total or partial. ResultExt::lift enables Fatal before
the following ?, so an unmapped case has a place to go. Consequently its
destination must enable Fatal. For a total Fail lift into a signature
without Fatal, use the value-level Fail::lift::<P, D> explicitly. Narrowing
removes a lane by name after its outcome has been handled.
§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. It takes the
source families to import and generates both Rejection and Lift for the
composed enum.
A source listed as Source as Prefix imports every one of its cases under a
prefixed name:
use errlanes::Rejection;
#[derive(Debug, errlanes::Rejection)]
pub enum Validation {
#[rejection(code = "INVALID_AMOUNT")]
InvalidAmount,
}
#[errlanes::compose(Validation as Amount)]
#[derive(Debug)]
pub enum Payment {
WindowClosed,
}
let payment = Payment::from(Validation::InvalidAmount);
assert!(matches!(payment, Payment::AmountInvalidAmount));
assert_eq!(Into::<&'static str>::into(payment.code()), "INVALID_AMOUNT");Validation as Amount expands into real variants such as
AmountInvalidAmount. 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
? for bare rejections and .lift()? for failures, 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.
Composition is transitive: a family that composes Posting is also From
everything Posting composes or strictly lifts, so a bare Validation reaches
an outer family without every layer re-declaring the hop. The composing family
never has to spell the deeper source’s path; it asks the family it imports for
the type. #[errlanes::compose(Posting(no_from))] imports Posting’s cases
only. Reach for it when the outer family maps one of Posting’s sources
differently, and the compiler reports two From impls for the same source.
A source is identified by the path as the family that converts from it wrote it.
If two imported families reach one type under different spellings, they export it
twice and the outer family gets two identical impls (E0119). Either import one of
them with (no_from), or register that source once on the outer family with
#[lift(Source)], spelled the same way as one of them.
An imported case keeps the from its source declares, so Composed::from(leaf)
works for every payload any source accepts by from. Two imported variants with
the same payload type are a conflicting-From error: merge them into one
variant (#[compose(merge(..))]) or import one source (no_leaf_from).
Source(no_from) imports the cases only — no leaf From, no transitive From.
Re-exports§
pub use profile::AllLanes;pub use profile::LaneProfile;pub use profile::NoLanes;pub use classify::Classify;pub use classify::RejectedSlot;pub use fail::CodeInfo;pub use fail::Fail;pub use fail::Fault;pub use fail::Laned;pub use fail::Level;pub use fail::Lift;pub use fail::Rejection;pub use fail::RejectionCode;
Modules§
- profile
- Sealed compile-time subsets of the three fault lanes.
Macros§
- lanes
- Select a subset of
Denied,Transient, andFatal, in any order.
Structs§
- Code
Info - One code a rejection family can resolve to, with its operator-facing text.
- Denied
- Authorization failure. Never retried, always audited.
- Exhausted
- A
Transientlane that never succeeded within the retry budget. - Fatal
- A failure that will not succeed on retry: a bug, a misconfiguration, or corrupt state.
- Transient
- A failure that may succeed if the same operation is retried.
Enums§
- Fail
- The generic view over a domain rejection
D, before retries have run. - Fatal
Kind - Why a
Fataloutcome is a bug, a misconfiguration, or corrupt state. - Fault
Failminus theRejectedlane: what an operation that cannot reject (nothing about it is the caller’s to correct) returns. Reads returnFault<L>; writes returnFail<{Entity}ConstraintViolation, L>— the type itself says whether a call can ever hand back a domain outcome.- Lane
- Which of the four lanes an outcome travels in.
- LaneRef
- A borrowed view of a carrier’s current lane.
Fault::lanesandFail::lanesboth return one, sorecord,messageandlanehave a single implementation over it. - Level
- Operator level, independent of any tracing dependency in the core API.
- Transient
Kind - Why a
Transientoutcome may succeed on retry.
Traits§
- Classify
- How a local error enters the lanes: which part is a typed domain outcome that a caller can correct, and which fault lanes the rest can take.
- Laned
- Sealed. The thing
retry/recordneed from any error they are handed: its lane, and how to narrow it. Implemented byFail<D, L>, byFault<L>— the two shapes a generated repo op can return. - Lift
- Consuming mapping.
#[derive(Lift)]with#[lift(Source)]generates an exhaustive mapping withUnmapped = Infallibleand a totalFrom<Source>conversion.#[lift(Source, unhandled = fatal)]returns the original unmapped source;Fail::liftwraps it as a fatal invariant with its source intact. The derive does not require or implementRejection. DeriveRejectionseparately to provide codes and levels; simple lift mappings forward that metadata by default unless the destination declares its own. - Rejected
Slot - The rejected slot of a
Classifytype: aRejection, orInfalliblefor a type that never rejects — the same encoding a disabled lane slot uses. - Rejection
- A pure, caller-correctable domain outcome. Implemented by hand or via
#[derive(errlanes::Rejection)], which emitsDisplay/Erroritself (defaultingDisplayto the code) unless#[rejection(error = manual)]hands that tothiserroror a hand-written impl. - Rejection
Code - The code type of a rejection family: a closed, enumerable catalogue.
- Reshape
- One lane edit per associated type; see the module docs. Implemented for
profiles,
FaultandFail. - Result
Ext - The one trait a consumer imports to move a
Resultbetween error signatures: lift its rejection, narrow away a lane that is no longer live at this boundary, hand the rejection to the caller as a value, classify a foreign error into a local wrapper, or (undertracing) record it onto the current span.
Type Aliases§
- With
Denied Ewith the denied lane enabled.- With
Fatal Ewith the fatal lane enabled.- With
Transient Ewith the transient lane enabled.- Without
Denied Ewith the denied lane disabled (whatnarrow_deniedreturns).- Without
Transient Ewith the transient lane disabled (whatnarrow_transientreturns).
Attribute Macros§
- compose
- Compose rejection families from a list of sources:
Sourceimports every case under its original variant name,Source as Prefixunder a prefixed one. Collisions between unprefixed sources require a local#[compose(merge)]variant; use#[compose(merge(SourceA::Case, SourceB::OtherCase))]to select participants explicitly. Merged variants own their metadata through an explicit rejection code or payload delegation. Every source gets a total lift.