Skip to content

The Rust Engineering Handbook / Chapter 35

Error Context, Conversion, and Reporting

Preserve typed causes, attach actionable context, redact sensitive details, and report each failure once at the boundary that owns the response.

Read the evidence that reaches the boundary

An ingestion command fails and leaves this operator event:

event=ingest_rejected class=dependency_unavailable record_id=42 exit_code=75

The event answers several operational questions. The failure belongs to ingestion. It concerns record 42. Policy classifies it as dependency unavailability. A command-line boundary selected an exit status. It does not disclose a credential, path, query, or payload.

The diagnostic chain retained beside that event answers a different question:

cause[0]=ingestion failed for record 42
cause[1]=store operation append failed
cause[2]=local fixture refusal

No single line is sufficient. The outer error says what the requested operation could not do. The middle error says which adapter operation failed. The final source records the concrete cause. If the storage layer had returned only "local fixture refusal", the application would know too little. If the application exposed every source line to the user, it could disclose too much. If all three layers logged, one failure would appear to be three incidents.

A sound error path preserves both the classification already chosen and the evidence needed to act on it:

While an error propagates, each layer may add context meaningful to its caller, but it must preserve the underlying cause unless the boundary intentionally redacts or translates it. The boundary that handles the failure owns emission, audience selection, and process disposition.

Context is not a longer message. It is the answer to a question introduced by a layer: which record, operation, shard, peer, configuration key, or phase was active? Reporting is not propagation. It is a boundary decision about what a caller, user, operator, developer, and supervisor may see.

The standard std::error::Error trait represents an error value that can describe itself and optionally expose a lower-level cause through source. Its supertraits require Debug and Display:

pub trait Error: Debug + Display {
    fn source(&self) -> Option<&(dyn Error + 'static)> { /* ... */ }
}

That shape provides a traversable chain. It does not classify retryability, choose an HTTP status, capture a backtrace, redact secrets, or log anything. Those remain type and boundary policy.

The verified fixture starts with a storage error:

#[derive(Debug)]
pub struct StoreError {
    operation: &'static str,
    source: std::io::Error,
}

impl std::fmt::Display for StoreError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "store operation {} failed", self.operation)
    }
}

impl std::error::Error for StoreError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.source)
    }
}

StoreError owns its io::Error. Returning a borrowed source requires the referenced value to remain part of the outer error for as long as the chain is inspected. The 'static bound on the trait object does not require the reference itself to live for the entire program; it means the erased source type contains no non-static borrowed dependencies.

The domain layer adds a record identifier while preserving StoreError:

#[derive(Debug)]
pub struct IngestError {
    record_id: u64,
    source: StoreError,
}

impl std::error::Error for IngestError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(&self.source)
    }
}

This is a causal chain, not a bag of strings. Code that needs the domain classification can call a typed method on IngestError. Generic diagnostic code can walk source() without knowing every concrete type. Downcasting is available for specialized tooling, but application policy should not depend on searching an arbitrary chain for whatever concrete dependency type happens to appear today.

Older code may implement or call the deprecated cause method. New code should use source. A source chain also need not be a complete distributed causal graph: another process, task, or remote dependency may contribute evidence carried only by a protocol status, trace identifier, or structured field.

Display serves readers; Debug serves inspection

Display and Debug have different jobs even though both produce text.

Display should express the current error at its abstraction level. IngestError says ingestion failed for record 42; it does not recursively append every source. This restraint lets a report renderer choose whether to show one line, a full chain, or a redacted summary. If every wrapper prints "{context}: {source}" and a reporter also traverses source, the same cause appears repeatedly.

Debug should expose a useful developer representation. Deriving it is often adequate for private types, but derived output can reveal every field, including secrets. Neither formatting trait creates a security boundary. Treat all formatted error text according to the sensitivity of the data stored inside the value.

Formatting is also not a stable control protocol. Changing punctuation in Display should not alter retry behavior. Localizing a user message should not break a metric label. A caller should select behavior through variants, fields, or classification methods:

match error.class() {
    ErrorClass::InvalidInput => reject_without_retry(),
    ErrorClass::DependencyUnavailable => consult_retry_budget(),
    ErrorClass::InternalDefect => contain_and_escalate(),
}

The text can then explain the decision to the intended audience.

Conversion must preserve the distinctions the caller needs

The ? operator returns early and uses conversion when the enclosing error type accepts the residual error. In common Result code, a suitable From implementation lets a lower-level error become the function’s declared error. That mechanism is convenient; it is not evidence that the conversion is semantically correct.

