Rust Error Boundaries in Services

How to separate recoverable errors, bugs, transport responses, retryability, and logging context in Rust services.

Software ยท Rust Services

Rust makes error paths explicit, but it does not design the service boundary for you. A useful service distinguishes internal failure, client-visible response, retryability, and operator context.

Error categories

The first split is recoverable versus unrecoverable. Rust represents recoverable errors with Result<T, E>. panic! is for bugs, violated invariants, and states where continuing would be misleading or unsafe.

For a service, refine recoverable errors by boundary:

Layer Error shape
domain typed cause the application understands
storage or network dependency error with operation context
API boundary status code, public message, retry hint
telemetry structured fields for debugging

Do not expose dependency internals to clients. Do not erase domain meaning before telemetry sees it.

Public response is not the internal error

An internal error might contain table names, paths, peer addresses, SQL state, or upstream body snippets. The client response should contain only what the client can act on.

Example mapping:

Internal condition Public response Retry hint
invalid request field 400 do not retry without change
auth missing 401 retry after authentication
auth forbidden 403 do not retry
missing object 404 do not retry unless eventual consistency applies
dependency timeout 503 retry with backoff
invariant violation 500 do not blind-retry forever

The mapping belongs near the API boundary. Domain code should not know HTTP status codes unless HTTP is the domain.

Context without noise

Attach context at the point where it becomes known:

let account = repo
    .load_account(account_id)
    .await
    .context("loading account for invoice creation")?;

Good context names the operation and stable identifiers. Bad context repeats the callee name, includes secrets, or formats huge payloads.

Retryability is semantic

A timeout is not automatically safe to retry. If the server might have committed a side effect before the timeout, the operation needs idempotency or a read-after-write reconciliation path.

Represent retryability explicitly at the boundary. Do not make every io::ErrorKind::TimedOut retriable by default.

Practical checks

  • One conversion point from internal error to public response.
  • Logs include operation, stable IDs, and dependency name.
  • Public errors do not include secrets, SQL, stack traces, or filesystem paths.
  • Retry hints match side-effect semantics.
  • Panics are reserved for bugs and tested invariants, not ordinary bad input.
  • Tests cover error mapping for user-actionable failures.

Design guidance

Use typed errors where callers can act differently. Use opaque reports where only humans inspect the chain. Keep the conversion to protocol responses explicit, small, and boring.

Source anchors