Keyboard shortcuts

Press ← or β†’ to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

masterror

Framework-agnostic application error types with stable codes, HTTP/gRPC mappings and built-in telemetry

English Русский ν•œκ΅­μ–΄

Crates.io docs.rs MSRV License


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::Code discriminant and an RFC 7807 type URI.
  • Typed telemetry β€” metadata is stored as typed fields (strings, integers, floats, durations, IPs, UUIDs, JSON) with per-field redaction policies, not ad-hoc String maps.
  • Native derives β€” #[derive(Error)] mirrors thiserror syntax, while #[app_error(...)] and #[derive(Masterror)] wire domain errors into AppError with 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

Concernthiserroranyhowmasterror
Display / source() derivesYesβ€”Yes (same syntax)
Type-erased propagation with contextβ€”YesYes (.ctx() / .context())
Stable machine-readable error codesManualManualAppCode, part of the wire contract
HTTP status mappingManualManualAppErrorKind::http_status(), stable table
gRPC status mappingManualManualCODE_MAPPINGS, tonic::Status conversion
RFC 7807 problem+jsonManualManualProblemJson::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

AreaWhat you get
Core taxonomyAppError, 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 flowensure! / fail! β€” typed, allocation-free early returns
ContextResultExt::ctx / ResultExt::context, Context builder with caller tracking
Wire payloadsErrorResponse (legacy JSON), ProblemJson (RFC 7807) with retry and auth hints
TransportsAxum IntoResponse, Actix ResponseError/Responder, tonic::Status, WASM JsValue, OpenAPI schema
Integrationssqlx, redis, reqwest, validator, config, tokio, teloxide, Telegram Mini Apps init data, Turnkey
Observabilitytracing 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

CrateRole
masterrorCore error types, metadata, transports, integrations, prelude
masterror-deriveProc-macros behind #[derive(Error)] and #[derive(Masterror)] (pulled in automatically)
masterror-templateShared #[error("...")] template parser

Documentation

Getting started

Core concepts

Integrations

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 the Display template with {field} placeholders.
  • #[from] generates From<std::io::Error> for the wrapper.
  • #[source] forwards the inner error through source().
  • #[app_error(kind = ..., code = ..., message)] generates From<DomainError> for AppError (and for AppCode); the message flag exposes the Display output 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


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

FlagWhat it enablesExtra deps
std (default)Standard library support; required by all runtime integrations. Disable for no_std (see No-Std)β€”

Web transports

FlagWhat it enablesExtra deps
axumIntoResponse for AppError and ProblemJson with RFC 7807 JSON bodies; AppErrorKind::status_code()axum (json, multipart), serde_json
actixActix Web ResponseError for AppError and Responder for ProblemJsonactix-web
multipartMaps axum::extract::multipart::MultipartError β†’ BadRequest (implies axum)via axum
openapiutoipa::ToSchema for ErrorResponse and AppCode so error payloads appear in OpenAPI specsutoipa
serde_jsonStructured JSON details on AppError/ErrorResponse/ProblemJson; FieldValue::Json and field::jsonserde_json
tonicConversion of errors into tonic::Status with sanitized metadata; exports StatusConversionErrortonic

Telemetry and observability

FlagWhat it enablesExtra deps
tracingStructured tracing events emitted when errors are constructedtracing, log, log-mdc
metricsIncrements an error_total{code,category} counter for each AppErrormetrics
backtraceLazy std::backtrace::Backtrace capture (honours RUST_BACKTRACE), with_backtrace() builderβ€”
coloredColored multi-line terminal output with automatic TTY detection; richer Display for AppErrorowo-colors

Async and IO integrations

Each integration flag adds a From<...> for AppError conversion that classifies the library error into the taxonomy:

FlagConversionExtra deps
sqlxsqlx_core::Error β†’ NotFound / Database (lean sqlx-core, no drivers or TLS)sqlx-core
sqlx-migratesqlx::migrate::MigrateError β†’ Database (full sqlx with migrate feature only)sqlx
redisredis::RedisError β†’ Cacheredis
reqwestreqwest::Error β†’ Timeout / Network / ExternalApireqwest
tokiotokio::time::error::Elapsed β†’ Timeouttokio (time)
validatorvalidator::ValidationErrors β†’ Validationvalidator
configconfig::ConfigError β†’ Configconfig

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

