Skip to content

The Rust Engineering Handbook / Chapter 34

Error Taxonomy and Result Design

Classify invalid input, environmental failure, partial completion, and programmer defects before choosing public Result types and boundary responses.

Failure handling begins before an error value exists

Ownership, legal-state, representation, and view-validity contracts govern values. Part VI applies the same discipline to operations that do not complete as requested.

Rust gives us Result<T, E>, Option<T>, panic machinery, and ordinary data types. It does not decide whether a failed write is retryable, whether a duplicate record is partial success, whether a malformed packet should be shown to a user, or whether an invariant violation belongs in a recoverable enum. Those are system contracts.

The controlling rule for this part is:

Classify a failure by who can act, whether repeating the operation can change the outcome, what work already committed, and which boundary may disclose the details. Only then choose the error representation.

This order matters. Starting with “Which error crate should we use?” can produce an excellent source chain around a category mistake. Starting with “return Result everywhere” can turn programmer defects into retry storms. Starting with display text can make callers parse English to recover structured policy.

Begin with taxonomy. Preserve causes and attach context only after the semantic category is sound; treat panic, unwinding, cancellation, idempotency, and cleanup as separate robustness contracts. The first decision is what kind of failure occurred.

Four questions separate the main contracts

Ask, in order appropriate to the operation:

  1. Can the caller change the request? Invalid syntax, forbidden values, unsupported versions, and violated preconditions are often recoverable input errors. Repeating identical input will not help.
  2. Can the environment change? A connection reset, exhausted capacity, missing credential, or unavailable dependency may be transient, permanent for this deployment, or resolvable only by operator action.
  3. Did the program violate its own invariant? An impossible state, wrong internal index, or accounting mismatch is a defect. Exposing it as an ordinary retryable domain variant can hide corruption and amplify harm.
  4. Did some requested work commit? A batch, fan-out, or multi-resource operation may have a mixed outcome. A single Err can discard the information required for repair.

“Recoverable” always needs a subject. A service may recover by rejecting one request while continuing the process. A library caller may recover by choosing another path. An operator may recover by restoring credentials. The original user may have no safe action at all. Avoid labeling an error recoverable without naming the actor and boundary.

Invalid input is data, not surprise control flow

Invalid input includes syntactically malformed data, semantically invalid values, unsupported protocol versions, failed authentication material, and requests outside declared resource limits. It is usually expected at a trust boundary even if well-behaved clients rarely send it.

A typed variant lets code handle the condition without parsing Display:

pub enum IngestError {
    InvalidInput {
        field: &'static str,
        reason: &'static str,
    },
    ResourceUnavailable {
        resource: &'static str,
        retry: RetryClass,
    },
    Conflict {
        record_id: u64,
    },
}

The illustrative fixture uses static strings to keep the type dependency-free. A production boundary may use stable field identifiers and structured validation codes. Do not expose arbitrary input fragments, secrets, internal paths, SQL text, or parser diagnostics merely because they are present in the error.

Invalid input is normally non-retryable without modification. That does not mean every 4xx-shaped response is permanent. Credentials can expire, authorization can change, and an optimistic concurrency token can become stale. Classify the precise condition, not its eventual transport status.

Resource limits deserve typed input treatment when they are part of the public contract: frame too large, batch too wide, nesting too deep, or deadline outside allowed range. Allocation failure caused by process pressure is environmental. A user-provided count that exceeds a documented cap is invalid input. Both may surface near the same allocation call, but the corrective actions differ.

Environmental failures need temporal classification

An unavailable resource is not automatically retryable. The same “database unavailable” phrase can represent:

  • a short connection reset likely to clear within a bounded retry budget;
  • invalid credentials requiring operator action;
  • a deleted database that will not return;
  • local admission control that should shed work immediately;
  • a deadline already exhausted, making another attempt useless;
  • a dependency overload where retries would worsen the incident.

Encode the decision as data owned by the right layer:

pub enum RetryClass {
    Never,
    After(Duration),
    AfterOperatorAction,
}

impl IngestError {
    pub const fn retry_class(&self) -> RetryClass {
        match self {
            Self::InvalidInput { .. } | Self::Conflict { .. } => RetryClass::Never,
            Self::ResourceUnavailable { retry, .. } => *retry,
        }
    }
}

This is classification, not a complete retry loop. Execution policy still needs a deadline, attempt budget, backoff and jitter, concurrency limit, idempotency proof, cancellation behavior, and overload signal. A library can describe that a failure is potentially transient without silently retrying. Applications own the request deadline and usually make the final attempt decision.

Do not infer retryability from concrete error types at every call site. If five callers each match raw I/O kinds, policy drifts. Translate dependency failures into a local classification at an adapter boundary, preserving the source for Chapter 35’s diagnostic chain while presenting stable semantics to the domain.

