- Overview
- Highlights
- Workspace Crates
- Feature Flags
- Installation
- Benchmarks
- Code Coverage
- Quick Start
- Advanced Usage
- Examples
- Resources
- Metrics
- License
masterror grew from a handful of helpers into a workspace of composable crates for
building consistent, observable error surfaces across Rust services. The core
crate stays framework-agnostic, while feature flags light up transport adapters,
integrations and telemetry without pulling in heavyweight defaults. No
unsafe, MSRV is pinned, and the derive macros keep your domain types in charge
of redaction and metadata.
- Unified taxonomy.
AppError,AppErrorKindandAppCodemodel domain and transport concerns with conservative HTTP/gRPC mappings, turnkey retry/auth hints and RFC7807 output viaProblemJson. - Native derives.
#[derive(Error)]and#[derive(Masterror)]wire custom types into the runtime types.#[derive(Masterror)]with#[masterror(...)]forwards sources, backtraces, telemetry fields and redaction policy;#[app_error]maps a derived error to anAppErrorKind/AppCode(optionally exposing itsDisplaymessage) and#[provide]registers typed telemetry providers on the domain error itself. - Typed telemetry.
Metadatastores structured key/value context (strings, integers, floats, durations, IP addresses and optional JSON) with per-field redaction controls and builders infield::*, so logs stay structured without manualStringmaps. - Transport adapters. Optional features expose Actix/Axum responders,
tonic::Statusconversions, WASM/browser logging and OpenAPI schema generation without contaminating the lean default build. - Battle-tested integrations. Enable focused mappings for
sqlx,reqwest,redis,validator,config,tokio,teloxide,multipart, Telegram init-data validation and more — each translating library errors into the taxonomy with telemetry attached. - Turnkey defaults. The
turnkeymodule ships a ready-to-use error catalog, a heuristic classifier and conservative mappings into the canonical taxonomy for teams that want a consistent baseline out of the box. - Boxed error interop.
AppError::from_boxedadopts anyBox<dyn Error + Send + Sync>without re-boxing andinto_boxed_dyn_errorconverts back, matchinganyhow's round-trip API while keeping the source chain intact and downcastable. - Typed control-flow macros.
ensure!andfail!short-circuit functions with your domain errors without allocating or formatting on the happy path, whileapp_error!builds ad-hocAppErrorvalues withformat!-style messages in expression position.
| Crate | What it provides | When to depend on it |
|---|---|---|
masterror |
Core error types, metadata builders, transports, integrations and the prelude. | Application crates, services and libraries that want a stable error surface. |
masterror-derive |
Proc-macros backing #[derive(Error)], #[derive(Masterror)], #[app_error] and #[provide]. |
Brought in automatically via masterror; depend directly only for macro hacking. |
masterror-template |
Shared template parser used by the derive macros for formatter analysis. | Internal dependency; reuse when you need the template parser elsewhere. |
Pick only what you need; the default feature set is just std, everything
else is opt-in.
- Web transports:
axum,actix,multipart,openapi,serde_json. - Telemetry & observability:
tracing,metrics,backtrace,coloredfor colored terminal output. - Async & IO integrations:
tokio,reqwest,sqlx,sqlx-migrate,redis,validator,config. - Messaging & bots:
teloxide,init-datafor Telegram Mini App init-data validation viainit-data-rs. - Front-end tooling:
frontendfor WASM/browser console logging. - gRPC:
tonicto emittonic::Statusresponses. - Batteries included:
turnkeyto adopt the pre-built taxonomy and helpers.
The build script keeps the full feature snippet below in sync with
Cargo.toml.
[dependencies]
masterror = { version = "0.29.0", default-features = false }
# or with features:
# masterror = { version = "0.29.0", features = [
# "std", "axum", "actix", "openapi",
# "serde_json", "tracing", "metrics", "backtrace",
# "colored", "sqlx", "sqlx-migrate", "reqwest",
# "redis", "validator", "config", "tokio",
# "multipart", "teloxide", "init-data", "tonic",
# "frontend", "turnkey", "benchmarks"
# ] }Criterion benchmarks cover the hottest conversion paths so regressions are visible before shipping. Run them locally with:
cargo bench -F benchmarks --bench error_pathsThe suite emits two groups:
context_into_error/*promotes a dummy source error with representative metadata (strings, counters, durations, IPs) throughResultExt::ctxin both redacted and non-redacted modes.problem_json_from_app_error/*consumes the resultingAppErrorvalues to build RFC 7807 payloads viaProblemJson::from_app_error, showing how message redaction and field policies impact serialization.
Adjust Criterion CLI flags (for example --sample-size 200 or --save-baseline local) after -- to trade
throughput for tighter confidence intervals when investigating changes.
Coverage reports are automatically generated on every CI run and uploaded to Codecov. The project maintains high test coverage across all modules to ensure reliability and catch regressions early.
Coverage Visualizations
The inner-most circle represents the entire project, moving outward through folders to individual files. Size and color indicate statement count and coverage percentage.
Each block represents a single file. Block size and color correspond to statement count and coverage percentage.
Hierarchical view starting with the entire project at the top, drilling down through folders to individual files. Size and color reflect statement count and coverage.
Create an error
Create an error:
use masterror::{AppError, AppErrorKind, field};
let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set");
assert!(matches!(err.kind, AppErrorKind::BadRequest));
let err_with_meta = AppError::service("downstream")
.with_field(field::str("request_id", "abc123"));
assert_eq!(err_with_meta.metadata().len(), 1);
let err_with_context = AppError::internal("db down")
.with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
assert!(err_with_context.source_ref().is_some());With prelude:
use masterror::prelude::*;
fn do_work(flag: bool) -> AppResult<()> {
if !flag {
return Err(AppError::bad_request("Flag must be set"));
}
Ok(())
}Fail fast without sacrificing typing
ensure! and fail! provide typed alternatives to the formatting-heavy
anyhow::ensure!/anyhow::bail! helpers. They evaluate the error expression
only when the guard trips, so success paths stay allocation-free.
use masterror::{AppError, AppErrorKind, AppResult};
fn guard(flag: bool) -> AppResult<()> {
masterror::ensure!(flag, AppError::bad_request("flag must be set"));
Ok(())
}
fn bail() -> AppResult<()> {
masterror::fail!(AppError::unauthorized("token expired"));
}
assert!(guard(true).is_ok());
assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest));
assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized));app_error! is the anyhow::anyhow! counterpart: an expression macro that
builds an AppError from a kind and an optional format!-style message with
implicit capture. The kind-only form performs no allocation; the message form
allocates exactly once.
use masterror::{AppErrorKind, AppResult, app_error};
fn find(id: u64) -> AppResult<u64> {
None::<u64>.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}"))
}
let bare = app_error!(AppErrorKind::Timeout);
assert!(bare.message.is_none());
assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound));Derive domain errors and map them to transports
masterror ships native derives so your domain types stay expressive while the
crate handles conversions, telemetry and redaction for you.
use std::io;
use masterror::Error;
#[derive(Debug, Error)]
#[error("I/O failed: {source}")]
pub struct DomainError {
#[from]
#[source]
source: io::Error,
}
#[derive(Debug, Error)]
#[error(transparent)]
pub struct WrappedDomainError(
#[from]
#[source]
DomainError
);
fn load() -> Result<(), DomainError> {
Err(io::Error::other("disk offline").into())
}
let err = load().unwrap_err();
assert_eq!(err.to_string(), "I/O failed: disk offline");
let wrapped = WrappedDomainError::from(err);
assert_eq!(wrapped.to_string(), "I/O failed: disk offline");use masterror::Error;brings the derive macro into scope.#[from]automatically implementsFrom<...>while ensuring wrapper shapes are valid.#[error(transparent)]enforces single-field wrappers that forwardDisplay/sourceto the inner error.#[app_error(kind = AppErrorKind::..., code = AppCode::..., message)]maps the derived error intoAppError/AppCode. The optionalcode = ...arm emits anAppCodeconversion, while themessageflag forwards the derivedDisplayoutput as the public message instead of producing a bare error.masterror::error::template::ErrorTemplateparses#[error("...")]strings, exposing literal and placeholder segments so custom derives can be implemented without relying onthiserror.TemplateFormattermirrorsthiserror's formatter detection so existing derives that relied on hexadecimal, pointer or exponential renderers keep compiling.- Display placeholders preserve their raw format specs via
TemplateFormatter::display_spec()andTemplateFormatter::format_fragment(), so derived code can forward:>8,:.3and other display-only options without reconstructing the original string. TemplateFormatterKindexposes the formatter trait requested by a placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant.
Attach telemetry, redaction policy and conversions
#[derive(Masterror)] wires a domain error into [masterror::Error], adds
metadata, redaction policy and optional transport mappings. The accompanying
#[masterror(...)] attribute mirrors the #[app_error] syntax while staying
explicit about telemetry and redaction.
use masterror::{
mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy
};
#[derive(Debug, Masterror)]
#[error("user {user_id} missing flag {flag}")]
#[masterror(
code = AppCode::NotFound,
category = AppErrorKind::NotFound,
message,
redact(message, fields("user_id" = hash)),
telemetry(
Some(masterror::field::str("user_id", user_id.clone())),
attempt.map(|value| masterror::field::u64("attempt", value))
),
map.grpc = 5,
map.problem = "https://errors.example.com/not-found"
)]
struct MissingFlag {
user_id: String,
flag: &'static str,
attempt: Option<u64>,
#[source]
source: Option<std::io::Error>
}
let err = MissingFlag {
user_id: "alice".into(),
flag: "beta",
attempt: Some(2),
source: None
};
let converted: Error = err.into();
assert_eq!(converted.code, AppCode::NotFound);
assert_eq!(converted.kind, AppErrorKind::NotFound);
assert_eq!(converted.edit_policy, MessageEditPolicy::Redact);
assert!(converted.metadata().get("user_id").is_some());
assert_eq!(
MissingFlag::HTTP_MAPPING,
HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound)
);code/categorypick the public [AppCode] and internal [AppErrorKind].messageforwards the formatted [Display] output as the safe public message. Omit it to keep the message private.redact(message)flips [MessageEditPolicy] to redactable at the transport boundary,fields("name" = hash, "card" = last4)overrides metadata policies (hash,last4,redact,none).telemetry(...)accepts expressions that evaluate toOption<masterror::Field>. Each populated field is inserted into the resulting [Metadata]; usetelemetry()when no fields are attached.map.grpc/map.problemcapture optional gRPC status codes (asi32) and RFC 7807typeURIs. The derive emits tables such asMyError::HTTP_MAPPING,MyError::GRPC_MAPPINGandMyError::PROBLEM_MAPPING(or slice variants for enums) for downstream integrations.
All familiar field-level attributes (#[from], #[source], #[backtrace])
are still honoured. Sources and backtraces are automatically attached to the
generated [masterror::Error].
Structured telemetry providers and AppError mappings
#[provide(...)] exposes typed context through std::error::Request, while
#[app_error(...)] records how your domain error translates into AppError
and AppCode. The derive mirrors thiserror's syntax. The generated From
conversions produce an AppError carrying the mapped kind and code (plus the
Display output as public message when the message flag is set) and attach
the original domain error as the source: it stays downcastable via
downcast_ref, shows up in chain()/root_cause(), and its #[provide]
data is forwarded through the AppError on toolchains with
error_generic_member_access. Source attachment requires the domain error to
be Send + Sync + 'static; add the no_source flag to #[app_error(...)]
to opt out and drop the domain error during conversion instead.
request_ref/request_value and the std::error::Request machinery require
a nightly toolchain (error_generic_member_access); the crate detects
compiler support at build time and only enables provider integration when
available.
use std::error::request_ref;
use masterror::{AppCode, AppError, AppErrorKind, Error};
#[derive(Clone, Debug, PartialEq, Eq)]
struct TelemetrySnapshot {
name: &'static str,
value: u64,
}
#[derive(Debug, Error)]
#[error("structured telemetry {snapshot:?}")]
#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)]
struct StructuredTelemetryError {
#[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)]
snapshot: TelemetrySnapshot,
}
let err = StructuredTelemetryError {
snapshot: TelemetrySnapshot {
name: "db.query",
value: 42,
},
};
let snapshot = request_ref::<TelemetrySnapshot>(&err).expect("telemetry");
assert_eq!(snapshot.value, 42);
let app: AppError = err.into();
assert!(matches!(app.kind, AppErrorKind::Service));Optional telemetry only surfaces when present, so None does not register a
provider. Owned snapshots can still be provided as values when the caller
requests ownership:
use masterror::{AppCode, AppErrorKind, Error};
#[derive(Debug, Error)]
#[error("optional telemetry {telemetry:?}")]
#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal)]
struct OptionalTelemetryError {
#[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)]
telemetry: Option<TelemetrySnapshot>,
}
let noisy = OptionalTelemetryError {
telemetry: Some(TelemetrySnapshot {
name: "queue.depth",
value: 17,
}),
};
let silent = OptionalTelemetryError { telemetry: None };
assert!(request_ref::<TelemetrySnapshot>(&noisy).is_some());
assert!(request_ref::<TelemetrySnapshot>(&silent).is_none());Enums support per-variant telemetry and conversion metadata. Each variant chooses
its own AppErrorKind/AppCode mapping while the derive generates a single
From<Enum> implementation:
#[derive(Debug, Error)]
enum EnumTelemetryError {
#[error("named {label}")]
#[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound)]
Named {
label: &'static str,
#[provide(ref = TelemetrySnapshot)]
snapshot: TelemetrySnapshot,
},
#[error("optional tuple")]
#[app_error(kind = AppErrorKind::Timeout, code = AppCode::Timeout)]
Optional(#[provide(ref = TelemetrySnapshot)] Option<TelemetrySnapshot>),
#[error("owned tuple")]
#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)]
Owned(#[provide(value = TelemetrySnapshot)] TelemetrySnapshot),
}
let owned = EnumTelemetryError::Owned(TelemetrySnapshot {
name: "redis.latency",
value: 3,
});
let app: AppError = owned.into();
assert!(matches!(app.kind, AppErrorKind::Service));Compared to thiserror, you retain the familiar deriving surface while gaining
structured telemetry (#[provide]) and first-class conversions into
AppError/AppCode without manual glue.
Problem JSON payloads and retry/authentication hints
use masterror::{AppError, AppErrorKind, ProblemJson};
let problem = ProblemJson::from_app_error(
AppError::new(AppErrorKind::Unauthorized, "Token expired")
.with_retry_after_secs(30)
.with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#)
);
assert_eq!(problem.status, 401);
assert_eq!(problem.retry_after, Some(30));
assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");Environment detection with DisplayMode
DisplayMode detects the deployment environment (Prod, Local or
Staging) and drives the Display output of AppError.
DisplayMode::current() resolves the mode in this order and caches the
result on first access (the environment is read once per process):
MASTERROR_ENVenvironment variable (prod/production,local/dev/development, orstaging/stage)KUBERNETES_SERVICE_HOSTpresence (selectsProd)- Build configuration (
debug_assertions→Local, release →Prod; the only detection step available without thestdfeature)
use masterror::DisplayMode;
let mode = DisplayMode::current();
match mode {
DisplayMode::Prod => println!("Running in production mode"),
DisplayMode::Local => println!("Running in local development mode"),
DisplayMode::Staging => println!("Running in staging mode"),
}Display for AppError dispatches on the detected mode:
| Mode | Layout |
|---|---|
Local |
Multi-line human-readable report: kind, code, message, source chain, metadata |
Prod |
Compact single-line JSON: kind, code, optional message, metadata |
Staging |
Same JSON as Prod plus a source_chain array |
All layouts apply per-field redaction policies: Redact renders the
[REDACTED] placeholder, Hash renders a SHA-256 hex digest, Last4
keeps only the trailing characters (fields that cannot be masked are
omitted). Set MASTERROR_ENV=local on any host to force the
human-readable layout, e.g. when debugging inside Kubernetes.
In Local mode the output looks like:
Error: Not found
Code: NOT_FOUND
Message: User not found
Context:
user_id: 12345
request_id: abc-def
In Prod mode the same error renders as:
{"kind":"NotFound","code":"NOT_FOUND","message":"User not found","metadata":{"request_id":"abc-def","user_id":12345}}
Colored Terminal Output:
Enable the colored feature for ANSI styling with automatic TTY
detection. It affects only the Local layout; Prod and Staging JSON
never contains escape sequences:
[dependencies]
masterror = { version = "0.29.0", features = ["colored"] }Comprehensive real-world examples demonstrating masterror integration with popular frameworks:
| Example | Description | Features |
|---|---|---|
| axum-rest-api | REST API with RFC 7807 Problem Details | HTTP endpoints, domain errors, integration tests |
| sqlx-database | Database error handling with SQLx | Connection errors, constraint violations, transactions |
| custom-domain-errors | Payment processing domain errors | Derive macro, error conversion, structured errors |
| basic-async | Async error handling with tokio | Error propagation, timeout handling, Result types |
All examples are runnable; the axum-rest-api example additionally ships integration tests. See the examples/ directory for complete source code and documentation.
- Explore the error-handling wiki for step-by-step guides,
comparisons with
thiserror/anyhow, and troubleshooting recipes. - Browse the crate documentation on docs.rs for API details, feature-specific guides and transport tables.
- Check
CHANGELOG.mdfor release highlights and migration notes. - Review RustManifest for the development standards and best practices this project follows.
MSRV: 1.96 · License: MIT · No unsafe