FlagConversionExtra deps
teloxideteloxide_core::RequestError β†’ RateLimited / Network / ExternalApi / Deserialization / Internalteloxide-core
init-datainit_data_rs::InitDataError β†’ TelegramAuth (Telegram Mini Apps init-data validation)init-data-rs

Front-end and domain

FlagWhat it enablesExtra deps
frontendfrontend module: convert errors to wasm_bindgen::JsValue and emit console.error logs in WASM/browser contextswasm-bindgen, js-sys, serde-wasm-bindgen
turnkeyturnkey module: TurnkeyErrorKind, TurnkeyError, classify_turnkey_error and conversions into AppErrorβ€”
benchmarksCriterion benchmark suite and CI baseline tooling (local profiling only)β€”

Baseline conversions (always available)

Without any feature flag, AppError already converts from:

SourceTarget kind
std::io::ErrorInternal
StringBadRequest

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 std except sqlx and sqlx-migrate, which stay std-independent at the flag level.
  • axum and actix pull serde_json transitively because their response bodies are JSON.
  • Feature flags never change the wire contract of ErrorResponse/ProblemJson fields that are already enabled β€” they only add capabilities (e.g. serde_json upgrades details from 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

VariantMeaningHTTP
NotFoundResource does not exist or is not visible to the caller404
ValidationStructured input failed validation422
ConflictState conflict (unique key violation, version mismatch)409
UnauthorizedAuthentication required or failed401
ForbiddenAuthenticated but not allowed403
NotImplementedOperation not supported by this deployment501
BadRequestMalformed request or missing parameters400
TelegramAuthTelegram authentication flow failed401
InvalidJwtJWT expired, malformed or has wrong signature/claims401
RateLimitedClient exceeded rate limits or quota429
TimeoutOperation did not complete in time504
NetworkNetwork-level error (DNS, connect, TLS)503
DependencyUnavailableExternal dependency down or degraded503
InternalUnexpected server-side failure500
DatabaseDatabase failure (query, connection, migration)500
ServiceGeneric service-layer/business-logic failure500
ConfigMissing or invalid configuration500
TurnkeyTurnkey subsystem failure500
SerializationFailed to encode data500
DeserializationFailed to decode data500
ExternalApiUpstream API returned an error500
QueueQueue publish/consume/ack failure500
CacheCache read/write/encoding failure500
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):