Temporal classification may depend on context. WouldBlock can be normal control flow in a nonblocking loop, a scheduling signal in a runtime adapter, or an error at a synchronous API boundary that promised completion. The operating contract determines the variant.

Programmer defects are not ordinary domain alternatives

A programmer defect means the program has violated an internal assumption it owns: a supposedly exhaustive state table has no entry, committed and rejected counts do not partition the input, or unsafe code has broken validity. Callers cannot repair that by changing ordinary input according to the API contract.

The fixture keeps such a condition out of IngestError:

pub fn committed_partition(total: usize, accepted: usize, rejected: usize) -> bool {
    let classified = accepted
        .checked_add(rejected)
        .expect("accepted and rejected counts must fit usize");
    assert_eq!(
        total, classified,
        "internal defect: every input must have exactly one outcome"
    );
    true
}

This assertion is not permission to panic for expected malformed input. It marks an invariant established by internal construction. Whether the process unwinds, aborts, contains the panic at a request boundary, or terminates belongs to Chapter 36 and deployment policy.

There are important boundary cases. A database can violate an invariant because another version wrote incompatible rows; from this process’s perspective, that may be corrupt environmental state requiring operator repair rather than a local coding bug. A public library should not panic because a caller supplied a documented-but-unusual value. A process reading its own checksum-protected file may still treat corruption as an external error. Write down who established the invariant and who can restore it.

Returning Result<T, InternalBug> can be appropriate at a containment boundary that must quarantine a tenant, report corruption, or avoid taking down a larger host. The error still should not be marked retryable or presented as an expected domain alternative. Containment changes the response, not the classification.

Absence, conflict, and failure are different promises

Option<T> says a value may be absent without carrying a reason. It fits a cache miss, optional field, or lookup where absence is part of normal domain state. It is insufficient when authorization failure, backend outage, invalid key, and not-found require different actions.

Result<Option<T>, E> can distinguish successful absence from failed lookup. That extra layer is warranted only when the caller needs both distinctions. An API that returns Result<T, NotFound> may be clearer when absence itself is the only domain failure.

Conflicts deserve similar care. A duplicate idempotency key may mean “the prior operation already succeeded,” an optimistic-lock mismatch may invite reread and recomputation, and a unique-name collision may require new user input. One Conflict(String) variant obscures those different repairs.

Use the smallest type that preserves decisions callers legitimately need to make. Do not model every diagnostic fact as a public enum variant. Context such as host, attempt number, and source chain can remain diagnostic metadata while the semantic class stays stable.

Partial success needs a success-shaped type

Suppose relay-service accepts a batch of three records. Records 0 and 2 commit; record 1 conflicts. Returning only Err(Conflict) loses committed identifiers and invites an unsafe blind retry. Returning Ok(Vec<Id>) loses the rejection. A mixed outcome should be explicit:

pub struct BatchOutcome<T> {
    pub accepted: Vec<T>,
    pub rejected: Vec<ItemFailure>,
}

pub struct ItemFailure {
    pub index: usize,
    pub error: IngestError,
}

Whether this is Ok(BatchOutcome<T>) or an error variant depends on the operation’s promise. If partial completion is a supported normal result, success with a mixed outcome is often clearest. If the operation promised atomicity, partial commitment is a severe failure that must carry recovery state and may reveal a transaction defect.

The outcome must identify each input durably. A positional index works only while the original batch order is retained. Domain identifiers or idempotency keys are stronger across retries and logs. The type also needs to state whether failures are independent, whether accepted work is visible immediately, and whether repeating the rejected subset is safe.

Alternatives include:

Contract Return shape Strength Main cost
all-or-nothing transaction Result<Committed, AtomicError> caller sees one commit decision requires transactional support or compensation
supported mixed batch Result<BatchOutcome<T>, BatchFatal> preserves per-item success and failure larger response and retry planning
streaming acknowledgements iterator/channel of item outcomes bounded memory and early progress ordering, cancellation, and disconnect semantics
best-effort opaque count aggregate summary small surface insufficient for precise repair

The default should match the external side effect, not desired API neatness. A Result wrapper cannot make a non-atomic system atomic.

Error enums are caller contracts

A typed error enum is powerful when callers need exhaustive or semi-structured handling. It also creates a compatibility surface. Downstream exhaustive matches can make adding a new public variant source-breaking unless the API uses a non-exhaustive strategy or documents another evolution plan.

Design variants around stable semantics rather than implementation topology. DatabasePoolTimedOut, HttpClientConnect, and ChannelClosed leak replaceable components. CapacityUnavailable, DependencyUnavailable, or Shutdown may better express what callers can decide. Keep the original source for diagnosis rather than erasing it.

