mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-06 08:18:58 +00:00
`thiserror`-derived `Display` does not walk `#[source]`, so `format!("{err}")`
and `format!("{err:#}")` on a typed error silently produce only the
top-level message — the same format string changes meaning when migrating
from `anyhow::Result` to a typed `Result`. Point at
`fabro_util::error::collect_chain` as the canonical helper and broaden
the test guidance to cover typed errors.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
58 lines
2.4 KiB
Markdown
58 lines
2.4 KiB
Markdown
# Error Handling Strategy
|
|
|
|
Preserve error structure until the boundary where the error is rendered.
|
|
|
|
A `String` is a rendered message, not an internal error transport. Inside Rust crates, prefer typed errors, `anyhow::Error`, or small domain error enums. Convert to `String` only at projection boundaries: CLI output, API JSON detail, serialized config, logs/audit text, or an external contract that intentionally requires text.
|
|
|
|
## Preserve source chains
|
|
|
|
Prefer:
|
|
|
|
```rust
|
|
.map_err(anyhow::Error::new)?;
|
|
.context("failed to fetch GitHub metadata")?;
|
|
.with_context(|| format!("failed to fetch GitHub metadata for {repo}"))?;
|
|
?;
|
|
```
|
|
|
|
Avoid for real errors:
|
|
|
|
```rust
|
|
.map_err(|err| anyhow!("{err}"))?;
|
|
.map_err(|err| err.to_string())?;
|
|
```
|
|
|
|
When adding context, use `context` / `with_context`; do not interpolate the source error into a new string. Interpolation turns a structured error into text and drops its `source()` chain.
|
|
|
|
## Public vs internal surfaces
|
|
|
|
CLI/miette output, logs, and telemetry may render the full cause chain.
|
|
|
|
HTTP API responses must stay curated. Log the full internal chain, but return only a safe public message. Do not expose `format!("{err:#}")` in API JSON.
|
|
|
|
### Rendering the chain
|
|
|
|
Anyhow walks the chain in `format!("{err:#}")`. `thiserror`-derived `Display` impls do **not** — they only render the variant's `#[error("...")]` template, even with `:#`. Rendering a typed error directly silently drops every `#[source]`.
|
|
|
|
Use `fabro_util::error::collect_chain` at any boundary that should preserve the chain:
|
|
|
|
```rust
|
|
let rendered = fabro_util::error::collect_chain(&err).join(": ");
|
|
```
|
|
|
|
When migrating from `anyhow::Result<T>` to a typed `Result<T, E>`, audit every formatter on the call path. `format!("{err}")` and `format!("{err:#}")` look identical but change behavior at the boundary — anyhow walks the chain, a typed error does not.
|
|
|
|
## Clone-bound storage
|
|
|
|
Do not fall back to `String` only because `anyhow::Error` is not `Clone`.
|
|
|
|
Prefer, in order:
|
|
|
|
1. Remove the clone requirement.
|
|
2. Use a small cloneable domain error.
|
|
3. Use one shared cloneable error wrapper for arbitrary error chains.
|
|
4. Use `String` only when the field is already a rendered projection.
|
|
|
|
## Tests
|
|
|
|
When changing error propagation, add a regression test that walks the source chain and proves the underlying cause is still present. For `anyhow::Error`, iterate `err.chain()`. For typed errors, recurse `err.source()` or assert on `collect_chain(&err)`.
|