This conversion is lossless with respect to diagnosis:

impl From<(u64, StoreError)> for IngestError {
    fn from((record_id, source): (u64, StoreError)) -> Self {
        Self { record_id, source }
    }
}

It retains the source and adds the context needed by the domain operation. A tuple is used only to keep the dependency-free fixture compact; a named constructor is usually clearer at a real call site.

This conversion is lossy:

let result: Result<Record, String> = append(record)
    .map_err(|error| format!("append failed: {error}"));

The string may look informative, but it erases the concrete source, structured class, I/O kind, and any non-display metadata. The next layer can only concatenate more text. Retry and disclosure policy drift toward substring matching.

Automatic From implementations are strongest when one lower-level category maps unambiguously into one outer category. Suppose an adapter’s io::Error may mean invalid local configuration, temporary network loss, permission denial, or corrupt persisted state. A blanket From<io::Error> for DomainError cannot see the operation and deployment context needed to choose correctly. Use an explicit mapping at the call site or a constructor that receives that context.

Conversion can intentionally erase detail at a stable boundary. A public library may map several internal parser failures to one documented InvalidFrame variant so callers do not acquire a compatibility dependency on parser internals. It should retain a diagnostic source privately when doing so is useful and safe. Erasure is a design act, not an incidental to_string.

Add context at the layer that can name the failed job

Useful context is specific, bounded, and assigned once. A storage adapter knows whether it was appending or loading. A domain service knows the record identifier and business operation. A request boundary knows the route, tenant handle, or correlation identifier. None of those layers should invent facts owned by another.

Figure 35-1 separates two lanes that often get conflated. The upper lane retains the semantic class used for policy. The lower lane enriches diagnostic evidence without severing source links. Both meet at one handling boundary, which produces audience-specific outputs.

Two parallel left-to-right lanes cross a storage adapter, domain operation, and application boundary. The semantic lane preserves the stable class dependency unavailable. The diagnostic lane retains the original source, then adds operation context and record 42. At one handling boundary they branch into a user response, one operator event, and exit policy.

The figure’s duplication of DEPENDENCY UNAVAILABLE across layers means the policy class survives propagation; it does not mean each layer constructs and logs a new failure. The linked lower boxes show enrichment. The single branching point shows handling ownership.

Context values require the same review as ordinary telemetry:

  • Prefer stable record ids, operation names, and bounded phase enums over entire payloads.
  • Do not attach authentication tokens, raw authorization headers, secrets, private keys, or unredacted configuration.
  • Treat file paths, queries, usernames, tenant identifiers, and network addresses according to the product’s disclosure policy.
  • Avoid high-cardinality values in metric labels even when they are permitted in controlled logs or traces.
  • Preserve correlation identifiers as structured fields rather than interpolating them into every message.

A context frame may allocate, especially in general-purpose application reporting libraries. A concrete enum or struct can store common fields without formatting them early. Measure the cost only when error volume matters, but remember that an overload path can make normally rare errors frequent. Expensive backtrace capture and string building in a hot rejection loop can deepen an incident.

Structured metadata needs a schema, not an open-ended map

An error value can carry structured fields without turning itself into a telemetry envelope. The distinction is ownership. IngestError owns a record id because the failed operation is defined in terms of that record. A request id belongs to the request boundary. A trace id belongs to tracing context. A hostname or deployment region usually belongs to the event emitter, which can obtain it from the running environment without copying it into every error.

This separation prevents error types from becoming bags of optional strings:

struct EverythingError {
    message: String,
    source: Option<String>,
    record_id: Option<String>,
    request_id: Option<String>,
    host: Option<String>,
    tags: HashMap<String, String>,
}

That design looks flexible but weakens contracts. Callers cannot tell which fields exist for which failure. Source is already stringified. Arbitrary tags have no cardinality, sensitivity, or compatibility discipline. Tests drift toward checking the presence of keys rather than the decisions the type supports.

Prefer typed fields on the layer that owns them, then compose structured data at the handling boundary. A useful event schema distinguishes at least:

Field kind Example Stability and disclosure rule
event identity ingest_rejected stable, bounded, suitable for aggregation
semantic class dependency_unavailable stable control category; never derived from prose
operation context record_id = 42 structured; allowed only for approved audiences
causal evidence source chain or cause code restricted diagnostic channel; may be sensitive
correlation request or trace id high cardinality; searchable event field, not metric label
disposition retry denied, response sent, exit 75 records the action actually taken at this boundary

