Rust Error Handling skill for Codex
Typed errors for callers, full context for humans. Codex picks the error shape from the kind of crate: a non-exhaustive thiserror enum for libraries, anyhow with layered context for binaries, and explicit mapping at HTTP or CLI boundaries. It keeps messages from repeating their source, preserves the cause chain all the way to logs, and removes unwrap on external input while keeping justified expect calls on true invariants.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- rust-error-handling Installs to ~/.agents/skills/rust-error-handling/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install rust-error-handling The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project rust-error-handling Installs to .agents/skills/rust-error-handling/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/rust-error-handling.zipunzip -o rust-error-handling.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) rust-error-handling PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $rust-error-handling, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Designs Rust error handling with Result, the ? operator, thiserror 2 for library error enums and anyhow for application context, including error source chains, From conversions, exit codes, panics versus errors, and converting errors at crate and HTTP boundaries. Use when replacing unwrap and expect in production code, defining a crate's public error type, adding context to failures, deciding between thiserror and anyhow, or when error messages lose the underlying cause.
Use it when
- A Rust crate has unwrap or expect on input it does not control
- You are defining or reviewing a library's public error enum
- Error output loses the underlying cause or prints it twice
- Errors must be mapped to HTTP statuses or CLI exit codes
Use something else when
- You want language-agnostic guidance on error strategy across services: use error-handling-patterns
- The question is CLI argument parsing and exit code conventions: use rust-clap-cli
How to check the result
Force a real failure and confirm the printed chain names the operation, the input and the root cause; clippy with unwrap_used reports nothing in library code and tests assert error variants.
The mistake it prevents
Writing error messages that embed the source error text, so every chain-aware reporter prints the cause twice.
Done well means: no unwrap() on input the program does not control, every error a
caller might branch on is a typed variant, every error a human will read says what was
being attempted and why it failed, and the full cause chain survives to the log line.
Decide the error shape first
| Code you are writing | Error type | Why |
|---|---|---|
| Library crate (published or shared internally) | Own enum with thiserror | Callers can match; no dependency leaks |
| Binary, CLI, script, top-level service glue | anyhow::Result<T> with .context() | Fast, rich chains, nobody matches on it |
| Library function with one obvious failure | Small dedicated error struct, or std::io::Error if truly I/O | Do not invent a mega-enum |
| Boundary (HTTP handler, gRPC, FFI, job runner) | Map to the boundary’s format at the edge only | Keep internals typed |
no_std | Own enum implementing core::error::Error (stable since 1.81) | No alloc required |
Read Cargo.toml and AGENTS.md before adding crates; many workspaces already standardise
on one of thiserror, snafu, anyhow, eyre/color-eyre or miette. Follow the
existing choice. Adding a crate needs network for cargo add.
Library errors with thiserror (2.x)
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ConfigError {
#[error("config file not found at {path}")]
NotFound { path: PathBuf },
#[error("failed to read {path}")]
Read { path: PathBuf, #[source] source: std::io::Error },
#[error("invalid value for `{key}`: {reason}")]
Invalid { key: String, reason: String },
#[error(transparent)]
Parse(#[from] toml::de::Error),
}
pub fn load(path: &Path) -> Result<Config, ConfigError> {
let text = std::fs::read_to_string(path).map_err(|source| match source.kind() {
std::io::ErrorKind::NotFound => ConfigError::NotFound { path: path.into() },
_ => ConfigError::Read { path: path.into(), source },
})?;
Ok(toml::from_str(&text)?) // #[from] gives the From impl for ?
}
Rules for the enum:
- Variants describe what failed for the caller, not which dependency failed. Avoid
Reqwest(reqwest::Error)in a public API; it couples callers to your dependency version. - Message text: lowercase, no trailing period, does not repeat the source’s message.
The source is reported separately through
source()."failed to read {path}: {source}"prints the cause twice in any chain-aware reporter. #[from]only when the conversion is unambiguous. Two variants wrappingio::Errorneed explicitmap_errwith context instead.#[non_exhaustive]on public enums so adding a variant is not a breaking change.- Keep errors
Send + Sync + 'staticso they cross threads and fitanyhow::Error. - thiserror 2 supports
no_stdand no longer accepts raw identifiers in format strings: a field namedr#typeis written{type}in#[error]. When upgrading from 1.x, build and fix the attribute errors rustc reports.
Application errors with anyhow
use anyhow::{bail, ensure, Context, Result};
fn run(args: &Args) -> Result<()> {
let cfg = config::load(&args.config)
.with_context(|| format!("loading config from {}", args.config.display()))?;
ensure!(cfg.workers > 0, "workers must be at least 1, got {}", cfg.workers);
if cfg.endpoints.is_empty() {
bail!("no endpoints configured in {}", args.config.display());
}
sync(&cfg).context("initial sync failed")?;
Ok(())
}
.context("static str")is fine; use.with_context(|| format!(..))when the message allocates so the happy path stays free.- Context describes the operation at this layer (“loading config from ./app.toml”), not the cause; the cause is already in the chain.
- Branching on an anyhow error is possible (
err.downcast_ref::<ConfigError>()), but if you need it often, the function should return a typed error. - Printing:
{}shows only the outermost message,{:#}shows the chain on one line,{:?}shows the chain plus a backtrace whenRUST_BACKTRACE=1. Log with{:#}or{:?}, never{}.
Converting at boundaries
// axum handler: typed internal errors -> HTTP status; log the chain, do not leak it
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
let status = match &self {
ApiError::NotFound(_) => StatusCode::NOT_FOUND,
ApiError::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
ApiError::Internal(e) => {
tracing::error!(error = ?e, "internal error");
StatusCode::INTERNAL_SERVER_ERROR
}
};
(status, Json(json!({ "error": self.public_message() }))).into_response()
}
}
For CLIs, return std::process::ExitCode from main (or anyhow::Result<()>, which exits
with code 1 and prints the Debug chain). Map specific errors to documented exit codes in one
place; see $rust-clap-cli.
Panic or error?
| Situation | Choose |
|---|---|
| Invalid input, I/O, network, parse, missing file, permission | Result |
| Broken internal invariant that indicates a bug | panic!/unreachable!/expect("why this cannot fail") |
| Tests, examples, build scripts | unwrap() and expect() are fine |
Lock poisoning (Mutex::lock()) | expect("lock poisoned") is the common, accepted choice |
| Index that is provably in bounds | Indexing is fine; otherwise .get() and handle None |
expect messages state the invariant (“regex is a valid literal”), not “failed”.
Enforce in CI for library code:
# Cargo.toml (workspace or crate)
[lints.clippy]
unwrap_used = "warn"
expect_used = "warn" # optional; many teams allow expect with a reason
panic = "warn"
Migration: unwrap-heavy code to proper errors
- [ ] 1. rg -n '\.unwrap\(\)|\.expect\(' src/ and classify each (input, invariant, test)
- [ ] 2. Change leaf functions to return Result first, then work upward
- [ ] 3. Add context at each layer where a human would want to know "while doing what"
- [ ] 4. Replace Box<dyn Error> in public APIs with a typed enum
- [ ] 5. Add tests that assert the variant (matches!) for each failure path
- [ ] 6. Run with a forced failure and read the printed chain
#[test]
fn missing_file_is_not_found() {
let err = load(Path::new("/nope.toml")).unwrap_err();
assert!(matches!(err, ConfigError::NotFound { .. }));
}
Gotchas
?converts viaFrom; a missing impl gives “the traitFrom<X>is not implemented”. Add#[from]or amap_err, not a blanketimpl From<anything>.Box<dyn Error>without+ Send + Synccannot crosstokio::spawn.main() -> Result<(), Box<dyn Error>>prints onlyDebugof the top error, losing the chain; anyhow prints it, plain Box does not.Optionto error:.ok_or_else(|| MyError::Missing { key })(lazy), not.ok_or(...)when building the error allocates.- Logging and returning the same error at every layer produces duplicate log lines. Log once, at the boundary that handles it.
#[error(transparent)]forwards Display and source; use it for wrapper variants that add no context of their own.- Do not stringify errors (
map_err(|e| e.to_string())) mid-stack; it destroys the chain.
Verification
cargo clippy --all-targets -- -D warnings
cargo test
rg -n '\.unwrap\(\)' src/ --glob '!**/tests/**' # remaining unwraps, each justified
Trigger one real failure (missing file, bad flag, dead endpoint) and confirm the output names the operation, the input (path, key, URL) and the root cause, in that order.
Report template
**Scope:** crates/config (library), crates/cli (binary)
**Design:** ConfigError (thiserror, 4 variants, #[non_exhaustive]); cli uses anyhow with context
**Removed:** 23 unwrap() on external input; kept 3 expect() on invariants (listed)
**Sample output:** Error: loading config from ./app.toml: invalid value for `workers`: must be >= 1
**Checks:** clippy unwrap_used clean in library code; 6 new variant tests pass What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Rust Error Handling"
short_description: "Typed errors for callers, full context for humans"
default_prompt: "$rust-error-handling Replace the unwraps in this crate with proper error handling" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.