Carry Error Kinds Don't Re-derive Them
Carry error kinds, don't re-derive them
If code decides how to handle an error by substring-matching its Display message, the error type is lying about what it knows. The kind was available at the source; throwing it away and reconstructing it from text downstream is fragile and silent when it breaks.
The bad shape
fn classify(err: &Error) -> ErrorKind {
let msg = err.to_string().to_lowercase();
if msg.contains("rate limit") { ErrorKind::RateLimited }
else if msg.contains("auth") { ErrorKind::Auth }
else if msg.contains("timeout") { ErrorKind::Timeout }
else { ErrorKind::Other }
}
This compiles, passes a happy-path test, and then someone rewords a message from "rate limited" to "too many requests" and the severity routing silently changes. There's no compiler error and usually no test, because the test that would catch it is a matrix nobody wrote. Spotuify had exactly this in a classify_error_kind, plus a sibling that stored format!("{:?}", kind) and re-parsed the Debug output later. Two places re-deriving a fact they were handed.
The good shape
Carry the typed kind from where it's known, all the way to where it's used.
#[derive(Debug, thiserror::Error)]
enum SpotifyError {
#[error("rate limited; retry after {retry_after:?}")]
RateLimited { retry_after: Duration, scope: String },
#[error("auth required")]
AuthRequired,
// ...
}
impl SpotifyError {
fn kind(&self) -> ErrorKind { /* match on self, no strings */ }
}
The HTTP layer knows it got a 429 with a Retry-After header. It builds RateLimited { retry_after } right there. Everything downstream matches on the variant. Crossing a process boundary (an IPC wire) is the one place this gets tempting to flatten back to a string — resist it: serialise the typed kind, and at the far end downcast_ref::<SpotifyError>() or read the typed wire field instead of re-classifying the rendered message. Spotuify's daemon does the downcast at its IPC seam, which is the right instinct.
Why this is the idiomatic answer
Rust hands you the tools to make this cheap: thiserror for the typed enum with Display derived from the variant, #[from] for source chaining, #[non_exhaustive] so you can add kinds later without a breaking change. The message is for humans; the variant is for control flow. When you find yourself parsing your own Display output, the type wanted another variant or a field, not a contains() call.
This is also a Client-Agnostic Cores concern: the core decides the kind once, and every client (CLI, TUI, MCP) reacts to the same typed signal instead of each re-interpreting prose differently.
See also
- Client-Agnostic Cores — one source of truth for state and classification, many views
- Idiomatic Rust Rubric — error handling is one of its scored dimensions
- thiserror: https://docs.rs/thiserror