The event should record facts, not a dump of the error object’s memory. If an operator must group by source category, define a bounded source code rather than grouping full source messages. If a developer needs the full chain, store it in a controlled diagnostic field or linked crash record. If a user needs remediation, construct a product message from the semantic class and approved context, not from raw metadata.

Schema evolution is another API concern. Renaming dependency_unavailable can break dashboards and alerts even when no Rust type changes. Adding a high-cardinality field to a metric can change cost and reliability. Treat event names, class codes, and exit meanings as versioned boundary contracts with owners and tests.

Backtraces are evidence with a capture policy

A backtrace records a stack observation near a chosen capture point. It is not the same as an error source chain. The chain describes error abstraction and causality as modeled by the program. A backtrace describes frames in one thread at one moment, subject to build settings, environment, platform, symbol information, and runtime configuration.

Capture placement determines usefulness. Capturing only at the top boundary may show reporting frames but omit the origin. Capturing at every wrapper wastes memory and creates several nearly identical traces. A concrete error may carry one std::backtrace::Backtrace captured near origin, or an application report may capture according to its framework and configuration.

Do not make correctness depend on a backtrace being available or fully symbolized. The standard library exposes capture status, and disabled or unsupported capture is a legitimate outcome. Typed classes, sources, structured operation context, and correlation data must still support response and triage.

Backtraces can disclose code layout, paths, function names, and deployment structure. They generally belong in developer or authorized operator diagnostics, not in ordinary user responses. They also have a cost. Establish whether capture is always on, sampled, environment-controlled, or reserved for selected classes, then verify behavior in the intended release profile.

Public libraries and applications have different reporting jobs

A public library serves unknown callers. Its error surface should communicate stable distinctions those callers can act on, document sources and panic conditions, and avoid committing users to the library author’s logging or terminal framework. Returning an opaque dynamic report from every public operation can make application integration easy at first while making typed recovery and long-term compatibility difficult.

An application owns orchestration and presentation. Once it has made all typed decisions, it can use an opaque report to aggregate context, render a chain, attach a backtrace, or feed incident tooling. That erasure should happen at a narrow boundary:

Boundary Preserve May decide Should not decide
dependency adapter concrete source, local operation dependency-to-local classification end-user wording
domain operation semantic category, entity context retry eligibility or partial outcome process exit status
application orchestration full chain, request context response class and handling owner terminal formatting for every embedding
executable boundary redacted report and disposition output stream and exit status hidden library retry policy

The default changes for a private application with no downstream users: an application-wide report type may be a good fit after local adapters preserve structured policy. Even there, avoid one enormous public enum that mirrors every dependency. Typed local errors plus an application report often keep both reasoning and diagnostics manageable.

Emit once, where a response becomes owned

Propagation layers should usually add context and return. The handling boundary emits because it owns what happens next: reject the request, fall back, retry within budget, mark readiness false, terminate the command, or escalate a defect.

Logging in both the storage adapter and its caller creates predictable damage:

  1. one failure increments several apparent incident events;
  2. severity differs because no layer knows who will recover;
  3. correlation becomes harder because messages use different shapes;
  4. source text and sensitive fields are duplicated;
  5. tests become coupled to incidental logging.

There are justified exceptions. A component may record an internal attempt metric for every retry while the outer boundary logs only the final failed operation. An audit event may be required even when a caller recovers. A remote boundary may need to emit before causal context leaves the process. Name those as distinct events with distinct ownership; do not log the same error reflexively.

The fixture builds a bounded boundary report:

pub struct BoundaryReport {
    pub event: &'static str,
    pub class: &'static str,
    pub record_id: u64,
    pub user_message: &'static str,
    pub exit_code: u8,
}

Its tests deliberately put a token fragment and private path in the deepest io::Error. The source-chain test proves authorized diagnostics retain them. A separate test proves the user message contains neither. This is evidence for the example’s redaction path, not a universal guarantee that derived Debug or arbitrary report renderers redact data.

Exit codes are a supervisor-facing compatibility surface

Rust’s Termination machinery allows main to communicate process success or failure, and an executable can select explicit codes. The language does not assign a portable product meaning to a catalog of application-specific numbers. Shells, operating systems, service managers, and orchestrators can interpret status differently.

The fixture uses 75 as an illustrative temporary-failure disposition. A real command must document its supported codes, output streams, partial-work behavior, and platform scope. A long-running service often should not exit for a request-scoped dependency failure. Startup configuration failure may require termination, while steady-state dependency loss may instead change readiness or shed load.

