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.
[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: enablesAsyncForgeErrorserde: enables serialization support where compatiblelog: enables thelogadaptertracing: enables thetracingadapterjitter: enables ±20% jitter inExponentialBackoff(pulls inrand)
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.
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);
}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 arecaption,retryable,fatal,statusandexit. 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 needDisplay/Debug. Variants without a display string use"<caption>: <Variant> | field = value", which needsDebugon every field (andDisplayon asourcefield). - Constructors are generated from the lowercase variant name, such as
ServiceError::config(...). - A field named
sourceparticipates instd::error::Error::source()chaining. - For custom
sourcefield types, implementerror_forge::macros::ErrorSourcein your crate. - With the
serdefeature 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 theForgeErrortrait. To pass the enum togroup!,ForgeErrorRecovery,log_errororprint_error, add a shortimpl ForgeErrorthat delegates to those methods (thegroup!API docs show one).
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);
}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());
}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_prefixerror_displayerror_kinderror_captionerror_retryableerror_http_statuserror_exit_codeerror_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.
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.
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::register_logger(...)installs a custom logger once.logging::log_impl::init()is available with thelogfeature.logging::tracing_impl::init()is available with thetracingfeature.
use error_forge::{console_theme::print_error, AppError};
fn main() {
let error = AppError::filesystem("config.toml", None);
print_error(&error);
}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());
}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);
}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"
);
}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-featurescargo test --workspaceper feature combination, including the README examples anddocs/API.mdas doctests and compile-pass / compile-fail tests for the macroscargo clippy --workspace --all-targets --all-features -- -D warningscargo clippy --workspace --all-targets -- -D warningscargo doc --workspace --all-features --no-depsunderRUSTDOCFLAGS="-D warnings"cargo +1.81 buildandcargo +1.81 test(MSRV)- a build on
1.81with every direct dependency at its declared minimum version cargo auditagainst the RustSec advisory database
- API reference — narrative walkthroughs.
- API-FREEZE-AUDIT — canonical manifest of every public symbol in
1.0.0. - STABILITY — binding SemVer / panic-safety / MSRV / deprecation policy.
- COMPARISON — side-by-side with
anyhow,thiserror,miette,snafu,eyre. - Architecture — design rationale.
- Migration — upgrade guides (
0.9.x → 1.0.0, etc.). - Examples — runnable code.
- docs.rs — generated docs.
Licensed under Apache-2.0.
COPYRIGHT © 2025 JAMES GOBER.