AppCodeHTTPgRPCproblem type
NOT_FOUND404NOT_FOUND (5)https://errors.masterror.rs/not-found
VALIDATION422INVALID_ARGUMENT (3).../validation
CONFLICT409ALREADY_EXISTS (6).../conflict
USER_ALREADY_EXISTS409ALREADY_EXISTS (6).../user-already-exists
UNAUTHORIZED401UNAUTHENTICATED (16).../unauthorized
FORBIDDEN403PERMISSION_DENIED (7).../forbidden
NOT_IMPLEMENTED501UNIMPLEMENTED (12).../not-implemented
BAD_REQUEST400INVALID_ARGUMENT (3).../bad-request
RATE_LIMITED429RESOURCE_EXHAUSTED (8).../rate-limited
TELEGRAM_AUTH401UNAUTHENTICATED (16).../telegram-auth
INVALID_JWT401UNAUTHENTICATED (16).../invalid-jwt
INTERNAL500INTERNAL (13).../internal
DATABASE500INTERNAL (13).../database
SERVICE500INTERNAL (13).../service
CONFIG500INTERNAL (13).../config
TURNKEY500INTERNAL (13).../turnkey
TIMEOUT504DEADLINE_EXCEEDED (4).../timeout
NETWORK503UNAVAILABLE (14).../network
DEPENDENCY_UNAVAILABLE503UNAVAILABLE (14).../dependency-unavailable
SERIALIZATION500INTERNAL (13).../serialization
DESERIALIZATION500INTERNAL (13).../deserialization
EXTERNAL_API500UNAVAILABLE (14).../external-api
QUEUE500UNAVAILABLE (14).../queue
CACHE500UNAVAILABLE (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 for thiserror::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 into masterror::Error with 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

AttributeEffect
#[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 the AppErrorKind; generates From<T> for AppError.
  • code = ... additionally generates From<T> for AppCode.
  • message forwards the Display output as the public message; omit it to keep the message internal.
  • no_source skips source attachment and drops the domain error during conversion (for types that are not Send + 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

OptionMeaning
code = AppCode::...Public machine-readable code
category = AppErrorKind::...Semantic category (drives HTTP status)
messageExpose 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:

ShapeConstants
StructT::HTTP_MAPPING: HttpMapping, T::GRPC_MAPPING: Option<GrpcMapping>, T::PROBLEM_MAPPING: Option<ProblemMapping>
EnumT::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

NeedUse
Display + source + From, thiserror-style#[derive(Error)]
Also convert into AppError/AppCode#[derive(Error)] + #[app_error(...)]
Typed context via std::error::Requestadd #[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

MethodEffect
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:

BuilderFieldValue 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:

PolicyEffect on the public payload
NoneValue preserved as-is
RedactField removed entirely
HashValue replaced with a SHA-256 digest
Last4All 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>() β€” true when the immediate source is of type E (does not walk the whole chain).
  • downcast_ref::<E>() β€” borrow the source as E.
  • downcast::<E>() / downcast_mut::<E>() β€” currently stubs (downcast always returns Err(self), downcast_mut always returns None), so prefer downcast_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

FeatureEnables
axumIntoResponse for AppError, ProblemJson, ErrorResponse; pulls serde_json
actixResponseError for AppError; Responder for ProblemJson, ErrorResponse
multipartFrom<axum::extract::multipart::MultipartError> for Error (implies axum)
openapiutoipa schema for ErrorResponse
[dependencies]
masterror = { version = "0.28", features = ["axum"] }   # or ["actix"]

Wire format

Both adapters serialize ProblemJson:

FieldTypeNotes
typestring URICanonical problem class, e.g. https://errors.masterror.rs/not-found
titlestringShort summary derived from AppErrorKind
statusnumberHTTP status code
detailstring?Public message; omitted when the error is redactable
detailsobject?Structured details (serde_json feature)
codestringStable machine-readable AppCode, e.g. NOT_FOUND
grpcobject?{ name, value } gRPC mapping for multi-protocol clients
metadataobject?Sanitized fields from Metadata; omitted when redacted

Transport hints become headers, not body fields:

  • AppError::with_retry_after_secs(n) β†’ Retry-After: n
  • AppError::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

FeatureSource typeResulting AppErrorKind
sqlxsqlx_core::error::ErrorNotFound, Conflict, Validation, Timeout, DependencyUnavailable, Config, BadRequest, Serialization, Deserialization, Network, Database, Internal
sqlx-migratesqlx::migrate::MigrateErrorDatabase (with migration phase metadata)
redisredis::RedisErrorCache (default), Timeout, DependencyUnavailable
reqwestreqwest::ErrorTimeout, Network, RateLimited, DependencyUnavailable, ExternalApi
validatorvalidator::ValidationErrorsValidation
configconfig::ConfigErrorConfig (with config.phase metadata)
tokiotokio::time::error::ElapsedTimeout
serde_jsonserde_json::ErrorSerialization (I/O), Deserialization (syntax/data/EOF)
teloxideteloxide_core::RequestErrorExternalApi, Unauthorized, RateLimited, Network, Deserialization, Internal
init-datainit_data_rs::InitDataErrorTelegramAuth
tonicmasterror::Error β†’ tonic::Statusoutbound mapping, see below
multipartaxum::extract::multipart::MultipartErrorBadRequest (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 sqlx error 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() β†’ Timeout
  • is_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 a config.phase metadata field (not_found, file_parse, type, …) identifying the failing stage.
  • tokio::time::error::Elapsed β†’ Timeout with a timeout.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::Error is classified via Error::classify(): I/O β†’ Serialization; syntax, data and EOF β†’ Deserialization.

teloxide

teloxide_core::RequestError mapping:

VariantAppErrorKind
ApiExternalApi (invalid token β†’ Unauthorized)
MigrateToChatIdExternalApi
RetryAfterRateLimited
NetworkNetwork
InvalidJsonDeserialization
IoInternal

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 a wasm_bindgen::JsValue
  • log_to_browser_console() β€” emit it via console.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:

TurnkeyErrorKindAppErrorKind
UniqueLabelConflict
RateLimitedRateLimited
TimeoutTimeout
AuthUnauthorized
NetworkNetwork
ServiceTurnkey

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

FeatureAdds
tracingStructured tracing event per error, trace_id via log-mdc
metricserror_total{code,category} counter via the metrics crate
backtraceLazy std::backtrace::Backtrace capture gated by RUST_BACKTRACE
coloredANSI-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:

FieldContent
codeAppCode string, e.g. NOT_FOUND
categoryAppErrorKind label, e.g. Database
messagePublic message, if any
retry_secondsRetry advice, if set
redactableWhether the message is redacted at transport boundaries
metadata_lenNumber of attached metadata fields
www_authenticateAuthentication challenge, if set
trace_idPulled 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.

FunctionStyleUse for
error_kind_criticalredcritical failure kinds
error_kind_warningyellowrecoverable/warning kinds
error_codecyanmachine codes
error_messagebright whitemain message
source_contextdimmedsecondary/source info
metadata_keygreenstructured 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:

AreaAvailable in no_std
Core typesError / AppError, AppResult, AppErrorKind, AppCode
MetadataMetadata, Field, FieldValue, FieldRedaction, field::* helpers
ContextContext, ResultExt::{ctx, context}
Control flowensure!, fail!
Derives#[derive(Error)], #[derive(Masterror)] with all attributes
Wire typesProblemJson, ErrorResponse, CODE_MAPPINGS, mapping_for_code
Introspectionchain(), root_cause(), is/downcast/downcast_ref/downcast_mut, render_message()
Serdeserde 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, colored
  • axum, actix, multipart, tonic, openapi
  • serde_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:

JobCommandVerifies
barecargo check --no-default-featurestrue no_std + alloc build
std-onlycargo check --features stddefault std surface
tracingcargo check --no-default-features --features tracingsingle telemetry feature builds standalone
metricscargo check --no-default-features --features metricssame for metrics
coloredcargo check --no-default-features --features coloredsame for colored
all-featurescargo check --all-featuresfull 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() (or redact(message) in #[masterror(...)]) hides detail from 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 (via ResultExt::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_ref lets 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 a redactable() error and check metadata().iter_with_redaction() policies.

Public message vs internal telemetry

A useful rule for every error you construct:

ChannelContents
message / detailHuman-oriented, non-sensitive, stable enough to show a user
Metadata fieldsIDs, attempts, endpoints, durations β€” for logs/metrics, with redaction policies
source chainRaw 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

thiserrormasterrorNotes
#[error("...")] with {field} placeholderssame1:1, including positional {0}
Format specs :>8, :.3, :x, :p, :esameTemplateFormatter mirrors thiserror’s formatter detection
#[error(transparent)]sameenforces single-field wrappers forwarding Display/source
#[from]samegenerates From<...>, validates wrapper shape
#[source]samewires the source() chain
#[backtrace]samehonoured 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

anyhowmasterrorNotes
anyhow::Result<T>masterror::AppResult<T>alias for Result<T, AppError>
anyhow::Errormasterror::AppError / Errorcarries 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_mutsame names on AppErrordowncasting parity
#[from]-style wrappingFrom<...> impls behind feature flagssqlx/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) and AppCode (public, SCREAMING_SNAKE_CASE) instead of stringly-typed context. See Error Kinds & Codes.
  • Transport mappings β€” RFC 7807 problem+json for Axum/Actix and tonic::Status for gRPC, derived from the same code table. See Web Frameworks.
  • Telemetry β€” automatic tracing events, error_total{code,category} metrics and lazy backtraces at the boundary. See Observability.
  • Redaction β€” redactable() messages and per-field Hash/Last4/Redact policies, 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::Error accepts any E: Error + Send + Sync. In masterror you choose a kind at wrap time (Context::new(kind) or a From conversion) β€” that decision point is the feature, not friction: it is where classification happens.
  • Both ensure! and fail! expand to return Err(...), so they work in any function returning Result<_, E> where your expression is already the error type β€” no Into conversion is inserted.

See also: Getting Started Β· Derive Macros Β· Context & Metadata Β· Best Practices