Keep exit selection outside reusable libraries. A library returns the information needed to decide. A CLI adapter decides whether invalid input is 2, dependency unavailability is a temporary-failure code, or every failure is intentionally collapsed to 1 for portability. Stability matters: scripts and supervisors become callers of this interface.

Panic termination is a separate policy. Hooks, unwind versus abort configuration, containment, and platform behavior affect what runs and what status appears. Chapter 36 treats that path directly; do not force panics through a recoverable-error exit map after the fact.

Failure patterns that erase actionability

Recursive Display plus chain rendering. Every wrapper prints its source, and the top reporter walks sources. Causes repeat. Let each Display describe one layer; let the renderer choose chain depth.

A universal From conversion. Every I/O failure becomes DependencyUnavailable. Permission and invalid configuration are retried. Convert where operation context permits a correct classification.

Context as raw payload. A wrapper stores the entire request because it is convenient for debugging. Reports and derived Debug disclose credentials or personal data. Attach stable identifiers and approved bounded fields.

Opaque reporting inside a public library. Callers cannot match stable domain conditions without downcasting through implementation types. Preserve an intentional public contract; leave top-level presentation to applications.

Log and return. Every layer records the error and propagates it. Incident counts inflate and recovery appears as failure. Emit at the handling boundary, with separately named attempt or audit signals where required.

Backtrace as classification. Code infers error origin from frame names. Optimization or refactoring changes policy. Keep structured semantic classes independent of diagnostic stack evidence.

One display string as metric label. Cardinality grows with ids and source messages. Use a stable bounded class code and keep detailed context in controlled event fields.

Review the ownership of every error fact

Use these questions at an API or observability review:

  • Can callers make required decisions without parsing Display or inspecting Debug?
  • Does each wrapper preserve the original cause, or is an intentional translation documented?
  • Is context attached by the layer that actually knows it?
  • Can any error field or formatter disclose secrets, payloads, private paths, or tenant data?
  • Does one boundary own the response and primary operator event?
  • Are metric dimensions bounded independently of log fields?
  • Is backtrace capture located, configured, and costed deliberately?
  • Are public-library errors independent of an application reporting framework where callers need typed policy?
  • Are process exit meanings documented for the intended platforms and supervisors?
  • Do tests prove both source retention and audience redaction?

Exercise: repair the duplicated, stringified stack

Level: Integrate. Audit a three-layer ingestion path in which the adapter calls map_err(|e| e.to_string()), the domain layer prefixes a record id, and both layers log before returning. The command prints the final string and exits with status 1.

Deliver:

  1. concrete adapter and domain error types with a preserved source chain;
  2. a stable semantic classification independent of formatting;
  3. a table assigning context, retry, logging, user response, and exit ownership to layers;
  4. one structured operator event and one redacted user response;
  5. tests that retain the deepest cause, prevent a supplied secret from reaching the user message, and prove only the handling boundary invokes the event sink;
  6. a short decision on whether a backtrace is captured, and at which point.

Reject a solution that hides the same strings inside a generic wrapper without restoring typed decisions. Accept more than one error shape when the ownership table and evidence preserve the contract.

Durable conclusions

  • Error::source preserves diagnostic causality; it does not define retry, disclosure, or logging policy.
  • Display describes one abstraction layer. It is not a machine-readable recovery protocol or a security boundary.
  • From is appropriate only when conversion preserves or intentionally translates the distinctions the caller needs.
  • Context should be structured, bounded, and attached by the layer that knows it.
  • Backtraces complement source chains but may be disabled, costly, incomplete, or sensitive.
  • Reusable libraries expose actionable error contracts; applications own final reports and audience selection.
  • Propagate with context, then emit once at the boundary that owns the response.

The remaining abnormal path is not an Err value. A panic can begin while state is mid-mutation and control may unwind through destructors—or the process may abort without unwinding. The next chapter makes invariant preservation explicit for both policy choices.

Sources and verification notes

  • Rust standard library, std::error::Error and Error::source.
  • Rust standard library, std::fmt::Display, std::fmt::Debug, and std::backtrace.
  • Rust Reference, the question-mark operator.
  • Rust standard library, std::process::Termination and ExitCode.
  • Executable source: examples/rust-engineering-handbook/part-06/robustness-boundaries-lab/, Rust 2024 Edition, no third-party dependencies, rust-version = "1.85".
  • Draft examples were checked on Rust 1.97.0 and the stated MSRV. Formatting, all-target compilation, tests, doctests, Clippy with warnings denied, release build, and executable output are covered by the fixture README. These checks support the draft and are not independent editorial acceptance.