Skip to content

About

Error Forge is a flexible, high-performance Rust framework for rich, structured errors. Define typed errors with macros/derives, add contextual metadata & severities, wire sync/async hooks for logging/telemetry, and use recovery helpers (retry/fallback/backoff) that keep your code clean and resilient.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

Rust logo

ERROR FORGE

Crates.io Crates.io Downloads docs.rs MSRV CI

Pragmatic error modeling, contextual diagnostics, and resilience helpers for Rust.

Error Forge is a Rust error-handling crate built around a few simple ideas:

  • Errors should carry stable metadata such as kind, retryability, status code, and exit code.
  • Application code should be able to add context without destroying the original cause chain.
  • Operational tooling should have clear hooks for logging, formatting, codes, and recovery policies.
  • Feature-gated integrations should stay optional so the core remains lightweight.

It ships with a built-in AppError, a declarative define_errors! macro, an optional #[derive(ModError)] proc macro, error collectors, registry support, console formatting, synchronous retry and circuit-breaker primitives, and async-specific traits behind feature flags.

Installation

[dependencies]
error-forge = "1.0.2"

MSRV: Rust 1.81. CI builds and tests the crate on the exact 1.81.0 toolchain.

Common optional features:

  • derive: enables #[derive(ModError)]
  • async: enables AsyncForgeError
  • serde: enables serialization support where compatible
  • log: enables the log adapter
  • tracing: enables the tracing adapter
  • jitter: enables ±20% jitter in ExponentialBackoff (pulls in rand)

The console, backtrace, registry, collector and context features exist but currently gate nothing: console formatting, the error-code registry, collectors and context wrapping are always compiled in, and no backtrace is captured automatically. They are kept so existing Cargo.toml files keep resolving; enabling them has no effect.

Quick Start

Built-in AppError

use error_forge::{AppError, ForgeError};

fn load_config() -> Result<(), AppError> {
    Err(AppError::config("Missing DATABASE_URL").with_fatal(true))
}

fn main() {
    let error = load_config().unwrap_err();

    assert_eq!(error.kind(), "Config");
    assert!(error.is_fatal());
    println!("{}", error);
}

Defining Custom Errors With define_errors!

define_errors! is the lowest-friction way to create a custom error enum with generated constructors and ForgeError metadata.

use error_forge::{define_errors, ForgeError};
use std::io;

define_errors! {
    pub enum ServiceError {
        #[error(display = "Configuration is invalid: {message}", message)]
        #[kind(Config, status = 500)]
        Config { message: String },

        #[error(display = "Request to {endpoint} failed", endpoint)]
        #[kind(Network, retryable = true, status = 503)]
        Network { endpoint: String, source: Option<Box<dyn std::error::Error + Send + Sync>> },

        #[error(display = "Could not read {path}", path)]
        #[kind(Filesystem, status = 500)]
        Filesystem { path: String, source: io::Error },
    }
}

fn main() {
    let error = ServiceError::config("Missing API token".to_string());
    assert_eq!(error.kind(), "Config");
    assert_eq!(error.status_code(), 500);

    let error = ServiceError::network("billing.internal".to_string(), None);
    assert_eq!(error.to_string(), "Request to billing.internal failed");
    assert!(error.is_retryable());
}

Notes:

  • #[kind(...)] is required for each variant; #[error(display = ...)] is optional and may come before or after it. Doc comments and other attributes on variants are kept, and the trailing comma is optional.
  • The recognised #[kind] tags are caption, retryable, fatal, status and exit. Any other tag is a compile error.
  • The display string is a format! string: list fields after it ("{path}", path) or name them inline ("{path:?}"). Only the fields it formats need Display/Debug. Variants without a display string use "<caption>: <Variant> | field = value", which needs Debug on every field (and Display on a source field).
  • Constructors are generated from the lowercase variant name, such as ServiceError::config(...).
  • A field named source participates in std::error::Error::source() chaining.
  • For custom source field types, implement error_forge::macros::ErrorSource in your crate.
  • With the serde feature enabled, source fields must themselves be serializable if you want to derive serialization through the macro-generated enum.
  • The metadata methods (kind, caption, is_retryable, is_fatal, status_code, exit_code) are generated as inherent methods; the macro does not implement the ForgeError trait. To pass the enum to group!, ForgeErrorRecovery, log_error or print_error, add a short impl ForgeError that delegates to those methods (the group! API docs show one).

Adding Context Without Losing the Original Error

use error_forge::{AppError, ResultExt};

fn connect() -> Result<(), AppError> {
    Err(AppError::network("db.internal", None))
}

fn main() {
    let error = connect()
        .with_context(|| "opening primary database connection".to_string())
        .unwrap_err();

    println!("{}", error);
}

Collecting Multiple Errors

use error_forge::{AppError, ErrorCollector};

fn main() {
    let mut collector = ErrorCollector::new();
    collector.push(AppError::config("missing host"));
    collector.push(AppError::other("invalid timeout"));

    assert_eq!(collector.len(), 2);
    println!("{}", collector.summary());
}

Derive Macro

Enable the derive feature to use #[derive(ModError)].

use error_forge::{ForgeError, ModError};

#[derive(Debug, ModError)]
#[error_prefix("Database")]
enum DbError {
    #[error_display("Connection failed: {0}")]
    #[error_retryable]
    #[error_http_status(503)]
    ConnectionFailed(String),

    #[error_display("Query failed for {query}")]
    QueryFailed { query: String },

    #[error_display("Permission denied")]
    #[error_fatal]
    PermissionDenied,
}

fn main() {
    let error = DbError::ConnectionFailed("primary".to_string());
    assert!(error.is_retryable());
    assert_eq!(error.status_code(), 503);
}

