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.

  • Languages
  • $rust-error-handling
  • 198 lines
  • 2 files
  • ~2,050 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- rust-error-handling

Installs to ~/.agents/skills/rust-error-handling/ for every project on this machine.

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

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.

~/.agents/skills/rust-error-handling/SKILL.md

--- name: rust-error-handling description: Designs Rust error handling with Result, the ? operator, thiserror 2 for library error enums and anyhow for application context, including error… license: MIT ---

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 writingError typeWhy
Library crate (published or shared internally)Own enum with thiserrorCallers can match; no dependency leaks
Binary, CLI, script, top-level service glueanyhow::Result<T> with .context()Fast, rich chains, nobody matches on it
Library function with one obvious failureSmall dedicated error struct, or std::io::Error if truly I/ODo not invent a mega-enum
Boundary (HTTP handler, gRPC, FFI, job runner)Map to the boundary’s format at the edge onlyKeep internals typed
no_stdOwn 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 wrapping io::Error need explicit map_err with context instead.
  • #[non_exhaustive] on public enums so adding a variant is not a breaking change.
  • Keep errors Send + Sync + 'static so they cross threads and fit anyhow::Error.
  • thiserror 2 supports no_std and no longer accepts raw identifiers in format strings: a field named r#type is 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 when RUST_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?

SituationChoose
Invalid input, I/O, network, parse, missing file, permissionResult
Broken internal invariant that indicates a bugpanic!/unreachable!/expect("why this cannot fail")
Tests, examples, build scriptsunwrap() and expect() are fine
Lock poisoning (Mutex::lock())expect("lock poisoned") is the common, accepted choice
Index that is provably in boundsIndexing 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 via From; a missing impl gives “the trait From<X> is not implemented”. Add #[from] or a map_err, not a blanket impl From<anything>.
  • Box<dyn Error> without + Send + Sync cannot cross tokio::spawn.
  • main() -> Result<(), Box<dyn Error>> prints only Debug of the top error, losing the chain; anyhow prints it, plain Box does not.
  • Option to 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.yamlgenerated from this page

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.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem