This document is the narrative API reference — guided walkthroughs
of the most common surfaces. For the canonical manifest of every
public symbol in 1.0.0 (locked under SemVer for the 1.x line), see
API-FREEZE-AUDIT.md.
Other companions:
STABILITY.md— binding SemVer / panic-safety / MSRV / deprecation policy.COMPARISON.md— side-by-side withanyhow,thiserror,miette,snafu,eyre.architecture.md— design rationale for the trait, hook system, recovery primitives.migration.md— upgrade guides from earlier versions.
This document tracks the public surface that is available today. It intentionally favors accuracy over aspiration.
Every rust code block in this file is compiled and run as a doctest
(cargo test --all-features), so the examples track the real API. The
examples that use #[derive(ModError)] or AsyncForgeError need the
derive and async features.
| Feature | Enables |
|---|---|
derive |
Re-exports #[derive(ModError)] from error-forge-derive |
async |
AsyncForgeError, AsyncResult, and async helpers on AppError |
serde |
Serialize on AppError |
log |
logging::log_impl adapter |
tracing |
logging::tracing_impl adapter |
jitter |
±20% jitter in ExponentialBackoff::with_jitter (pulls in rand) |
console, backtrace, registry, collector, context |
Reserved. They currently gate nothing; the related APIs are always available. |
define_errors! adds #[cfg_attr(feature = "serde", derive(serde::Serialize))]
to the enums it generates. That cfg is evaluated in your crate, so it
follows your crate's own serde feature, not error-forge's.
ForgeError is the crate's central trait. It extends std::error::Error with stable metadata that is useful in logs, HTTP layers, workers, and recovery policies. Its shape, with the provided default bodies:
use std::backtrace::Backtrace;
pub trait ForgeError: std::error::Error + Send + Sync + 'static {
fn kind(&self) -> &'static str;
fn caption(&self) -> &'static str;
fn is_retryable(&self) -> bool { false }
fn is_fatal(&self) -> bool { false }
fn status_code(&self) -> u16 { 500 }
fn exit_code(&self) -> i32 { 1 }
fn user_message(&self) -> String { self.to_string() }
fn dev_message(&self) -> String { format!("[{}] {}", self.kind(), self) }
fn backtrace(&self) -> Option<&Backtrace> { None }
// The real default forwards to the registered error hook.
fn register(&self) {}
}Key defaults:
is_retryable():falseis_fatal():falsestatus_code():500exit_code():1
AppError is the built-in general-purpose error enum. Variants:
ConfigFilesystemNetworkOther
Convenience constructors:
AppError::config(...)AppError::filesystem(...)AppError::filesystem_with_source(...)AppError::network(...)AppError::network_with_source(...)AppError::other(...)
Common modifiers:
with_retryable(bool)with_fatal(bool)with_status(u16)with_code(...)context(...)
use error_forge::{AppError, ForgeError};
let error = AppError::network("api.example.com", None).with_status(502);
assert_eq!(error.kind(), "Network");
assert!(error.is_retryable());
assert_eq!(error.status_code(), 502);pub type AppResult<T> = std::result::Result<T, error_forge::AppError>;error_forge::Result<T> is a deprecated alias for the same type. It
shadows std::result::Result in use error_forge::* glob imports.
Use define_errors! when you want a custom error enum with generated constructors and ForgeError-style metadata methods. The methods are inherent; the macro does not implement the ForgeError trait itself, so add a delegating impl ForgeError when the enum has to satisfy a ForgeError bound (group!, ForgeErrorRecovery, log_error, print_error).
use error_forge::define_errors;
define_errors! {
pub enum ApiError {
#[error(display = "Configuration error: {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 },
}
}
fn main() {
let error = ApiError::network("api.example.com".to_string());
assert_eq!(error.to_string(), "Request to api.example.com failed");
assert_eq!(error.kind(), "Network");
assert!(error.is_retryable());
assert_eq!(error.status_code(), 503);
}Rules and behavior:
- Each variant requires
#[kind(...)].#[error(display = ...)]is optional and may come before or after it; doc comments and other attributes on a variant are kept, and the trailing comma after the last variant is optional. - Constructor names are the lowercase form of the variant name (
RequestFailedbecomesrequestfailed). retryable,fatal,status,exit, andcaptioncan be supplied inside#[kind(...)]. Any other tag is a compile error.- The display string is a
format!string. Fields listed after it are passed as named arguments and fields named inline ({path:?}) are captured, so{{and}}print literal braces. Only the fields the string formats needDisplayorDebug. - A field named
sourceis used forError::source()chaining. - For custom
sourcefield types, implementerror_forge::macros::ErrorSourcein your crate. - If
#[error(display = ...)]is omitted, display falls back to the caption, variant name, and debug-formatted fields (asourcefield is shown withDisplay). Only this fallback needs every field to implementDebug.
group! creates a parent error enum with From<T> conversions for wrapped source types. Every wrapped type must implement ForgeError; the generated ForgeError impl delegates to the wrapped value. To group a foreign error such as std::io::Error, wrap it in your own error type first.
use error_forge::{group, AppError, ForgeError, ModError};
#[derive(Debug, ModError)]
#[error_prefix("Storage")]
pub enum StorageError {
#[error_display("Disk full on {0}")]
#[error_http_status(507)]
DiskFull(String),
}
group! {
#[derive(Debug)]
pub enum ServiceError {
App(AppError),
Storage(StorageError),
}
}
fn main() {
let error: ServiceError = StorageError::DiskFull("/var".to_string()).into();
assert_eq!(error.to_string(), "Disk full on /var");
assert_eq!(error.status_code(), 507);
let error: ServiceError = AppError::config("missing key").into();
assert_eq!(error.kind(), "Config");
}This macro is best suited for coarse-grained composition at module or service boundaries.
Enable the derive feature to use #[derive(ModError)].
Supported attributes:
error_prefixerror_displayerror_kinderror_captionerror_retryableerror_http_statuserror_exit_codeerror_fatal
error_display is optional and may mention any subset of the fields: by name for struct-like variants ({type} for a raw r#type field), by position for tuple variants. Width and precision given where the error is formatted ({:>20}) apply to the whole message.
Every attribute accepts the list form (#[error_http_status(404)]) and the name-value form (#[error_http_status = 404]). #[error_retryable] and #[error_fatal] alone mean true; an explicit (false) or = false is honoured. A value of the wrong type, or an integer that does not fit (u16 for error_http_status, i32 for error_exit_code), is a compile error.
On a struct only error_prefix is read; the struct displays as "<prefix>: Error" and the other attributes are ignored.
Example:
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: {reason}")]
QueryFailed { reason: String, query: String },
#[error_display("Permission denied")]
#[error_fatal]
PermissionDenied,
}
let err = DbError::ConnectionFailed("primary".into());
assert!(err.is_retryable());
assert_eq!(err.status_code(), 503);
assert_eq!(err.caption(), "Database: Error");
let err = DbError::QueryFailed {
reason: "timeout".into(),
query: "SELECT 1".into(),
};
assert_eq!(err.to_string(), "Query failed: timeout");
assert!(DbError::PermissionDenied.is_fatal());Wraps an error and an arbitrary context value. The error and context fields are public; the struct is #[non_exhaustive], so build it with ContextError::new or the extension methods.
Useful methods:
ContextError::new(error, context)into_error()map_context(...)context(...)to nest additional context layers
Extension trait for Result<T, E>:
context(value)eagerly adds context on errorwith_context(|| value)lazily creates context only on error
use error_forge::{AppError, ForgeError, ResultExt};
fn load_settings(path: &str) -> Result<String, AppError> {
Err(AppError::config(format!("{path} is empty")))
}
let err = load_settings("app.toml")
.with_context(|| "Loading settings".to_string())
.unwrap_err();
assert_eq!(err.context, "Loading settings");
assert_eq!(
err.to_string(),
"Loading settings: ⚙️ Configuration Error: app.toml is empty"
);
// Metadata comes from the wrapped error.
assert_eq!(err.kind(), "Config");
let outer = err.context("Starting service");
assert!(outer.to_string().starts_with("Starting service: Loading settings: "));anyhow::Context also adds a context method to Result. With both traits in scope the call is ambiguous; import only one per module, or call this one by path: ResultExt::context(result, "...").
Registers a stable code with description, optional documentation URL, and retryability metadata. Registering the same code twice returns Err.
Attach a code to an error with with_code(...).
CodedError<E> preserves the underlying error and supports instance-level overrides:
with_retryable(bool)with_fatal(bool)with_status(u16)
Retryability resolution order:
- explicit instance override
- registered code metadata
- underlying error metadata
Status-code resolution order:
- explicit instance override
- underlying error status code
use error_forge::{register_error_code, AppError, ForgeError};
register_error_code(
"AUTH-001",
"Credentials were rejected",
Some("https://docs.example.com/errors/auth-001"),
false,
)
.expect("code registered once");
let error = AppError::other("bad password").with_code("AUTH-001");
assert_eq!(error.to_string(), "[AUTH-001] 🚨 Error: bad password");
assert!(!error.is_retryable());
assert!(error.dev_message().ends_with("(https://docs.example.com/errors/auth-001)"));
assert_eq!(error.code_info().unwrap().description, "Credentials were rejected");
// Per-instance overrides win over the registry and the inner error.
let error = AppError::other("rate limited")
.with_code("AUTH-001")
.with_retryable(true)
.with_status(429);
assert!(error.is_retryable());
assert_eq!(error.status_code(), 429);An accumulator for collecting multiple errors before returning.
Useful methods:
new()push(...)with(...)len()is_empty()into_result(ok_value)result(ok_value)try_collect(...)errors(),into_errors()summary()forE: ForgeErrorhas_fatal()forE: ForgeErrorall_retryable()forE: ForgeError
use error_forge::{AppError, CollectError, ErrorCollector};
struct Form {
username: String,
email: String,
}
fn validate(form: &Form) -> Result<(), ErrorCollector<AppError>> {
let mut errors = ErrorCollector::new();
if form.username.len() < 3 {
errors.push(AppError::other("username is too short"));
}
if !form.email.contains('@') {
errors.push(AppError::other("email is invalid"));
}
let parsed: Result<u32, AppError> = "42".parse().map_err(|_| AppError::other("bad age"));
let _age = parsed.collect_err(&mut errors);
errors.into_result(())
}
let errors = validate(&Form {
username: "al".into(),
email: "nowhere".into(),
})
.unwrap_err();
assert_eq!(errors.len(), 2);
assert!(errors.summary().starts_with("2 errors collected (0 fatal, 0 retryable)"));Available in error_forge::macros (and re-exported at the crate root):
try_register_error_hook(...)register_error_hook(...)(deprecated; ignores a failed registration)ErrorContextErrorLevel
call_error_hook(...) is hidden and used by generated code. Only one hook can be installed per process; try_register_error_hook(...) returns an error if a hook was already installed. Every AppError and define_errors! constructor calls the hook, as does ForgeError::register.
The hook is not re-entered: an error created while the hook runs on the same thread (for example by a log sink that fails) does not call it again. A panic inside the hook is caught and discarded so it does not unwind through the constructor that fired it; the process panic hook still reports it.
use error_forge::{try_register_error_hook, AppError, ErrorLevel};
use std::sync::{Arc, Mutex};
let seen: Arc<Mutex<Vec<String>>> = Arc::default();
let sink = Arc::clone(&seen);
try_register_error_hook(move |ctx| {
let level = match ctx.level {
ErrorLevel::Critical => "critical",
ErrorLevel::Error => "error",
ErrorLevel::Warning => "warning",
ErrorLevel::Info => "info",
ErrorLevel::Debug => "debug",
// `ErrorLevel` is `#[non_exhaustive]`.
_ => "other",
};
sink.lock().unwrap().push(format!("{level}: {}", ctx.kind));
})
.expect("first hook in this process");
let _config = AppError::config("missing key");
let _network = AppError::network("api.example.com", None);
assert_eq!(*seen.lock().unwrap(), ["error: Config", "info: Network"]);
assert!(try_register_error_hook(|_| {}).is_err());Available in error_forge::logging:
register_logger(...)logger()log_error(...)ErrorLoggercustom::ErrorLoggerBuilder
Feature-gated adapters:
logging::log_impl::init()with thelogfeaturelogging::tracing_impl::init()with thetracingfeature
ErrorLogger::log_panic is not called automatically; forward to it from your own panic hook if you want panics logged.
use error_forge::logging::custom::ErrorLoggerBuilder;
use error_forge::{log_error, register_logger, AppError};
use std::sync::{Arc, Mutex};
let lines: Arc<Mutex<Vec<String>>> = Arc::default();
let sink = Arc::clone(&lines);
let logger = ErrorLoggerBuilder::new()
.with_error_fn(move |error, level| {
sink.lock().unwrap().push(format!("{level:?} {}", error.dev_message()));
})
.build();
register_logger(logger).expect("first logger in this process");
log_error(&AppError::config("missing key").with_fatal(true));
assert_eq!(
*lines.lock().unwrap(),
["Critical [Config] ⚙️ Configuration Error: missing key"]
);Provides console-friendly formatting and panic-hook installation. ConsoleTheme::new() detects colour support (stderr must be a terminal, TERM not dumb, and NO_COLOR unset or empty); with_colors() and plain() force the choice.
Useful exports:
ConsoleThemeprint_error(...)install_panic_hook()
use error_forge::{print_error, AppError, ConsoleTheme};
let error = AppError::config("Configuration file not found");
// Writes to stderr with the cached default theme.
print_error(&error);
let report = ConsoleTheme::plain().format_error(&error);
assert!(report.contains("Configuration file not found"));
assert!(report.contains("Retryable: No"));
// `install_panic_hook()` installs a hook that prints panics through the
// default theme and then runs the hook that was installed before it.
error_forge::install_panic_hook();The recovery APIs are synchronous.
ExponentialBackoff:with_initial_delay,with_max_delay,with_factor,with_jitterLinearBackoff:with_initial_delay,with_increment,with_max_delayFixedBackoff::new(delay_ms)
Every strategy implements the Backoff trait (next_delay(attempt)). Delays never exceed the configured maximum.
use error_forge::recovery::{Backoff, ExponentialBackoff, FixedBackoff, LinearBackoff};
use std::time::Duration;
let exponential = ExponentialBackoff::new()
.with_initial_delay(100)
.with_max_delay(1_000)
.with_factor(2.0);
assert_eq!(exponential.next_delay(0), Duration::from_millis(100));
assert_eq!(exponential.next_delay(2), Duration::from_millis(400));
assert_eq!(exponential.next_delay(10), Duration::from_millis(1_000));
let linear = LinearBackoff::new()
.with_initial_delay(100)
.with_increment(50)
.with_max_delay(300);
assert_eq!(linear.next_delay(1), Duration::from_millis(150));
assert_eq!(linear.next_delay(9), Duration::from_millis(300));
let fixed = FixedBackoff::new(200);
assert_eq!(fixed.next_delay(5), Duration::from_millis(200));RetryPolicy::new_exponential()RetryPolicy::new_linear()RetryPolicy::new_fixed(delay_ms)with_max_retries(...)executor::<E>()forge_executor::<E>()(retries only errors whoseis_retryable()is true)retry(...)RetryExecutor::with_retry_if(...)andretry_with_handler(...)
RetryExecutor uses blocking sleeps, so it is best suited to sync workloads or dedicated worker threads.
use error_forge::recovery::RetryPolicy;
use error_forge::AppError;
use std::cell::Cell;
let attempts = Cell::new(0);
let result = RetryPolicy::new_fixed(1)
.with_max_retries(3)
.forge_executor::<AppError>()
.retry(|| {
attempts.set(attempts.get() + 1);
if attempts.get() < 3 {
// Network errors are retryable by default.
Err(AppError::network("api.example.com", None))
} else {
Ok("response")
}
});
assert_eq!(result.unwrap(), "response");
assert_eq!(attempts.get(), 3);
// Config errors are not retryable, so they fail on the first attempt.
let attempts = Cell::new(0);
let result: Result<(), AppError> = RetryPolicy::new_fixed(1)
.forge_executor()
.retry(|| {
attempts.set(attempts.get() + 1);
Err(AppError::config("bad key"))
});
assert!(result.is_err());
assert_eq!(attempts.get(), 1);CircuitBreaker::new(name)CircuitBreaker::with_config(name, config)execute(...)state()reset()CircuitBreakerConfig::new(threshold, window_ms, reset_ms)orCircuitBreakerConfig::default()withwith_failure_threshold,with_failure_window_ms,with_reset_timeout_ms
States:
Closed: calls pass through and failures are counted inside the windowOpen: calls fail fast withCircuitOpenErrorHalfOpen: the reset timeout has elapsed; one probe call is admitted and others fail fast until it resolves
use error_forge::recovery::{CircuitBreaker, CircuitBreakerConfig, CircuitOpenError, CircuitState};
use error_forge::AppError;
let config = CircuitBreakerConfig::default()
.with_failure_threshold(2)
.with_reset_timeout_ms(30_000);
let breaker = CircuitBreaker::with_config("inventory", config);
for _ in 0..2 {
let result = breaker.execute(|| Err::<(), _>(AppError::network("inventory", None)));
assert!(result.is_err());
}
assert_eq!(breaker.state(), CircuitState::Open);
// While open, calls fail fast without running the closure.
let err = breaker.execute(|| Ok::<_, AppError>("unreachable")).unwrap_err();
assert!(err.is::<CircuitOpenError>());
breaker.reset();
assert_eq!(breaker.state(), CircuitState::Closed);Extension trait implemented for every ForgeError type. It needs no manual impl.
| Method | Parameters | Return Type | Description |
|---|---|---|---|
create_retry_policy() |
max_retries: usize |
RetryPolicy |
Exponential retry policy with the given retry limit |
retry() |
max_retries: usize, operation: F |
Result<T, E> |
Runs operation once if the receiver's is_retryable() is false; otherwise retries errors whose is_retryable() is true |
create_circuit_breaker() |
name |
CircuitBreaker |
Circuit breaker with the default configuration |
use error_forge::recovery::ForgeErrorRecovery;
use error_forge::AppError;
let template = AppError::network("api.example.com", None);
let policy = template.create_retry_policy(2);
let result: Result<u8, AppError> = policy.retry(|| Ok(7));
assert_eq!(result.unwrap(), 7);
let breaker = template.create_circuit_breaker("api");
assert_eq!(breaker.name(), "api");Enabled with the async feature.
The trait mirrors ForgeError and adds an async hook:
use async_trait::async_trait;
use std::backtrace::Backtrace;
use std::error::Error;
#[async_trait]
pub trait AsyncForgeError: Error + Send + Sync + 'static {
fn kind(&self) -> &'static str;
fn caption(&self) -> &'static str;
fn is_retryable(&self) -> bool { false }
fn is_fatal(&self) -> bool { false }
fn status_code(&self) -> u16 { 500 }
fn exit_code(&self) -> i32 { 1 }
fn user_message(&self) -> String { self.to_string() }
fn dev_message(&self) -> String { format!("[{}] {}", self.kind(), self) }
fn backtrace(&self) -> Option<&Backtrace> { None }
async fn async_handle(&self) -> Result<(), Box<dyn Error + Send + Sync>> { Ok(()) }
// The real default forwards to the registered error hook.
fn register(&self) {}
}AppError implements both traits, which share method names. Because Cargo unifies features across the dependency graph, enabling async in any crate makes error.kind() ambiguous (E0034) in every module that has both ForgeError and AsyncForgeError in scope, for example through use error_forge::*. Import only the trait you need, or call ForgeError::kind(&error).
Additional async exports:
AsyncResult<T, E>AppError::from_async_result(...)AppError::handle_async()
use async_trait::async_trait;
use error_forge::{AppError, AsyncForgeError};
use std::error::Error;
use std::fmt;
#[derive(Debug)]
struct UploadError(String);
impl fmt::Display for UploadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "upload failed: {}", self.0)
}
}
impl Error for UploadError {}
#[async_trait]
impl AsyncForgeError for UploadError {
fn kind(&self) -> &'static str { "Upload" }
fn caption(&self) -> &'static str { "Upload Error" }
fn is_retryable(&self) -> bool { true }
async fn async_handle(&self) -> Result<(), Box<dyn Error + Send + Sync>> {
// Flush telemetry, release resources, and so on.
Ok(())
}
}
#[tokio::main]
async fn main() {
let error = UploadError("bucket offline".into());
assert!(error.is_retryable());
assert!(error.async_handle().await.is_ok());
let io: Result<(), std::io::Error> = Err(std::io::Error::other("timed out"));
let wrapped = AppError::from_async_result(io).await.unwrap_err();
assert!(wrapped.to_string().contains("timed out"));
}- The crate is cross-platform and tested on Windows-friendly paths and outputs.
- Public examples are kept aligned with
cargo test --all-featuresand strict Clippy. - Recovery helpers are sync-first by design; async runtimes should wrap those patterns intentionally rather than rely on hidden blocking behavior.