Supported derive attributes:

  • error_prefix
  • error_display
  • error_kind
  • error_caption
  • error_retryable
  • error_http_status
  • error_exit_code
  • error_fatal

Every attribute accepts both the list form (#[error_http_status(404)]) and the name-value form (#[error_http_status = 404]). A bare #[error_retryable] or #[error_fatal] means true, and an explicit (false) / = false is honoured. A value of the wrong type or out of range (#[error_http_status("404")]) is a compile error.

On a struct, only error_prefix is read today: the struct displays as "<prefix>: Error" and uses the ForgeError defaults for the other metadata. The other attributes are ignored when placed on a struct.

Recovery and Resilience

The recovery module is intentionally synchronous today. It is designed for blocking code, worker threads, and service wrappers where a small sleep is acceptable.

use error_forge::recovery::{CircuitBreaker, CircuitOpenError, RetryPolicy};

fn main() {
    let breaker = CircuitBreaker::new("inventory-service");
    let policy = RetryPolicy::new_fixed(25).with_max_retries(3);

    // `execute` returns `RecoveryResult<T>`: the closure's error is boxed
    // as `Box<dyn Error + Send + Sync>`.
    let value = breaker.execute(|| policy.retry(|| Ok::<u32, std::io::Error>(42)));
    assert_eq!(value.unwrap(), 42);

    // Downcast the boxed error to tell a fail-fast rejection apart from
    // the closure's own error.
    if let Err(error) = breaker.execute(|| Err::<u32, _>(std::io::Error::other("down"))) {
        if error.is::<CircuitOpenError>() {
            eprintln!("circuit open, skipped the call");
        } else if let Some(io) = error.downcast_ref::<std::io::Error>() {
            eprintln!("call failed: {io}");
        }
    }
}

If you need async retries, keep Error Forge for modeling and classification, then wrap retry behavior with your async runtime of choice.

Hooks, Logging, and Formatting

Error Hooks

use error_forge::{
    AppError,
    macros::{try_register_error_hook, ErrorLevel},
};

fn main() {
    let _ = try_register_error_hook(|ctx| {
        if matches!(ctx.level, ErrorLevel::Critical | ErrorLevel::Error) {
            eprintln!("{} [{}]", ctx.caption, ctx.kind);
        }
    });

    let _ = AppError::config("Missing environment variable");
}

The hook runs on the thread that created the error. Errors created inside the hook itself (for example by a failing log sink) do not call it again, and a panic inside the hook is caught so it does not unwind through the error constructor.

Logging Adapters

  • logging::register_logger(...) installs a custom logger once.
  • logging::log_impl::init() is available with the log feature.
  • logging::tracing_impl::init() is available with the tracing feature.

Console Output

use error_forge::{console_theme::print_error, AppError};

fn main() {
    let error = AppError::filesystem("config.toml", None);
    print_error(&error);
}

Error Codes

Attach stable codes to errors when you want machine-readable identifiers or documentation links.

use error_forge::{register_error_code, AppError, ForgeError};

fn main() {
    let _ = register_error_code(
        "AUTH-001",
        "Authentication failed",
        Some("https://example.com/errors/AUTH-001"),
        false,
    );

    let error = AppError::config("Invalid credentials")
        .with_code("AUTH-001")
        .with_status(401);

    assert_eq!(error.status_code(), 401);
    println!("{}", error.dev_message());
}

Known Limitations

Ambiguous method calls with the async feature

AppError implements both ForgeError and, with the async feature, AsyncForgeError. The two traits have methods with the same names (kind, caption, is_retryable, ...). Cargo features are unified across the dependency graph, so if any crate in your build enables async, a call such as error.kind() fails with E0034 ("multiple applicable items in scope") wherever both traits are imported, for example through use error_forge::*.

Import only the trait you call, or use qualified syntax:

use error_forge::{AppError, ForgeError};

fn main() {
    let error = AppError::config("missing key");

    // Works whether or not `async` is enabled anywhere in the graph.
    assert_eq!(ForgeError::kind(&error), "Config");
    assert_eq!(<AppError as ForgeError>::status_code(&error), 500);
}

ResultExt::context and anyhow::Context

error_forge::ResultExt and anyhow::Context both add a context method to Result. With both traits in scope, result.context(...) is ambiguous. Import only one of them in a module, or call the error-forge method by path:

use error_forge::{AppError, ResultExt};

fn main() {
    let result: Result<(), AppError> = Err(AppError::config("bad value"));
    let wrapped = ResultExt::context(result, "loading settings");
    assert_eq!(
        wrapped.unwrap_err().to_string(),
        "loading settings: \u{2699}\u{fe0f} Configuration Error: bad value"
    );
}

Quality Bar

Every push runs the following on a Linux + macOS + Windows matrix across nine feature combinations, plus dedicated MSRV (1.81.0), minimal-versions and cargo audit jobs:

  • cargo build --workspace --all-features
  • cargo test --workspace per feature combination, including the README examples and docs/API.md as doctests and compile-pass / compile-fail tests for the macros
  • cargo clippy --workspace --all-targets --all-features -- -D warnings
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo doc --workspace --all-features --no-deps under RUSTDOCFLAGS="-D warnings"
  • cargo +1.81 build and cargo +1.81 test (MSRV)
  • a build on 1.81 with every direct dependency at its declared minimum version
  • cargo audit against the RustSec advisory database

Documentation

License

Licensed under Apache-2.0.


COPYRIGHT © 2025 JAMES GOBER.

About

Error Forge is a flexible, high-performance Rust framework for rich, structured errors. Define typed errors with macros/derives, add contextual metadata & severities, wire sync/async hooks for logging/telemetry, and use recovery helpers (retry/fallback/backoff) that keep your code clean and resilient.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages