Rust Error Boundaries in Services
How to separate recoverable errors, bugs, transport responses, retryability, and logging context in 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.