Avoid enormous “one enum for the process” designs. A parser, domain operation, storage adapter, and command-line boundary serve different callers. Each should expose the distinctions meaningful at that layer and convert intentionally. Automatic From conversions are convenient, but a broad conversion that maps every I/O error to one domain variant may discard retry and disclosure distinctions.

Credible shapes include:

  • a small public enum with stable categories and private diagnostic sources;
  • concrete error structs per operation when fields and evolution differ;
  • a non-exhaustive public enum when callers may match known classes and retain a fallback;
  • an opaque application report at the top boundary, after semantic policy has already been decided;
  • a trait-based classification view when many internal errors expose the same retry or status decision.

Trait-based errors can overabstract a closed domain. Dynamic error reports can simplify applications but are weak public library contracts when callers need typed recovery. Choose for the boundary, not by project-wide fashion.

Opaque reports belong after the policy decision

Applications often benefit from an opaque report that carries a source chain, captured context, and perhaps a backtrace. That report is useful for operators and developers. It should not be the only object from which retry, status, redaction, or exit policy is inferred by string matching.

A practical flow is:

dependency error
    -> local adapter classification + preserved source
    -> domain error used for control policy
    -> boundary disposition
    -> operator report and user-safe response

Libraries should generally return errors whose semantics callers can understand without depending on a reporting framework. Applications may erase concrete types at a narrow orchestration boundary once no typed decision remains. Erasure too early produces “something failed” APIs; erasure too late can force one giant enum to mirror the entire dependency graph.

Error::source, conversion, display, debug formatting, and context attachment can preserve diagnostic evidence. They cannot make a source chain substitute for a semantic category.

One failure, several audiences

The same classified failure can produce different representations. The fixture maps a domain error into a boundary disposition:

pub struct BoundaryDisposition {
    pub http_status: u16,
    pub user_message: &'static str,
    pub operator_code: &'static str,
    pub retry: RetryClass,
}

For invalid input, the user receives a stable non-sensitive message while the operator signal retains a bounded code. Tests prove that an internal field name and token fragment do not appear in the user message. The original structured error can remain available to authorized diagnostic handling.

Separate at least these audiences:

  • caller control flow: stable variant, status class, retry classification, partial outcome;
  • end user: actionable, localized or product-appropriate message without secrets;
  • operator telemetry: stable event name, bounded fields, source/context reference, severity;
  • developer diagnosis: source chain, debug details, backtrace when enabled;
  • process supervisor: documented exit status and restart meaning.

Do not log the same error at every propagation layer. Add context while propagating, then emit once at the handling boundary that owns the response. Duplicate logging inflates incident counts, fragments correlation, and risks disclosing progressively formatted details.

Metrics labels must be bounded. error_kind="invalid_input" is viable; reason=<full Display string> is a cardinality and data-exposure hazard. Keep request ids and tenant ids in controlled structured fields, not metric dimensions.

Figure 34-1 gathers the chapter’s decisions into a retrieval path, not a universal algorithm. Real failures can have more than one cause; the tree preserves the questions and keeps typed classification upstream of audience-specific presentation.

A failure classification tree separates caller-controlled invalid input, transient or permanent resource unavailability, programmer defects, and partial success. Typed classification is preserved before separate operator telemetry, user-safe messaging, and process-exit decisions.

Process exits are another API boundary

A CLI or service process communicates with a shell, supervisor, scheduler, or orchestrator through exit status plus output and telemetry. The fixture maps local policy to four illustrative values: success, invalid request, temporary failure, and permanent configuration-like failure.

Those numbers are not Rust guarantees. A product must document supported platforms, supervisor behavior, restart policy, signal handling, and whether partial work can precede exit. Mapping every error to status 1 discards useful automation semantics; inventing dozens of unstable codes creates a compatibility burden.

Services often continue after request-scoped failures. A corrupt global configuration, inability to bind the required socket, or failed startup migration may require process termination. A dependency outage during steady state may instead transition readiness, shed requests, or degrade. The same underlying resource class can have different process consequences at startup and during operation.

Panic exit behavior depends on unwind/abort profile, hooks, containment, and platform. Do not promise one exit code for all panic configurations. Treat the supervisor contract separately from the Rust error type.

Failure modes that compile cleanly

Several weak designs pass type checking:

Stringly recovery. Result<T, String> carries prose but no stable decision. Callers search substrings such as “timeout.” Repair by keeping a typed classification and generating prose at the presentation boundary.

Retry as a blanket middleware rule. Every 5xx or I/O error is retried three times. This repeats permanent failures, ignores deadlines and idempotency, and amplifies overload. Repair by classifying, budgeting, and measuring retries at the operation boundary.

Internal defects as Err(Unknown). Impossible state is converted into an opaque recoverable variant and the process continues with suspect state. Repair by defining containment; surface the defect distinctly and prevent automatic retry.

