masterror
Framework-agnostic application error types with stable codes, HTTP/gRPC mappings and built-in telemetry
What is masterror?
masterror is an error-handling workspace for Rust services that need more than Display and source(). Where thiserror stops at deriving trait implementations and anyhow stops at type-erased propagation, masterror carries an error all the way to the transport boundary:
AppErrorβ a rich error value with a semantic category (AppErrorKind), a stable machine-readable code (AppCode), an optional safe public message, structured metadata and transport hints (Retry-After,WWW-Authenticate).- Conservative HTTP and gRPC mappings β every kind and code maps deterministically to an HTTP status, a
tonic::Codediscriminant and an RFC 7807typeURI. - Typed telemetry β metadata is stored as typed fields (strings, integers, floats, durations, IPs, UUIDs, JSON) with per-field redaction policies, not ad-hoc
Stringmaps. - Native derives β
#[derive(Error)]mirrorsthiserrorsyntax, while#[app_error(...)]and#[derive(Masterror)]wire domain errors intoAppErrorwith codes, categories, redaction and mapping tables. - Redaction by design β sources are never serialized to clients; messages, details and metadata fields can be redacted, hashed or masked at the boundary.
No unsafe, pinned MSRV, no_std support with the default std feature disabled.
The problem it solves
| Concern | thiserror | anyhow | masterror |
|---|---|---|---|
Display / source() derives | Yes | β | Yes (same syntax) |
| Type-erased propagation with context | β | Yes | Yes (.ctx() / .context()) |
| Stable machine-readable error codes | Manual | Manual | AppCode, part of the wire contract |
| HTTP status mapping | Manual | Manual | AppErrorKind::http_status(), stable table |
| gRPC status mapping | Manual | Manual | CODE_MAPPINGS, tonic::Status conversion |
RFC 7807 problem+json | Manual | Manual | ProblemJson::from_app_error |
| Structured, typed metadata | β | β | Metadata + field::* builders |
| Redaction of secrets at the boundary | β | β | MessageEditPolicy, FieldRedaction |
| tracing / metrics / backtrace emission | β | β | Feature-gated, automatic on construction |
A thiserror-derived enum tells you what happened. masterror also decides what the client sees (status, code, safe message, problem+json), what operators see (structured fields, tracing events, counters) and what never leaks (sources, redacted fields).
Feature highlights
| Area | What you get |
|---|---|
| Core taxonomy | AppError, AppErrorKind (23 stable categories), AppCode (SCREAMING_SNAKE_CASE codes, custom codes supported), AppResult<T> |
| Derives | #[derive(Error)], #[derive(Masterror)], #[app_error(...)], #[masterror(...)], #[provide(...)] telemetry providers |
| Control flow | ensure! / fail! β typed, allocation-free early returns |
| Context | ResultExt::ctx / ResultExt::context, Context builder with caller tracking |
| Wire payloads | ErrorResponse (legacy JSON), ProblemJson (RFC 7807) with retry and auth hints |
| Transports | Axum IntoResponse, Actix ResponseError/Responder, tonic::Status, WASM JsValue, OpenAPI schema |
| Integrations | sqlx, redis, reqwest, validator, config, tokio, teloxide, Telegram Mini Apps init data, Turnkey |
| Observability | tracing events, metrics counters, lazy backtrace capture, colored terminal output, DisplayMode (prod/staging/local) |
Quick example
use masterror::{AppError, AppErrorKind, AppResult, ProblemJson, field};
fn find_user(id: u64) -> AppResult<()> {
masterror::ensure!(id != 0, AppError::bad_request("id must be non-zero"));
Err(AppError::not_found("user not found")
.with_field(field::u64("user_id", id))
.with_field(field::str("request_id", "abc123")))
}
let err = find_user(42).unwrap_err();
assert_eq!(err.kind, AppErrorKind::NotFound);
assert_eq!(err.kind.http_status(), 404);
let problem = ProblemJson::from_app_error(err);
assert_eq!(problem.status, 404);
assert_eq!(problem.code.as_str(), "NOT_FOUND");
assert_eq!(problem.grpc.expect("grpc").name, "NOT_FOUND");
Or declare a domain error once and let the derive handle the mapping:
use masterror::{AppCode, AppError, AppErrorKind, Error};
#[derive(Debug, Error)]
#[error("missing flag: {name}")]
#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)]
struct MissingFlag {
name: &'static str
}
let app: AppError = MissingFlag { name: "feature" }.into();
assert!(matches!(app.kind, AppErrorKind::BadRequest));
Workspace crates
| Crate | Role |
|---|---|
masterror | Core error types, metadata, transports, integrations, prelude |
masterror-derive | Proc-macros behind #[derive(Error)] and #[derive(Masterror)] (pulled in automatically) |
masterror-template | Shared #[error("...")] template parser |
Documentation
Getting started
- Getting Started β installation, first errors, macros, first derive
- Feature Flags β complete flag reference with dependencies
Core concepts
- Error Kinds and Codes β taxonomy, HTTP/gRPC tables, problem+json
- Derive Macros β
#[derive(Error)],#[derive(Masterror)]and their attributes - Context and Metadata β
Context,ResultExt, fields, redaction, chains
Integrations
- Web Frameworks β Axum, Actix, tonic
- Integrations β sqlx, redis, reqwest, validator and friends
- Observability β tracing, metrics, backtraces, display modes
Advanced
- No-Std β running without the standard library
- Best Practices β patterns for services and libraries
- Migration β moving from
thiserror/anyhow
Getting Started
This page walks through installing masterror, returning your first AppError, short-circuiting with ensure!/fail!, using the prelude and writing your first derive.
Installation
The default build enables only the std feature β no web framework, no telemetry backends:
[dependencies]
masterror = "0.28"
Enable integrations as you need them (see Feature Flags for the full list):
[dependencies]
masterror = { version = "0.28", features = ["axum", "serde_json", "tracing"] }
MSRV is 1.96. The crate forbids unsafe and supports no_std when built with default-features = false.
Your first error
AppError couples a semantic category (AppErrorKind) with an optional public message. AppResult<T> is an alias for Result<T, AppError>:
use masterror::{AppError, AppErrorKind, AppResult};
fn do_work(flag: bool) -> AppResult<()> {
if !flag {
return Err(AppError::new(AppErrorKind::BadRequest, "Flag must be set"));
}
Ok(())
}
let err = do_work(false).unwrap_err();
assert!(matches!(err.kind, AppErrorKind::BadRequest));
assert_eq!(err.kind.http_status(), 400);
Every kind has a named constructor, so you rarely spell out AppErrorKind at call sites:
use masterror::AppError;
let _ = AppError::not_found("user not found"); // 404
let _ = AppError::validation("invalid email"); // 422
let _ = AppError::unauthorized("token expired"); // 401
let _ = AppError::forbidden("no access"); // 403
let _ = AppError::conflict("already exists"); // 409
let _ = AppError::rate_limited("slow down"); // 429
let _ = AppError::internal("unexpected failure"); // 500
let _ = AppError::service("orchestration failed"); // 500
let _ = AppError::timeout("upstream timed out"); // 504
let _ = AppError::bare(masterror::AppErrorKind::NotFound); // no message
Attach structured metadata and an upstream source without giving up typing:
use masterror::{AppError, field};
let err = AppError::service("downstream degraded")
.with_field(field::str("request_id", "abc123"))
.with_field(field::i64("attempt", 2))
.with_context(std::io::Error::other("connection reset"));
assert_eq!(err.metadata().len(), 2);
assert!(err.source_ref().is_some());
The source is available for logs and chain() traversal but is never serialized to clients.
ensure! and fail!
ensure! and fail! are typed alternatives to anyhow::ensure!/anyhow::bail!. The error expression is evaluated lazily, so the success path performs no formatting and no allocation:
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));
ensure! also accepts a verbose form for complex conditions:
use masterror::{AppError, AppResult};
fn bounded(value: i32, max: i32) -> AppResult<()> {
masterror::ensure!(
cond = value <= max,
else = AppError::service("value too large")
);
Ok(())
}
The prelude
masterror::prelude re-exports just the core types (AppError, AppErrorKind, AppCode, AppResult, ErrorResponse, plus the turnkey helpers when that feature is on):
use masterror::prelude::*;
fn handler(flag: bool) -> AppResult<()> {
if !flag {
return Err(AppError::bad_request("Flag must be set"));
}
Ok(())
}
Framework trait implementations (Axum IntoResponse, Actix Responder) are activated by feature flags and need no extra imports.
Adding context to foreign errors
ResultExt promotes any Result<T, E: Error> into AppResult<T>:
use masterror::{AppErrorKind, Context, ResultExt, field};
fn read_config() -> Result<String, std::io::Error> {
Err(std::io::Error::from(std::io::ErrorKind::NotFound))
}
// Simple, anyhow-style message:
let err = read_config().context("Failed to read config file").unwrap_err();
assert!(err.source_ref().is_some());
// Full control over category, code, metadata and redaction:
let err = read_config()
.ctx(|| Context::new(AppErrorKind::Config).with(field::str("path", "app.toml")))
.unwrap_err();
assert_eq!(err.kind, AppErrorKind::Config);
See Context and Metadata for the full Context API.
Your first derive
#[derive(Error)] mirrors thiserror syntax, and #[app_error(...)] adds the conversion into AppError:
use masterror::{AppCode, AppError, AppErrorKind, Error};
#[derive(Debug, Error)]
#[error("I/O failed: {source}")]
#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal, message)]
pub struct DomainError {
#[from]
#[source]
source: std::io::Error
}
fn load() -> Result<(), DomainError> {
Err(std::io::Error::other("disk offline").into())
}
let err = load().unwrap_err();
assert_eq!(err.to_string(), "I/O failed: disk offline");
let app: AppError = err.into();
assert!(matches!(app.kind, AppErrorKind::Internal));
#[error("...")]defines theDisplaytemplate with{field}placeholders.#[from]generatesFrom<std::io::Error>for the wrapper.#[source]forwards the inner error throughsource().#[app_error(kind = ..., code = ..., message)]generatesFrom<DomainError> for AppError(andfor AppCode); themessageflag exposes theDisplayoutput as the public message.
Enums work the same way with per-variant #[error] and #[app_error] attributes. When you also need metadata, redaction policy and gRPC/problem+json mapping tables, reach for #[derive(Masterror)] β covered in Derive Macros.
Where to go next
- Map errors to HTTP responses in Axum or Actix β Web Frameworks
- Understand the taxonomy and wire contract β Error Kinds and Codes
- Enable integrations for sqlx, redis, reqwest β Feature Flags
See also: Feature Flags Β· Error Kinds and Codes Β· Derive Macros Β· Context and Metadata Β· Migration
Feature Flags
masterror keeps the default build lean: only std is enabled out of the box. Everything else β web transports, telemetry, library integrations β is opt-in. This page is the complete reference for every flag declared in Cargo.toml.
[dependencies]
masterror = { version = "0.28", default-features = false }
# or with features:
# masterror = { version = "0.28", 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"
# ] }
Core
| Flag | What it enables | Extra deps |
|---|---|---|
std (default) | Standard library support; required by all runtime integrations. Disable for no_std (see No-Std) | β |
Web transports
| Flag | What it enables | Extra deps |
|---|---|---|
axum | IntoResponse for AppError and ProblemJson with RFC 7807 JSON bodies; AppErrorKind::status_code() | axum (json, multipart), serde_json |
actix | Actix Web ResponseError for AppError and Responder for ProblemJson | actix-web |
multipart | Maps axum::extract::multipart::MultipartError β BadRequest (implies axum) | via axum |
openapi | utoipa::ToSchema for ErrorResponse and AppCode so error payloads appear in OpenAPI specs | utoipa |
serde_json | Structured JSON details on AppError/ErrorResponse/ProblemJson; FieldValue::Json and field::json | serde_json |
tonic | Conversion of errors into tonic::Status with sanitized metadata; exports StatusConversionError | tonic |
Telemetry and observability
| Flag | What it enables | Extra deps |
|---|---|---|
tracing | Structured tracing events emitted when errors are constructed | tracing, log, log-mdc |
metrics | Increments an error_total{code,category} counter for each AppError | metrics |
backtrace | Lazy std::backtrace::Backtrace capture (honours RUST_BACKTRACE), with_backtrace() builder | β |
colored | Colored multi-line terminal output with automatic TTY detection; richer Display for AppError | owo-colors |
Async and IO integrations
Each integration flag adds a From<...> for AppError conversion that classifies the library error into the taxonomy:
| Flag | Conversion | Extra deps |
|---|---|---|
sqlx | sqlx_core::Error β NotFound / Database (lean sqlx-core, no drivers or TLS) | sqlx-core |
sqlx-migrate | sqlx::migrate::MigrateError β Database (full sqlx with migrate feature only) | sqlx |
redis | redis::RedisError β Cache | redis |
reqwest | reqwest::Error β Timeout / Network / ExternalApi | reqwest |
tokio | tokio::time::error::Elapsed β Timeout | tokio (time) |
validator | validator::ValidationErrors β Validation | validator |
config | config::ConfigError β Config | config |
sqlx and sqlx-migrate are split deliberately: error classification only needs sqlx-core, while migration error mapping pulls the full sqlx crate.
Messaging and bots
| Flag | Conversion | Extra deps |
|---|---|---|
teloxide | teloxide_core::RequestError β RateLimited / Network / ExternalApi / Deserialization / Internal | teloxide-core |
init-data | init_data_rs::InitDataError β TelegramAuth (Telegram Mini Apps init-data validation) | init-data-rs |
Front-end and domain
| Flag | What it enables | Extra deps |
|---|---|---|
frontend | frontend module: convert errors to wasm_bindgen::JsValue and emit console.error logs in WASM/browser contexts | wasm-bindgen, js-sys, serde-wasm-bindgen |
turnkey | turnkey module: TurnkeyErrorKind, TurnkeyError, classify_turnkey_error and conversions into AppError | β |
benchmarks | Criterion benchmark suite and CI baseline tooling (local profiling only) | β |
Baseline conversions (always available)
Without any feature flag, AppError already converts from:
| Source | Target kind |
|---|---|
std::io::Error | Internal |
String | BadRequest |
Recipes
REST API on Axum with problem+json, OpenAPI docs and tracing:
masterror = { version = "0.28", features = ["axum", "openapi", "serde_json", "tracing"] }
Database service with sqlx, migrations and metrics:
masterror = { version = "0.28", features = ["sqlx", "sqlx-migrate", "metrics", "tracing"] }
gRPC service:
masterror = { version = "0.28", features = ["tonic", "tracing", "backtrace"] }
Telegram bot with Mini App auth:
masterror = { version = "0.28", features = ["teloxide", "init-data", "reqwest", "tokio"] }
WASM front-end:
masterror = { version = "0.28", features = ["frontend", "serde_json"] }
no_std embedded or library target:
masterror = { version = "0.28", default-features = false }
Notes
- All integration flags imply
stdexceptsqlxandsqlx-migrate, which staystd-independent at the flag level. axumandactixpullserde_jsontransitively because their response bodies are JSON.- Feature flags never change the wire contract of
ErrorResponse/ProblemJsonfields that are already enabled β they only add capabilities (e.g.serde_jsonupgradesdetailsfrom plain text to structured JSON) or trait implementations.
See also: Getting Started Β· Web Frameworks Β· Integrations Β· Observability Β· No-Std
Error Kinds and Codes
Two types form the backbone of the taxonomy:
AppErrorKindβ the internal, semantic category of a failure. Small, stable, framework-agnostic. Controls the default HTTP status.AppCodeβ the public, machine-readable code exposed to clients as a SCREAMING_SNAKE_CASE string (e.g."NOT_FOUND"). Part of the wire contract.
Every AppError carries both. AppCode::from(kind) gives the canonical 1:1 mapping, and AppError::with_code(...) overrides the public code without changing the category.
AppErrorKind taxonomy
| Variant | Meaning | HTTP |
|---|---|---|
NotFound | Resource does not exist or is not visible to the caller | 404 |
Validation | Structured input failed validation | 422 |
Conflict | State conflict (unique key violation, version mismatch) | 409 |
Unauthorized | Authentication required or failed | 401 |
Forbidden | Authenticated but not allowed | 403 |
NotImplemented | Operation not supported by this deployment | 501 |
BadRequest | Malformed request or missing parameters | 400 |
TelegramAuth | Telegram authentication flow failed | 401 |
InvalidJwt | JWT expired, malformed or has wrong signature/claims | 401 |
RateLimited | Client exceeded rate limits or quota | 429 |
Timeout | Operation did not complete in time | 504 |
Network | Network-level error (DNS, connect, TLS) | 503 |
DependencyUnavailable | External dependency down or degraded | 503 |
Internal | Unexpected server-side failure | 500 |
Database | Database failure (query, connection, migration) | 500 |
Service | Generic service-layer/business-logic failure | 500 |
Config | Missing or invalid configuration | 500 |
Turnkey | Turnkey subsystem failure | 500 |
Serialization | Failed to encode data | 500 |
Deserialization | Failed to decode data | 500 |
ExternalApi | Upstream API returned an error | 500 |
Queue | Queue publish/consume/ack failure | 500 |
Cache | Cache read/write/encoding failure | 500 |
use masterror::AppErrorKind;
let kind = AppErrorKind::NotFound;
assert_eq!(kind.http_status(), 404); // always available, u16
assert_eq!(kind.label(), "Not found"); // human-readable title
// With the `axum` feature: kind.status_code() -> axum::http::StatusCode
Design rules baked into the mapping: infrastructure and I/O issues default to 5xx; Unauthorized (401) means authentication failed, Forbidden (403) means authentication succeeded but access was denied; use Network for connect/build failures and ExternalApi for upstream HTTP status errors.
AppCode
AppCode ships constants matching every kind (AppCode::NotFound β "NOT_FOUND", AppCode::RateLimited β "RATE_LIMITED", β¦) plus AppCode::UserAlreadyExists ("USER_ALREADY_EXISTS", mapped as a conflict). It is #[non_exhaustive] and supports caller-defined codes:
use std::str::FromStr;
use masterror::AppCode;
// Compile-time literal β panics at compile-time evaluation if not SCREAMING_SNAKE_CASE
const INVALID_JSON: AppCode = AppCode::new("INVALID_JSON");
// Runtime value β validated, returns Result<AppCode, ParseAppCodeError>
let dynamic = AppCode::try_new(String::from("THIRD_PARTY_FAILURE")).expect("valid code");
assert_eq!(dynamic.as_str(), "THIRD_PARTY_FAILURE");
// Parsing round-trips through the same validation
let parsed = AppCode::from_str("NOT_FOUND").expect("known code");
assert_eq!(parsed, AppCode::NotFound);
Valid codes contain only A-Z, 0-9 and single _ separators, and serialize as plain JSON strings.
HTTP / gRPC / problem+json mapping table
CODE_MAPPINGS (and the mapping_for_code lookup) define the canonical transport mapping for every built-in code. Unknown custom codes fall back to INTERNAL (500 / gRPC 13):
| AppCode | HTTP | gRPC | problem type |
|---|---|---|---|
NOT_FOUND | 404 | NOT_FOUND (5) | https://errors.masterror.rs/not-found |
VALIDATION | 422 | INVALID_ARGUMENT (3) | .../validation |
CONFLICT | 409 | ALREADY_EXISTS (6) | .../conflict |
USER_ALREADY_EXISTS | 409 | ALREADY_EXISTS (6) | .../user-already-exists |
UNAUTHORIZED | 401 | UNAUTHENTICATED (16) | .../unauthorized |
FORBIDDEN | 403 | PERMISSION_DENIED (7) | .../forbidden |
NOT_IMPLEMENTED | 501 | UNIMPLEMENTED (12) | .../not-implemented |
BAD_REQUEST | 400 | INVALID_ARGUMENT (3) | .../bad-request |
RATE_LIMITED | 429 | RESOURCE_EXHAUSTED (8) | .../rate-limited |
TELEGRAM_AUTH | 401 | UNAUTHENTICATED (16) | .../telegram-auth |
INVALID_JWT | 401 | UNAUTHENTICATED (16) | .../invalid-jwt |
INTERNAL | 500 | INTERNAL (13) | .../internal |
DATABASE | 500 | INTERNAL (13) | .../database |
SERVICE | 500 | INTERNAL (13) | .../service |
CONFIG | 500 | INTERNAL (13) | .../config |
TURNKEY | 500 | INTERNAL (13) | .../turnkey |
TIMEOUT | 504 | DEADLINE_EXCEEDED (4) | .../timeout |
NETWORK | 503 | UNAVAILABLE (14) | .../network |
DEPENDENCY_UNAVAILABLE | 503 | UNAVAILABLE (14) | .../dependency-unavailable |
SERIALIZATION | 500 | INTERNAL (13) | .../serialization |
DESERIALIZATION | 500 | INTERNAL (13) | .../deserialization |
EXTERNAL_API | 500 | UNAVAILABLE (14) | .../external-api |
QUEUE | 500 | UNAVAILABLE (14) | .../queue |
CACHE | 500 | UNAVAILABLE (14) | .../cache |
gRPC values match tonic::Code discriminants, so the tonic feature converts directly.
use masterror::{AppCode, mapping_for_code};
let mapping = mapping_for_code(&AppCode::Timeout);
assert_eq!(mapping.http_status(), 504);
assert_eq!(mapping.grpc().name, "DEADLINE_EXCEEDED");
assert_eq!(mapping.grpc().value, 4);
assert_eq!(mapping.problem_type(), "https://errors.masterror.rs/timeout");
Retry and authentication hints
Transport adapters translate two optional hints into HTTP headers:
use std::time::Duration;
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)); // -> Retry-After header
assert!(problem.www_authenticate.is_some()); // -> WWW-Authenticate header
assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");
On ErrorResponse the equivalent builders are with_retry_after_secs, with_retry_after_duration and with_www_authenticate.
Redaction semantics
AppError messages are meant to be safe for clients, but you can mark an error as redactable so the boundary strips it:
use masterror::{AppError, MessageEditPolicy, ProblemJson};
let err = AppError::internal("host db-3 credentials rejected").redactable();
assert_eq!(err.edit_policy, MessageEditPolicy::Redact);
let problem = ProblemJson::from_app_error(err);
assert!(problem.detail.is_none()); // message stripped
assert!(problem.metadata.is_none()); // metadata stripped too
When edit_policy is Redact, ProblemJson drops detail, details and the entire metadata section. Individual metadata fields additionally carry their own FieldRedaction policy (None, Redact, Hash, Last4) applied during serialization β see Context and Metadata. Error sources (source_ref()) are never serialized regardless of policy.
Wire payloads
ProblemJson β RFC 7807 application/problem+json, produced by ProblemJson::from_app_error (owned) or ProblemJson::from_ref (borrowed). Fields: type, title (kind label), status, detail, optional details, code, grpc ({name, value}), metadata, plus non-serialized retry_after/www_authenticate for headers.
ErrorResponse β legacy flat JSON payload: status, code, message, optional details, retry, www_authenticate. With the openapi feature it derives utoipa::ToSchema.
use masterror::{AppCode, AppError, AppErrorKind, ErrorResponse};
let app_err = AppError::new(AppErrorKind::NotFound, "user_not_found");
let resp: ErrorResponse = (&app_err).into();
assert_eq!(resp.status, 404);
assert_eq!(resp.code, AppCode::NotFound);
Prefer ProblemJson for new APIs; ErrorResponse remains for services already committed to the flat shape.
See also: Getting Started Β· Derive Macros Β· Context and Metadata Β· Web Frameworks
Derive Macros
masterror ships two derives via the bundled masterror-derive crate:
#[derive(Error)]β a drop-in replacement forthiserror::Error(same#[error],#[from],#[source],#[backtrace]attributes) extended with#[app_error(...)]conversions and#[provide(...)]telemetry.#[derive(Masterror)]β builds on the same syntax and wires a domain error directly intomasterror::Errorwith metadata, redaction policy and transport mapping tables via#[masterror(...)].
Both are re-exported from the root: use masterror::{Error, Masterror};.
#[error("...")] templates
The template drives the generated Display implementation. Placeholders reference fields by name ({field}), tuple index ({0}) or explicit arguments. Parsing is handled by the shared masterror-template crate and mirrors thiserror semantics.
use masterror::Error;
#[derive(Debug, Error)]
#[error("{kind}: {message}")]
struct NamedError {
kind: &'static str,
message: &'static str
}
#[derive(Debug, Error)]
#[error("{0} -> {1:?}")]
struct TupleError(&'static str, u8);
Formatter traits and specs
Placeholders support the full formatter palette β {x:?}, {x:#?}, {x:x}, {x:#X}, {x:b}, {x:o}, {x:e}, {x:E}, {x:p} β and display-only specs such as {value:>8} or {ratio:.3} are forwarded verbatim. For programmatic template inspection, masterror::error::template exposes ErrorTemplate, TemplateFormatter and TemplateFormatterKind.
Format arguments and projections
Templates accept named and positional arguments, including expressions on self and field projections with the .field shortcut:
use masterror::Error;
#[derive(Debug, Error)]
#[error("{formatted}", formatted = self.message.to_uppercase())]
struct FormatArgExpressionError {
message: &'static str
}
#[derive(Debug, Error)]
#[error("{}, {label}, {}", label = self.label, self.first, self.second)]
struct MixedImplicitArgsError {
label: &'static str,
first: &'static str,
second: &'static str
}
#[derive(Debug, Error)]
#[error("{value}", value = .value)]
struct FieldShortcutError {
value: &'static str
}
transparent and fmt = ...
use masterror::Error;
#[derive(Debug, Error)]
#[error("inner failure")]
struct Inner;
// Forwards Display and source() to the single wrapped field
#[derive(Debug, Error)]
#[error(transparent)]
struct Wrapper(#[from] Inner);
// Delegate rendering to a function: fields first, formatter last
fn render(count: &usize, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "count={count}")
}
#[derive(Debug, Error)]
#[error(fmt = crate::render)]
struct CustomFormat {
count: usize
}
transparent requires exactly one field and cannot be combined with fmt or a template string. fmt = path points at a function receiving references to all fields plus the Formatter.
Field attributes
| Attribute | Effect |
|---|---|
#[source] | Field is returned from source(). Option<E> is supported. |
#[from] | Generates From<FieldType> for the wrapper; implies #[source] on the same field. |
#[backtrace] | Field holds a std::backtrace::Backtrace (or Option<Backtrace>) surfaced via error introspection, or delegates to the sourceβs backtrace when combined with #[source]. |
Inference: a field literally named source is treated as the source automatically, and a field of type std::backtrace::Backtrace (or Option<Backtrace>) is picked up as the backtrace without an attribute.
Enums accept per-variant #[error] and per-variant #[from]/#[source]/#[backtrace]:
use masterror::Error;
#[derive(Debug, Error)]
#[error("leaf failure")]
struct LeafError;
#[derive(Debug, Error)]
enum EnumError {
#[error("unit failure")]
Unit,
#[error("{code}")]
Code {
code: u16,
#[source]
cause: LeafError
},
#[error(transparent)]
Wrapped(#[from] LeafError)
}
#[app_error(...)] β conversions into AppError
Records how the derived error translates into AppError/AppCode. Options: kind (required), code (optional), message (flag), no_source (flag).
use masterror::{AppCode, AppError, AppErrorKind, Error};
#[derive(Debug, Error)]
#[error("missing flag: {name}")]
#[app_error(kind = AppErrorKind::BadRequest, code = AppCode::BadRequest, message)]
struct MissingFlag {
name: &'static str
}
let app: AppError = MissingFlag { name: "feature" }.into();
assert!(matches!(app.kind, AppErrorKind::BadRequest));
let code: AppCode = MissingFlag { name: "other" }.into();
assert_eq!(code, AppCode::BadRequest);
kind = ...selects theAppErrorKind; generatesFrom<T> for AppError.code = ...additionally generatesFrom<T> for AppCode.messageforwards theDisplayoutput as the public message; omit it to keep the message internal.no_sourceskips source attachment and drops the domain error during conversion (for types that are notSend + Sync + 'static).
The generated From<T> for AppError attaches the original domain error as the source: it stays downcastable via downcast_ref, appears in chain()/root_cause(), and its #[provide] data is forwarded through the AppError on toolchains with error_generic_member_access. Source attachment requires T: Send + Sync + 'static.
Enums choose a mapping per variant, and the derive still emits a single From<Enum> for AppError.
#[provide(...)] β typed telemetry
Exposes typed context through std::error::Request (nightly error_generic_member_access; compiled in automatically when available). Option fields only register a provider when populated:
use masterror::{AppCode, 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
}
Consumers extract the snapshot with std::error::request_ref::<TelemetrySnapshot>(&err) on the domain error, or on the converted AppError β the conversion forwards providers through the attached source.
#[derive(Masterror)] β end-to-end domain errors
#[derive(Masterror)] generates Display, std::error::Error, From<T> for masterror::Error and compile-time transport mapping tables, all configured by one #[masterror(...)] attribute:
use masterror::{
AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy, mapping::HttpMapping
};
#[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)
);
#[masterror(...)] options
| Option | Meaning |
|---|---|
code = AppCode::... | Public machine-readable code |
category = AppErrorKind::... | Semantic category (drives HTTP status) |
message | Expose the formatted Display output as the safe public message |
redact(message) | Set MessageEditPolicy::Redact so transports strip the message |
redact(fields("name" = hash, "card" = last4)) | Override per-field metadata policies: hash, last4, redact, none |
telemetry(expr, ...) | Expressions evaluating to Option<masterror::Field>; populated fields are inserted into Metadata. Use telemetry() for none |
map.grpc = <i32> | gRPC status code (matches tonic::Code discriminants) |
map.problem = "<uri>" | RFC 7807 type URI |
Generated mapping tables
For structs the derive emits associated constants; for enums it emits an array and slices aggregating the per-variant mappings:
| Shape | Constants |
|---|---|
| Struct | T::HTTP_MAPPING: HttpMapping, T::GRPC_MAPPING: Option<GrpcMapping>, T::PROBLEM_MAPPING: Option<ProblemMapping> |
| Enum | T::HTTP_MAPPINGS: [HttpMapping; N], T::GRPC_MAPPINGS: &'static [GrpcMapping], T::PROBLEM_MAPPINGS: &'static [ProblemMapping] |
The descriptor types live in masterror::mapping (HttpMapping::status() derives the HTTP code from the kind; GrpcMapping::status() returns the i32; ProblemMapping::type_uri() returns the URI).
#[from], #[source] and #[backtrace] keep working under #[derive(Masterror)]; sources and captured backtraces are attached to the resulting masterror::Error automatically, and Arc-wrapped sources are reused without extra cloning.
Choosing between the derives
| Need | Use |
|---|---|
Display + source + From, thiserror-style | #[derive(Error)] |
Also convert into AppError/AppCode | #[derive(Error)] + #[app_error(...)] |
Typed context via std::error::Request | add #[provide(...)] |
| Metadata, redaction policy, gRPC/problem+json tables | #[derive(Masterror)] + #[masterror(...)] |
See also: Getting Started Β· Error Kinds and Codes Β· Context and Metadata Β· Migration
Context and Metadata
masterror replaces string-glued context (format!("failed to X: {e}")) with three structured mechanisms: the Context builder, typed Metadata fields, and redaction policies enforced at the transport boundary.
ResultExt: promoting foreign errors
ResultExt is implemented for every Result<T, E> where E: Error + Send + Sync + 'static and offers two methods:
.context(msg) β anyhow-style
Wraps the error with a message; the original error becomes the source:
use masterror::ResultExt;
fn read_config() -> Result<String, std::io::Error> {
Err(std::io::Error::from(std::io::ErrorKind::NotFound))
}
let err = read_config().context("Failed to read config file").unwrap_err();
assert!(err.source_ref().is_some());
If the underlying error is already a masterror::Error, .context() preserves its classification: kind, code, metadata, edit policy, retry advice and details are carried over, only the message is replaced and the original error is kept as the source.
.ctx(|| Context) β full control
use masterror::{AppErrorKind, Context, ResultExt, field};
fn validate() -> Result<(), std::io::Error> {
Err(std::io::Error::other("boom"))
}
let err = validate()
.ctx(|| {
Context::new(AppErrorKind::Validation)
.with(field::str("phase", "validate"))
.redact(true)
.track_caller()
})
.unwrap_err();
assert_eq!(err.kind, AppErrorKind::Validation);
assert!(err.metadata().get("phase").is_some());
The closure is only evaluated on the error path.
The Context builder
| Method | Effect |
|---|---|
Context::new(kind) | Target category; the AppCode defaults to the canonical mapping for that kind |
.code(AppCode) | Override the public code |
.category(kind) | Change the category; keeps the code in sync unless it was overridden |
.with(field) | Attach a metadata Field |
.redact(bool) | Toggle message redaction (MessageEditPolicy::Redact / Preserve) |
.redact_field(name, FieldRedaction) | Override the redaction policy of a named field |
.track_caller() | Record the call site as caller.file, caller.line, caller.column metadata |
Metadata fields
Metadata is a sorted, inline-allocated map of typed fields (0β4 fields stay on the stack). Build fields with the masterror::field module:
| Builder | FieldValue variant |
|---|---|
field::str("key", value) | Str(Cow<'static, str>) |
field::i64("key", -1) | I64 |
field::u64("key", 42) | U64 |
field::f64("key", 0.5) | F64 |
field::bool("key", true) | Bool |
field::uuid("key", uuid) | Uuid |
field::duration("key", dur) | Duration |
field::ip("key", addr) | Ip (v4 or v6) |
field::json("key", json!({...})) | Json (requires serde_json feature) |
Attach fields when constructing errors or through Context:
use core::time::Duration;
use masterror::{AppError, FieldValue, field};
let err = AppError::service("downstream degraded")
.with_field(field::str("request_id", "abc123"))
.with_field(field::duration("elapsed", Duration::from_millis(1500)))
.with_field(field::u64("attempt", 2));
assert_eq!(err.metadata().len(), 3);
assert_eq!(err.metadata().get("attempt"), Some(&FieldValue::U64(2)));
for (name, value) in err.metadata().iter() {
println!("{name}={value}");
}
with_fields(iter) extends from an iterator, with_metadata(meta) replaces the container, and Metadata::insert returns the previous value when a key is overwritten.
Redaction policies
Message policy: MessageEditPolicy
Preserve (default) keeps the public message; Redact tells transports to strip it. Set it with .redactable() on an error, .redact(true) on a Context, or redact(message) in #[masterror(...)]:
use masterror::{AppError, MessageEditPolicy, ProblemJson};
let err = AppError::internal("db-3 credentials rejected").redactable();
assert_eq!(err.edit_policy, MessageEditPolicy::Redact);
let problem = ProblemJson::from_app_error(err);
assert!(problem.detail.is_none());
Field policy: FieldRedaction
Each field carries its own policy applied when metadata is serialized into ProblemJson:
| Policy | Effect on the public payload |
|---|---|
None | Value preserved as-is |
Redact | Field removed entirely |
Hash | Value replaced with a SHA-256 digest |
Last4 | All but the last four characters masked |
use masterror::{AppError, FieldRedaction, field};
let err = AppError::internal("payment failed")
.with_field(field::str("card_number", "4111111111111111"))
.redact_field("card_number", FieldRedaction::Last4);
Common secret-like names get a safe default automatically when the field is created: names containing password, secret, authorization, cookie, session, jwt, bearer, otp, pin default to Redact; token/key-like names (api_token, refresh_token, key, apikey) default to Hash; card/account segments combined with a number-like segment (card_number, iban_no, account_id) default to Last4. Detection is case-insensitive. Explicit redact_field/with_redaction always wins.
Error chains
Errors keep their full causal chain. chain() iterates from the error itself down to the root cause; root_cause() jumps straight to the deepest error:
use masterror::AppError;
let io_err = std::io::Error::other("disk offline");
let app_err = AppError::internal("db down").with_context(io_err);
let chain: Vec<_> = app_err.chain().collect();
assert_eq!(chain.len(), 2);
assert_eq!(app_err.root_cause().to_string(), "disk offline");
with_context(...) is the preferred way to attach an upstream error: it accepts owned errors or shared Arc<dyn Error + Send + Sync> values and reuses existing allocations. with_source(...) / with_source_arc(...) are the lower-level equivalents.
Downcasting
Inspect the attached source with is and downcast_ref/downcast_mut:
use masterror::AppError;
let io_err = std::io::Error::other("disk offline");
let err = AppError::internal("boom").with_context(io_err);
assert!(err.is::<std::io::Error>());
if let Some(io) = err.downcast_ref::<std::io::Error>() {
assert_eq!(io.to_string(), "disk offline");
}
is::<E>()βtruewhen the immediate source is of typeE(does not walk the whole chain).downcast_ref::<E>()β borrow the source asE.downcast::<E>()/downcast_mut::<E>()β currently stubs (downcastalways returnsErr(self),downcast_mutalways returnsNone), so preferdowncast_ref.
For deeper matches, walk chain() and use source.is::<E>() / source.downcast_ref::<E>() on each element.
Backtraces
With the backtrace feature, err.backtrace() returns a lazily captured std::backtrace::Backtrace honouring RUST_BACKTRACE, and with_backtrace(bt) attaches an explicit capture. Backtraces are shared via Arc when errors are re-wrapped, so .context() chains do not re-capture.
See also: Getting Started Β· Error Kinds and Codes Β· Derive Macros Β· Observability Β· Best Practices
Web Frameworks
masterror maps errors to HTTP at the transport boundary. Domain code returns
AppResult<T>; the framework adapter converts the
error into an RFC 7807 application/problem+json response, flushes telemetry
and applies redaction. There is exactly one IntoResponse /
ResponseError implementation for AppError in the crate β you never write
the mapping by hand.
Feature flags
| Feature | Enables |
|---|---|
axum | IntoResponse for AppError, ProblemJson, ErrorResponse; pulls serde_json |
actix | ResponseError for AppError; Responder for ProblemJson, ErrorResponse |
multipart | From<axum::extract::multipart::MultipartError> for Error (implies axum) |
openapi | utoipa schema for ErrorResponse |
[dependencies]
masterror = { version = "0.28", features = ["axum"] } # or ["actix"]
Wire format
Both adapters serialize ProblemJson:
| Field | Type | Notes |
|---|---|---|
type | string URI | Canonical problem class, e.g. https://errors.masterror.rs/not-found |
title | string | Short summary derived from AppErrorKind |
status | number | HTTP status code |
detail | string? | Public message; omitted when the error is redactable |
details | object? | Structured details (serde_json feature) |
code | string | Stable machine-readable AppCode, e.g. NOT_FOUND |
grpc | object? | { name, value } gRPC mapping for multi-protocol clients |
metadata | object? | Sanitized fields from Metadata; omitted when redacted |
Transport hints become headers, not body fields:
AppError::with_retry_after_secs(n)βRetry-After: nAppError::with_www_authenticate(challenge)βWWW-Authenticate: challenge
Internal sources (std::error::Error chain) are logged only and never
serialized to clients.
Axum
The axum feature implements IntoResponse for AppError, ProblemJson and
ErrorResponse, plus an inherent AppError::http_status() returning
axum::http::StatusCode derived from the error kind. Converting to a response
flushes telemetry (tracing event, metrics counter, lazy backtrace) β see
Observability.
use axum::{Router, routing::get};
use masterror::{AppError, AppResult};
async fn handler() -> AppResult<&'static str> {
Err(AppError::forbidden("no access"))
}
let app: Router = Router::new().route("/demo", get(handler));
A 401 with hints:
use masterror::AppError;
let err = AppError::unauthorized("missing token")
.with_retry_after_secs(7)
.with_www_authenticate("Bearer realm=\"api\"");
produces status 401, headers Retry-After: 7 and
WWW-Authenticate: Bearer realm="api", and body:
{
"type": "https://errors.masterror.rs/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "missing token",
"code": "UNAUTHORIZED",
"grpc": { "name": "UNAUTHENTICATED", "value": 16 }
}
Domain errors in handlers
The pattern from
examples/axum-rest-api:
derive a domain enum, convert it to AppError once, then reuse the crateβs
IntoResponse.
use axum::response::{IntoResponse, Response};
use masterror::{AppError, Error};
#[derive(Debug, Error, Clone)]
pub enum UserError {
#[error("user not found")]
NotFound,
#[error("email already exists")]
DuplicateEmail,
#[error("invalid email format")]
InvalidEmail
}
impl From<UserError> for AppError {
fn from(err: UserError) -> Self {
match err {
UserError::NotFound => AppError::not_found(err.to_string()),
UserError::DuplicateEmail => AppError::conflict(err.to_string()),
UserError::InvalidEmail => AppError::validation(err.to_string())
}
}
}
impl IntoResponse for UserError {
fn into_response(self) -> Response {
AppError::from(self).into_response()
}
}
With #[app_error(kind = ..., code = ...)] on the derive, the From<UserError> for AppError impl is generated for you β see Derive Macros.
Actix Web
The actix feature implements actix_web::ResponseError for AppError, so
handlers returning AppResult<T> work out of the box. error_response()
emits telemetry and builds the same problem+json payload via
ProblemJson::from_ref.
use actix_web::{App, HttpServer, get};
use masterror::{AppError, AppResult};
#[get("/forbidden")]
async fn forbidden() -> AppResult<&'static str> {
Err(AppError::forbidden("no access"))
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
HttpServer::new(|| App::new().service(forbidden))
.bind(("127.0.0.1", 8080))?
.run()
.await
}
The client receives 403 with:
{
"type": "https://errors.masterror.rs/forbidden",
"title": "Forbidden",
"status": 403,
"detail": "no access",
"code": "FORBIDDEN",
"grpc": { "name": "PERMISSION_DENIED", "value": 7 }
}
ProblemJson and ErrorResponse also implement Responder, so a handler can
return them directly. Status mapping uses the same stable
AppErrorKind β StatusCode table as Axum.
Multipart
multipart (implies axum) converts
axum::extract::multipart::MultipartError into Error with
AppErrorKind::BadRequest, preserving the parser message:
use axum::extract::multipart::Multipart;
use masterror::{AppErrorKind, Error};
async fn upload(mut multipart: Multipart) -> Result<(), Error> {
while let Some(field) = multipart.next_field().await? {
let _ = field.bytes().await?;
}
Ok(())
}
Malformed client payloads surface as 400 Bad Request instead of a 500.
Building responses manually
For tests or custom transports, construct the payload without a framework:
use masterror::{AppError, ProblemJson};
let problem = ProblemJson::from_app_error(AppError::not_found("resource not found"));
assert_eq!(problem.status, 404);
assert_eq!(problem.code.as_str(), "NOT_FOUND");
ProblemJson::from_ref(&err) borrows instead of consuming, and
ProblemJson::from_error_response(resp) upgrades the legacy ErrorResponse
wire type.
See also: Error Kinds & Codes Β· Integrations Β· Observability Β· Feature Flags
Integrations
Optional feature flags add From<...> conversions from popular third-party
error types into masterror::Error, so a single ? at the call site produces
a classified error with structured metadata. Every conversion picks a stable
AppErrorKind and attaches
telemetry fields (never secrets) for observability.
Conversion matrix
| Feature | Source type | Resulting AppErrorKind |
|---|---|---|
sqlx | sqlx_core::error::Error | NotFound, Conflict, Validation, Timeout, DependencyUnavailable, Config, BadRequest, Serialization, Deserialization, Network, Database, Internal |
sqlx-migrate | sqlx::migrate::MigrateError | Database (with migration phase metadata) |
redis | redis::RedisError | Cache (default), Timeout, DependencyUnavailable |
reqwest | reqwest::Error | Timeout, Network, RateLimited, DependencyUnavailable, ExternalApi |
validator | validator::ValidationErrors | Validation |
config | config::ConfigError | Config (with config.phase metadata) |
tokio | tokio::time::error::Elapsed | Timeout |
serde_json | serde_json::Error | Serialization (I/O), Deserialization (syntax/data/EOF) |
teloxide | teloxide_core::RequestError | ExternalApi, Unauthorized, RateLimited, Network, Deserialization, Internal |
init-data | init_data_rs::InitDataError | TelegramAuth |
tonic | masterror::Error β tonic::Status | outbound mapping, see below |
multipart | axum::extract::multipart::MultipartError | BadRequest (see Web Frameworks) |
sqlx and sqlx-migrate
sqlx depends only on sqlx-core (no drivers, no TLS). Key mappings:
Error::RowNotFoundβNotFound- Pool timeout β
Timeout; pool closed and I/O failures βDependencyUnavailable; TLS errors βNetwork - Constraint violations are classified by
sqlxerror kind: unique and foreign-key violations βConflict, not-null / check violations βValidation, anything else βDatabase - Encode β
Serialization, decode βDeserialization
Database errors capture SQLSTATE and constraint names as metadata. Known
SQLSTATE codes override the public AppCode:
23505 β USER_ALREADY_EXISTS, 23503 β CONFLICT, 23502/23514 β
VALIDATION. Transient SQLSTATEs (40001 serialization failure, 55P03 lock
not available) attach retry hints.
use masterror::{AppErrorKind, Error};
async fn load_user(pool: &sqlx::PgPool, id: i64) -> Result<User, Error> {
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await?; // RowNotFound becomes AppErrorKind::NotFound
Ok(user)
}
sqlx-migrate pulls full sqlx (still without default features) and maps
sqlx::migrate::MigrateError to Database, recording the migration phase and
version in metadata.
redis
All redis::RedisError values map to Cache by default; timeout-flavoured
errors become Timeout and connection failures become DependencyUnavailable.
Error category and code are preserved as metadata.
reqwest
Treats reqwest as a client of an external HTTP API:
is_timeout()βTimeoutis_connect()/is_request()βNetwork- HTTP status errors:
429βRateLimited,408βTimeout,5xxβDependencyUnavailable, others βExternalApi - everything else β
ExternalApi
Metadata records the endpoint, status and low-level flags; the URL is marked for hashing/redaction in public payloads.
validator
validator::ValidationErrors β Validation, with aggregate context in
metadata: failing field names (validation.fields), field and error counts,
and the first validation codes (validation.codes):
use masterror::AppResult;
use validator::Validate;
#[derive(Validate)]
struct Payload {
#[validate(length(min = 5))]
name: String
}
fn check(p: &Payload) -> AppResult<()> {
p.validate()?; // ValidationErrors -> AppErrorKind::Validation
Ok(())
}
config, tokio, serde_json
config::ConfigErrorβConfig, with aconfig.phasemetadata field (not_found,file_parse,type, β¦) identifying the failing stage.tokio::time::error::ElapsedβTimeoutwith atimeout.source = "tokio::time::timeout"metadata field. The error carries no custom message, so clients see the kindβs fixed title"Operation timed out".serde_json::Erroris classified viaError::classify(): I/O βSerialization; syntax, data and EOF βDeserialization.
teloxide
teloxide_core::RequestError mapping:
| Variant | AppErrorKind |
|---|---|
Api | ExternalApi (invalid token β Unauthorized) |
MigrateToChatId | ExternalApi |
RetryAfter | RateLimited |
Network | Network |
InvalidJson | Deserialization |
Io | Internal |
init-data (Telegram Mini Apps)
Every init_data_rs::InitDataError variant (missing/invalid hash, expired
payload, signature failures) maps to TelegramAuth, keeping Mini App
authentication failures distinct from generic bad requests.
tonic (outbound gRPC)
tonic converts in the opposite direction: masterror::Error β
tonic::Status via From. The AppCode is mapped
to the canonical tonic::Code through the same CODE_MAPPINGS table used for
HTTP. The status carries metadata entries app-code, app-http-status and
app-problem-type, plus retry and www-authenticate hints when present.
Redactable errors have their message replaced by the kind label and their
metadata stripped.
use masterror::AppError;
use tonic::{Code, Status};
let status = Status::from(AppError::not_found("missing"));
assert_eq!(status.code(), Code::NotFound);
frontend (WASM / browser)
The frontend feature adds the masterror::frontend::BrowserConsoleExt trait
for AppError and ErrorResponse, backed by wasm-bindgen:
to_js_value()β serialize the error into awasm_bindgen::JsValuelog_to_browser_console()β emit it viaconsole.error
Both are functional on wasm32 targets; on native targets they return
BrowserConsoleError::UnsupportedTarget. Failure modes (console missing,
console.error not callable, serialization failure) are covered by the
BrowserConsoleError enum.
use masterror::{AppError, frontend::BrowserConsoleExt};
let err = AppError::not_found("user not found");
err.log_to_browser_console()?;
turnkey
The turnkey feature exposes a small stable domain taxonomy in
masterror::turnkey:
TurnkeyErrorKind | AppErrorKind |
|---|---|
UniqueLabel | Conflict |
RateLimited | RateLimited |
Timeout | Timeout |
Auth | Unauthorized |
Network | Network |
Service | Turnkey |
TurnkeyError::new(kind, msg) builds a domain error; From<TurnkeyError> for AppError and From<TurnkeyErrorKind> for AppErrorKind perform the mapping.
classify_turnkey_error(&str) heuristically classifies a raw provider message
(case-insensitive, word-boundary aware) into a TurnkeyErrorKind:
use masterror::turnkey::{TurnkeyError, TurnkeyErrorKind, classify_turnkey_error};
use masterror::{AppError, AppErrorKind};
let kind = classify_turnkey_error("429 rate-limit reached");
assert_eq!(kind, TurnkeyErrorKind::RateLimited);
let app: AppError = TurnkeyError::new(kind, "quota exceeded").into();
assert_eq!(app.kind, AppErrorKind::RateLimited);
See also: Feature Flags Β· Web Frameworks Β· Error Kinds & Codes Β· Observability
Observability
masterror treats telemetry as part of the error lifecycle. Each AppError
tracks a dirty flag; telemetry is emitted once per state change β at
construction, after mutation, or when the error crosses a transport boundary
(Axum IntoResponse, Actix error_response(), tonic Status conversion).
You rarely call anything manually.
Feature flags
| Feature | Adds |
|---|---|
tracing | Structured tracing event per error, trace_id via log-mdc |
metrics | error_total{code,category} counter via the metrics crate |
backtrace | Lazy std::backtrace::Backtrace capture gated by RUST_BACKTRACE |
colored | ANSI-colored terminal styling with TTY detection |
[dependencies]
masterror = { version = "0.28", features = ["tracing", "metrics", "backtrace"] }
Tracing
With tracing enabled, each error emits one ERROR-level event with target
masterror::error:
| Field | Content |
|---|---|
code | AppCode string, e.g. NOT_FOUND |
category | AppErrorKind label, e.g. Database |
message | Public message, if any |
retry_seconds | Retry advice, if set |
redactable | Whether the message is redacted at transport boundaries |
metadata_len | Number of attached metadata fields |
www_authenticate | Authentication challenge, if set |
trace_id | Pulled from the log-mdc context key trace_id, if present |
The emission is subscriber-aware: if no subscriber is interested in ERROR-level events for the target, the event stays pending and is retried on the next flush, so nothing is lost when a subscriber is installed late.
To correlate errors with requests, store a trace ID in the MDC in your request middleware:
log_mdc::insert("trace_id", request_id);
Every error constructed while the key is set carries it in the event.
Metrics
With metrics enabled, each newly-dirty error increments:
error_total{code="NOT_FOUND", category="NotFound"}
Both labels are stable strings (AppCode::as_str() and the AppErrorKind
label), so dashboards and alerts survive refactors of your domain types. Wire
any metrics recorder (Prometheus, StatsD, β¦) as usual; masterror only
uses metrics::counter!.
Backtraces
With backtrace enabled, a Backtrace snapshot is captured lazily when
telemetry is flushed β not on every construction. Capture is controlled by
RUST_BACKTRACE: unset, empty, 0, off and false disable it; anything
else enables it. The preference is read once and cached per process.
#[cfg(feature = "backtrace")] {
use masterror::AppError;
let err = AppError::internal("db down");
if let Some(bt) = err.backtrace() {
eprintln!("{bt}");
}
}
You can also attach a pre-captured trace with
AppError::with_backtrace(backtrace), which takes priority over lazy capture.
Manual flushing with .log()
Constructors and conversions emit telemetry automatically. After mutating an error (adding fields, changing retry advice) you can force re-emission:
use masterror::{AppError, field};
let err = AppError::service("upstream degraded")
.with_field(field::str("upstream", "billing"));
err.log();
log() is idempotent per state: if nothing changed since the last emission,
it does nothing. The HTTP/gRPC adapters flush the same way at the boundary,
so an error that is constructed, enriched and then returned from an Axum
handler emits once per state β once at construction and once at the boundary
for the enriched state β never twice for the same state.
Inspecting the chain
Independent of features, AppError exposes the tools log pipelines need:
#[cfg(feature = "std")] {
use std::io::Error as IoError;
use masterror::AppError;
let err = AppError::internal("db down").with_context(IoError::other("disk offline"));
assert_eq!(err.chain().count(), 2);
let _root = err.root_cause();
assert!(err.metadata().is_empty());
}
metadata().iter_with_redaction() yields (key, value, policy) triples so a
logging layer can honour field redaction β see
Context & Metadata.
Colored terminal output
The colored feature adds masterror::colored::style for CLI tools. Colors
are applied only when stderr is a TTY, NO_COLOR is unset, TERM is not
dumb and the terminal supports ANSI; otherwise the text passes through
unchanged. Detection is cached per process.
| Function | Style | Use for |
|---|---|---|
error_kind_critical | red | critical failure kinds |
error_kind_warning | yellow | recoverable/warning kinds |
error_code | cyan | machine codes |
error_message | bright white | main message |
source_context | dimmed | secondary/source info |
metadata_key | green | structured field names |
#[cfg(feature = "colored")] {
use masterror::colored::style;
eprintln!(
"{}: {}",
style::error_code("ERR_DB_001"),
style::error_message("Database connection failed")
);
}
See the runnable demo in
examples/colored_cli.rs.
See also: Feature Flags Β· Context & Metadata Β· Web Frameworks Β· Best Practices
no_std Support
masterror builds without the Rust standard library. The crate root declares
#![cfg_attr(not(feature = "std"), no_std)], and the default std feature is
the only thing standing between you and an embedded/WASM-friendly build:
[dependencies]
masterror = { version = "0.28", default-features = false }
alloc is required
masterror is no_std but not no_alloc. The crate unconditionally
declares extern crate alloc and uses Cow<'static, str>, String, Arc
and BTreeMap for messages, metadata and source chains. Your target needs a
global allocator; pure core-only environments are not supported.
What works without std
The entire framework-agnostic core:
| Area | Available in no_std |
|---|---|
| Core types | Error / AppError, AppResult, AppErrorKind, AppCode |
| Metadata | Metadata, Field, FieldValue, FieldRedaction, field::* helpers |
| Context | Context, ResultExt::{ctx, context} |
| Control flow | ensure!, fail! |
| Derives | #[derive(Error)], #[derive(Masterror)] with all attributes |
| Wire types | ProblemJson, ErrorResponse, CODE_MAPPINGS, mapping_for_code |
| Introspection | chain(), root_cause(), is/downcast/downcast_ref/downcast_mut, render_message() |
| Serde | serde with alloc (JSON serialization of wire types) |
Error sources work through core::error::Error: the crate implements and
consumes core::error::Error (aliased internally as CoreError) instead of
std::error::Error, so with_source(...), source chains and downcasting are
fully functional in no_std builds.
use masterror::{AppCode, AppError, AppErrorKind, field};
let err = AppError::new(AppErrorKind::Timeout, "deadline exceeded")
.with_field(field::u64("attempt", 3));
assert_eq!(err.code, AppCode::Timeout);
assert_eq!(err.metadata().len(), 1);
What requires std
Every runtime integration explicitly re-enables std in its feature
definition. From Cargo.toml:
tracing,metrics,backtrace,coloredaxum,actix,multipart,tonic,openapiserde_json,redis,validator,config,tokio,reqwest,teloxide,init-data,frontend,turnkey
backtrace needs std::backtrace::Backtrace and environment access;
colored needs TTY detection; the web and client integrations need their
host crates, which are themselves std-only.
CI feature matrix
The no_std CI job (.github/workflows/ci.yml) checks these combinations on
every pull request and push to main:
| Job | Command | Verifies |
|---|---|---|
bare | cargo check --no-default-features | true no_std + alloc build |
std-only | cargo check --features std | default std surface |
tracing | cargo check --no-default-features --features tracing | single telemetry feature builds standalone |
metrics | cargo check --no-default-features --features metrics | same for metrics |
colored | cargo check --no-default-features --features colored | same for colored |
all-features | cargo check --all-features | full feature union |
Note the semantics: only the bare job is a genuine no_std compilation.
tracing = [..., "std"], metrics = [..., "std"] and
colored = [..., "std"] transitively re-enable std, so those jobs verify
that each telemetry feature is self-sufficient when defaults are off β not
that telemetry works without the standard library. If you need telemetry, you
need std.
Practical setup
Library crates that want to stay transport-agnostic and no_std-compatible:
[dependencies]
masterror = { version = "0.28", default-features = false }
[features]
std = ["masterror/std"]
The binary or service crate then turns on std plus whatever integrations it
needs:
[dependencies]
masterror = { version = "0.28", features = ["axum", "tracing", "metrics"] }
Because AppErrorKind, AppCode and the wire types live in the no_std
core, domain crates can classify errors and even build ProblemJson payloads
while the HTTP mapping happens only in the service crate β see
Best Practices.
Toolchain
The crate targets edition 2024 with rust-version = "1.96" in Cargo.toml.
core::error::Error (the foundation of no_std source chains) has been
stable since Rust 1.81, so no nightly features are involved.
See also: Feature Flags Β· Getting Started Β· Best Practices
Best Practices
Patterns that keep masterror-based services predictable: typed domain
errors, one stable code taxonomy, transport mapping at the edge, and public
messages that never leak internals.
Derive domain errors, map them once
Model each bounded context as an enum with #[derive(Error)] and declare the
AppError mapping inline with #[app_error(...)]. The derive generates
Display, From<...> for wrapped sources, and the conversion into
AppError/AppCode β no hand-written match at every call site.
use masterror::{AppCode, AppError, AppErrorKind, Error};
#[derive(Debug, Error)]
pub enum UserError {
#[error("user {0} not found")]
#[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)]
NotFound(u64),
#[error("email already registered")]
#[app_error(kind = AppErrorKind::Conflict, code = AppCode::Conflict, message)]
DuplicateEmail,
#[error("storage failure")]
#[app_error(kind = AppErrorKind::Database, code = AppCode::Database)]
Storage(#[from] std::io::Error)
}
let app: AppError = UserError::DuplicateEmail.into();
assert_eq!(app.kind, AppErrorKind::Conflict);
Include message only on variants whose Display output is safe to show to
clients. Omit it (as on Storage) to keep the text internal β the client sees
only the kindβs title. Full attribute reference:
Derive Macros.
One AppCode taxonomy per service
AppCode is your public API contract; clients branch on it. Keep the set
small, documented and per-service:
- Prefer the built-in codes (
NOT_FOUND,CONFLICT,VALIDATION, β¦) β they already carry canonical HTTP/gRPC/problem-type mappings. - Mint custom codes centrally, not inline at call sites:
use masterror::AppCode;
pub const CODE_PLAN_LIMIT: AppCode = AppCode::new("PLAN_LIMIT_EXCEEDED");
AppCode::new is const and panics at compile time on anything that is not
SCREAMING_SNAKE_CASE; use AppCode::try_new for runtime strings. Renaming a
code is a breaking API change β treat additions like adding an enum variant.
Map to transports at the edge only
Domain and service layers return AppResult<T> and know nothing about HTTP.
The single IntoResponse/ResponseError/Status implementation in the crate
does the mapping in the handler layer:
async fn get_user(id: u64, repo: &Repo) -> masterror::AppResult<User> {
repo.find(id).await? // sqlx::Error -> AppError::NotFound/Database
}
Never hand-construct status codes in business logic and never implement a
second response conversion β the stable AppErrorKind β status table in
Web Frameworks is the one source of truth.
Redact sensitive data, keep telemetry
Two independent knobs:
- Message redaction β
err.redactable()(orredact(message)in#[masterror(...)]) hidesdetailfrom wire payloads while logs keep it. - Field redaction β per-field policy applied when metadata is serialized:
use masterror::{AppError, FieldRedaction, field};
let err = AppError::bad_request("Invalid credentials")
.with_field(field::str("email", "user@example.com").with_redaction(FieldRedaction::Hash))
.with_field(field::str("card", "4111111111111111").with_redaction(FieldRedaction::Last4))
.with_field(field::str("ip", "192.168.1.100").with_redaction(FieldRedaction::Redact));
Hash keeps correlational value (same input β same digest) without exposing
the raw string; Last4 suits card/token suffixes; Redact removes the value
entirely. Default is None. Redact anything user-identifying by default and
opt out consciously, not the other way around.
Context vs derive
- Derive when the error type is part of your domain vocabulary: it has variants, appears in signatures, and its mapping is static.
Context(viaResultExt::ctx) when wrapping an infrastructure error ad hoc at a call site and the classification depends on the operation, not the type:
#[cfg(feature = "std")] {
use masterror::{AppErrorKind, Context, ResultExt, field};
fn read_state() -> masterror::AppResult<Vec<u8>> {
std::fs::read("/var/lib/app/state.bin").ctx(|| {
Context::new(AppErrorKind::Internal)
.with(field::str("path", "/var/lib/app/state.bin"))
.track_caller()
})
}
}
ctx is lazy β the closure runs only on the error path. Use plain
.context("message") when a human-readable note is all you need. Details:
Context & Metadata.
Test kinds and codes, not strings
Assert on the stable taxonomy, never on formatted messages:
use masterror::{AppCode, AppError, AppErrorKind, ProblemJson};
let err = AppError::not_found("user 42 missing");
assert_eq!(err.kind, AppErrorKind::NotFound);
assert_eq!(err.code, AppCode::NotFound);
let problem = ProblemJson::from_ref(&err);
assert_eq!(problem.status, 404);
assert_eq!(problem.code.as_str(), "NOT_FOUND");
ProblemJson::from_reflets integration tests assert the exact wire contract without spinning up a server.mapping_for_code(&code)exposes the canonical HTTP status, gRPC code and problem-type URI for table-driven tests.- For redaction tests, assert
problem.detail.is_none()on aredactable()error and checkmetadata().iter_with_redaction()policies.
Public message vs internal telemetry
A useful rule for every error you construct:
| Channel | Contents |
|---|---|
message / detail | Human-oriented, non-sensitive, stable enough to show a user |
Metadata fields | IDs, attempts, endpoints, durations β for logs/metrics, with redaction policies |
source chain | Raw underlying errors β logged, never serialized to clients |
masterror enforces the last row (sources are never written to wire
payloads), but the first two are your responsibility: if a string contains
anything you would not print in a browser, put it in metadata with a
redaction policy or mark the error redactable().
See also: Derive Macros Β· Context & Metadata Β· Error Kinds & Codes Β· Migration
Migration
masterror is designed as a drop-in successor to both thiserror (derive
syntax) and anyhow (ergonomics). Most migrations are a dependency swap plus
incremental adoption of the typed features. Runnable walkthroughs:
examples/migrate_from_thiserror.rs
and
examples/migrate_from_anyhow.rs.
From thiserror
Step 1 is mechanical β change the import; the derive syntax is compatible:
-use thiserror::Error;
+use masterror::Error;
Attribute compatibility
| thiserror | masterror | Notes |
|---|---|---|
#[error("...")] with {field} placeholders | same | 1:1, including positional {0} |
Format specs :>8, :.3, :x, :p, :e | same | TemplateFormatter mirrors thiserrorβs formatter detection |
#[error(transparent)] | same | enforces single-field wrappers forwarding Display/source |
#[from] | same | generates From<...>, validates wrapper shape |
#[source] | same | wires the source() chain |
#[backtrace] | same | honoured on fields |
| β | #[app_error(kind = ..., code = ..., message)] | added: generates From<YourError> for AppError (and AppCode); message forwards Display as the public message |
| β | #[provide(ref = T, value = T)] | added: typed telemetry via std::error::Request; Option<T> fields provide only when Some |
| β | #[derive(Masterror)] + #[masterror(...)] | added: full mapping with category, redact(message, fields(...)), telemetry(...), map.grpc, map.problem |
Existing enums keep compiling unchanged. What you gain by annotating them:
use masterror::{AppCode, AppError, AppErrorKind, Error};
#[derive(Debug, Error)]
#[error("user {user_id} not found")]
#[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound, message)]
struct UserMissing {
user_id: u64
}
let app: AppError = UserMissing { user_id: 42 }.into();
assert_eq!(app.kind, AppErrorKind::NotFound);
Enums map per variant β each variant carries its own #[app_error(...)], and
the derive emits a single From<Enum> for AppError.
Recommended order: (1) swap the import, (2) add #[app_error] to types that
cross the API boundary, (3) replace hand-written From<DomainError> for AppError impls with the generated ones, (4) adopt #[masterror(...)] where
you need redaction or metadata.
From anyhow
| anyhow | masterror | Notes |
|---|---|---|
anyhow::Result<T> | masterror::AppResult<T> | alias for Result<T, AppError> |
anyhow::Error | masterror::AppError / Error | carries kind, code, metadata instead of a blob |
.context("msg") | .context("msg") | identical, via masterror::ResultExt |
.with_context(|| ...) | .ctx(|| Context::new(kind)...) | lazy like anyhow, but builds a typed Context (kind, code, fields, redaction) |
bail!(err) | fail!(err) | takes a typed error expression, no format machinery |
ensure!(cond, "msg {x}") | ensure!(cond, AppError::...) | condition + typed error; no formatting on the success path |
err.chain() | err.chain() | same iterator over the source chain |
err.root_cause() | err.root_cause() | same |
err.is::<E>() / downcast / downcast_ref / downcast_mut | same names on AppError | downcasting parity |
#[from]-style wrapping | From<...> impls behind feature flags | sqlx/redis/reqwest/β¦ arrive pre-classified |
.context() works exactly as you expect:
use masterror::{AppResult, ResultExt};
fn read_config(path: &str) -> AppResult<String> {
let content = std::fs::read_to_string(path).context("Failed to read config file")?;
Ok(content)
}
ensure!/fail! trade anyhowβs string formatting for typed errors:
use masterror::{AppError, AppResult, ensure, fail, field};
fn parse(content: &str, path: &str) -> AppResult<()> {
ensure!(
!content.is_empty(),
AppError::bad_request("Config file is empty")
.with_field(field::str("path", path.to_owned()))
);
if content.starts_with("invalid") {
fail!(AppError::bad_request("Invalid config format"));
}
Ok(())
}
The error expression is evaluated only when the guard trips, so the happy path stays allocation-free β same guarantee anyhow gives, plus a machine-readable code.
Where anyhow has no equivalent
Migrating buys you capabilities that have no anyhow counterpart:
- Typed taxonomy β
AppErrorKind(internal) andAppCode(public, SCREAMING_SNAKE_CASE) instead of stringly-typed context. See Error Kinds & Codes. - Transport mappings β RFC 7807
problem+jsonfor Axum/Actix andtonic::Statusfor gRPC, derived from the same code table. See Web Frameworks. - Telemetry β automatic
tracingevents,error_total{code,category}metrics and lazy backtraces at the boundary. See Observability. - Redaction β
redactable()messages and per-fieldHash/Last4/Redactpolicies, honoured by every transport. See Best Practices. - Structured metadata β typed
field::str/u64/duration/ip/...instead of formatting values into the message.
What to watch for
- anyhowβs
ensure!(cond, "format {}", x)formatted-message form has no direct twin: construct the error explicitly (AppError::bad_request(format!(...))) or, better, use a static message plus metadata fields. anyhow::Erroraccepts anyE: Error + Send + Sync. In masterror you choose a kind at wrap time (Context::new(kind)or aFromconversion) β that decision point is the feature, not friction: it is where classification happens.- Both
ensure!andfail!expand toreturn Err(...), so they work in any function returningResult<_, E>where your expression is already the error type β noIntoconversion is inserted.
See also: Getting Started Β· Derive Macros Β· Context & Metadata Β· Best Practices