One enum mirrors dependencies. Public callers see variants named after replaceable drivers. Repair by translating to caller decisions while preserving sources privately.

Lossy conversion. map_err(|e| e.to_string()) discards type, source, and structured metadata. Repair by wrapping or converting without stringification.

Partial work hidden behind Err. The caller retries an entire batch and duplicates committed side effects. Repair with atomic execution, idempotent keys, or a partial-outcome type.

User and operator text are identical. Internal paths, tokens, topology, or raw input escape. Repair with separate presentation policy and redaction tests.

Public exhaustive enum grows casually. A minor release adds a variant and breaks downstream matches. Repair with an explicit evolution policy, non-exhaustive design where appropriate, and compatibility tests.

Applying the taxonomy to relay-service

Classify representative failures before writing transport handlers:

Failure Classification Caller action Operator action Retry default
frame exceeds declared 64 KiB cap invalid input send a smaller valid frame inspect abuse-rate signal never unchanged
protocol version unsupported invalid/compatibility negotiate or upgrade monitor version distribution never unchanged
bounded ingestion queue full capacity unavailable shed, defer within deadline, or reduce load inspect saturation only within budget and policy
storage connection reset environmental, potentially transient retry idempotent operation within deadline inspect dependency health classified delay
storage credentials rejected environmental, operator action stop blind retries rotate/fix credentials after operator action
duplicate idempotency key with same result successful replay or domain outcome reuse prior result usually none no new write
batch commits 8 of 10 allowed independent items partial success retry identified failures if safe monitor rejection causes per-item
accepted + rejected count differs from total programmer defect no ordinary repair contain and page according to impact never automatic

The queue-full row illustrates why “transient” is not enough. Retrying immediately against a saturated bounded queue increases load. The service may return an overload signal with a server hint, but the client still needs a deadline and backoff. Admission control is doing its job when it fails predictably.

For the parser from Chapter 33, malformed input should be rejected before a PacketView exists. That typed parse error may then convert into the service’s InvalidInput category without losing the local source. Raw bytes should not become a user message or metric label. An unsupported version may carry a safe supported-range hint; invalid UTF-8 usually should not echo hostile bytes.

Exercise: produce the failure contract before code

relay-service accepts batches of up to 1,000 events, validates each event, writes accepted events to a store, and publishes acknowledgements. The store can reject credentials, time out, report a conflict, or become read-only. Publishing can fail after storage commits. Clients supply idempotency keys and a request deadline.

Produce a failure decision record containing:

  1. a taxonomy of every named failure by actor, permanence, retryability, and disclosure;
  2. the atomicity or partial-success contract for validation, storage, and acknowledgement;
  3. Rust return types for validation, storage, the batch operation, and the top boundary;
  4. an idempotency rule for retry after storage commits but acknowledgement fails;
  5. a public error-evolution policy;
  6. user messages, operator event codes, metric labels, and CLI/process outcomes without sensitive duplication;
  7. tests proving invalid input is not retryable, credential failure does not hot-loop, partial outcomes retain item identity, and internal invariant failure is not treated as an ordinary rejection;
  8. the deadline and attempt budget for the one class you approve for automatic retry.

Reject any design that derives retry from display strings, loses committed item identities, or sends the same diagnostic representation to every audience.

Error-contract review card

  • Who can act on each failure: caller, end user, operator, supervisor, or developer?
  • Will identical repetition change the outcome, and within which deadline and budget?
  • Is invalid input distinguished from environmental state and programmer defect?
  • Does Option mean normal absence rather than suppressed failure?
  • Can partial work commit, and does the return type preserve its identity?
  • Are public variants stable semantic decisions rather than dependency names?
  • Can the error type evolve without surprising exhaustive downstream matches?
  • Is the original source preserved without leaking it into user text?
  • Is retry classification structured and separate from retry execution?
  • Are operator codes and metric labels bounded in cardinality?
  • Is the error emitted once at the boundary that owns the response?
  • Does process behavior distinguish request failure, degraded readiness, and fatal startup state?

Classification is the first robustness mechanism

Result is a transport for a decision already made. A strong error contract distinguishes caller-correctable input, changing environmental conditions, supported partial outcomes, normal absence, conflicts, and programmer defects. It records retry semantics as structured policy, preserves committed work, and separates user, operator, developer, and supervisor representations.

Typed enums are valuable when callers need stable decisions; opaque reports are valuable after those decisions are complete. Neither should mirror the whole dependency graph or reduce recovery to display text. Public error design also carries compatibility cost, so expose only distinctions that callers can use.

With the taxonomy in place, the next problem becomes evidence preservation. Chapter 35 follows one classified failure through adapters and application boundaries, adding cause and action context without stringifying it, logging it repeatedly, or exposing sensitive details.

Sources and version notes