Skip to content

RAprogramm/masterror

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

751 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

masterror

Framework-agnostic application error types

Crates.io docs.rs Downloads MSRV License REUSE status codecov

CI Hits-of-Code IMIR

🇷🇺 Читайте README на русском языке 🇰🇷 한국어 README


Table of Contents


Overview

masterror grew from a handful of helpers into a workspace of composable crates for building consistent, observable error surfaces across Rust services. The core crate stays framework-agnostic, while feature flags light up transport adapters, integrations and telemetry without pulling in heavyweight defaults. No unsafe, MSRV is pinned, and the derive macros keep your domain types in charge of redaction and metadata.


Highlights

  • Unified taxonomy. AppError, AppErrorKind and AppCode model domain and transport concerns with conservative HTTP/gRPC mappings, turnkey retry/auth hints and RFC7807 output via ProblemJson.
  • Native derives. #[derive(Error)] and #[derive(Masterror)] wire custom types into the runtime types. #[derive(Masterror)] with #[masterror(...)] forwards sources, backtraces, telemetry fields and redaction policy; #[app_error] maps a derived error to an AppErrorKind/AppCode (optionally exposing its Display message) and #[provide] registers typed telemetry providers on the domain error itself.
  • Typed telemetry. Metadata stores structured key/value context (strings, integers, floats, durations, IP addresses and optional JSON) with per-field redaction controls and builders in field::*, so logs stay structured without manual String maps.
  • Transport adapters. Optional features expose Actix/Axum responders, tonic::Status conversions, WASM/browser logging and OpenAPI schema generation without contaminating the lean default build.
  • Battle-tested integrations. Enable focused mappings for sqlx, reqwest, redis, validator, config, tokio, teloxide, multipart, Telegram init-data validation and more — each translating library errors into the taxonomy with telemetry attached.
  • Turnkey defaults. The turnkey module ships a ready-to-use error catalog, a heuristic classifier and conservative mappings into the canonical taxonomy for teams that want a consistent baseline out of the box.
  • Boxed error interop. AppError::from_boxed adopts any Box<dyn Error + Send + Sync> without re-boxing and into_boxed_dyn_error converts back, matching anyhow's round-trip API while keeping the source chain intact and downcastable.
  • Typed control-flow macros. ensure! and fail! short-circuit functions with your domain errors without allocating or formatting on the happy path, while app_error! builds ad-hoc AppError values with format!-style messages in expression position.

Workspace Crates

Crate What it provides When to depend on it
masterror Core error types, metadata builders, transports, integrations and the prelude. Application crates, services and libraries that want a stable error surface.
masterror-derive Proc-macros backing #[derive(Error)], #[derive(Masterror)], #[app_error] and #[provide]. Brought in automatically via masterror; depend directly only for macro hacking.
masterror-template Shared template parser used by the derive macros for formatter analysis. Internal dependency; reuse when you need the template parser elsewhere.

Feature Flags

Pick only what you need; the default feature set is just std, everything else is opt-in.

  • Web transports: axum, actix, multipart, openapi, serde_json.
  • Telemetry & observability: tracing, metrics, backtrace, colored for colored terminal output.
  • Async & IO integrations: tokio, reqwest, sqlx, sqlx-migrate, redis, validator, config.
  • Messaging & bots: teloxide, init-data for Telegram Mini App init-data validation via init-data-rs.
  • Front-end tooling: frontend for WASM/browser console logging.
  • gRPC: tonic to emit tonic::Status responses.
  • Batteries included: turnkey to adopt the pre-built taxonomy and helpers.

The build script keeps the full feature snippet below in sync with Cargo.toml.


Installation

[dependencies]
masterror = { version = "0.29.0", default-features = false }
# or with features:
# masterror = { version = "0.29.0", features = [
#   "std", "axum", "actix", "openapi",
#   "serde_json", "tracing", "metrics", "backtrace",
#   "colored", "sqlx", "sqlx-migrate", "reqwest",
#   "redis", "validator", "config", "tokio",
#   "multipart", "teloxide", "init-data", "tonic",
#   "frontend", "turnkey", "benchmarks"
# ] }

Benchmarks

Criterion benchmarks cover the hottest conversion paths so regressions are visible before shipping. Run them locally with:

cargo bench -F benchmarks --bench error_paths

The suite emits two groups:

  • context_into_error/* promotes a dummy source error with representative metadata (strings, counters, durations, IPs) through ResultExt::ctx in both redacted and non-redacted modes.
  • problem_json_from_app_error/* consumes the resulting AppError values to build RFC 7807 payloads via ProblemJson::from_app_error, showing how message redaction and field policies impact serialization.

Adjust Criterion CLI flags (for example --sample-size 200 or --save-baseline local) after -- to trade throughput for tighter confidence intervals when investigating changes.


Code Coverage

codecov

Coverage reports are automatically generated on every CI run and uploaded to Codecov. The project maintains high test coverage across all modules to ensure reliability and catch regressions early.

Coverage Visualizations

Sunburst Graph

The inner-most circle represents the entire project, moving outward through folders to individual files. Size and color indicate statement count and coverage percentage.

Sunburst

Grid View

Each block represents a single file. Block size and color correspond to statement count and coverage percentage.

Grid

Icicle Chart

Hierarchical view starting with the entire project at the top, drilling down through folders to individual files. Size and color reflect statement count and coverage.

Icicle


Quick Start

Create an error

Create an error:

use masterror::{AppError, AppErrorKind, field};

let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set");
assert!(matches!(err.kind, AppErrorKind::BadRequest));
let err_with_meta = AppError::service("downstream")
    .with_field(field::str("request_id", "abc123"));
assert_eq!(err_with_meta.metadata().len(), 1);

let err_with_context = AppError::internal("db down")
    .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom"));
assert!(err_with_context.source_ref().is_some());

With prelude:

use masterror::prelude::*;

fn do_work(flag: bool) -> AppResult<()> {
    if !flag {
        return Err(AppError::bad_request("Flag must be set"));
    }
    Ok(())
}

Advanced Usage

Fail fast without sacrificing typing

ensure! and fail! provide typed alternatives to the formatting-heavy anyhow::ensure!/anyhow::bail! helpers. They evaluate the error expression only when the guard trips, so success paths stay allocation-free.

use masterror::{AppError, AppErrorKind, AppResult};

fn guard(flag: bool) -> AppResult<()> {
    masterror::ensure!(flag, AppError::bad_request("flag must be set"));
    Ok(())
}

fn bail() -> AppResult<()> {
    masterror::fail!(AppError::unauthorized("token expired"));
}

assert!(guard(true).is_ok());
assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest));
assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized));

app_error! is the anyhow::anyhow! counterpart: an expression macro that builds an AppError from a kind and an optional format!-style message with implicit capture. The kind-only form performs no allocation; the message form allocates exactly once.

use masterror::{AppErrorKind, AppResult, app_error};

fn find(id: u64) -> AppResult<u64> {
    None::<u64>.ok_or_else(|| app_error!(AppErrorKind::NotFound, "no entity {id}"))
}

let bare = app_error!(AppErrorKind::Timeout);
assert!(bare.message.is_none());
assert!(matches!(find(7).unwrap_err().kind, AppErrorKind::NotFound));
Derive domain errors and map them to transports

masterror ships native derives so your domain types stay expressive while the crate handles conversions, telemetry and redaction for you.

use std::io;

use masterror::Error;

#[derive(Debug, Error)]
#[error("I/O failed: {source}")]
pub struct DomainError {
    #[from]
    #[source]
    source: io::Error,
}

#[derive(Debug, Error)]
#[error(transparent)]
pub struct WrappedDomainError(
    #[from]
    #[source]
    DomainError
);

fn load() -> Result<(), DomainError> {
    Err(io::Error::other("disk offline").into())
}

let err = load().unwrap_err();
assert_eq!(err.to_string(), "I/O failed: disk offline");

let wrapped = WrappedDomainError::from(err);
assert_eq!(wrapped.to_string(), "I/O failed: disk offline");
  • use masterror::Error; brings the derive macro into scope.
  • #[from] automatically implements From<...> while ensuring wrapper shapes are valid.
  • #[error(transparent)] enforces single-field wrappers that forward Display/source to the inner error.
  • #[app_error(kind = AppErrorKind::..., code = AppCode::..., message)] maps the derived error into AppError/AppCode. The optional code = ... arm emits an AppCode conversion, while the message flag forwards the derived Display output as the public message instead of producing a bare error.
  • masterror::error::template::ErrorTemplate parses #[error("...")] strings, exposing literal and placeholder segments so custom derives can be implemented without relying on thiserror.
  • TemplateFormatter mirrors thiserror's formatter detection so existing derives that relied on hexadecimal, pointer or exponential renderers keep compiling.
  • Display placeholders preserve their raw format specs via TemplateFormatter::display_spec() and TemplateFormatter::format_fragment(), so derived code can forward :>8, :.3 and other display-only options without reconstructing the original string.
  • TemplateFormatterKind exposes the formatter trait requested by a placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant.
Attach telemetry, redaction policy and conversions

#[derive(Masterror)] wires a domain error into [masterror::Error], adds metadata, redaction policy and optional transport mappings. The accompanying #[masterror(...)] attribute mirrors the #[app_error] syntax while staying explicit about telemetry and redaction.

use masterror::{
    mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy
};

#[derive(Debug, Masterror)]
#[error("user {user_id} missing flag {flag}")]
#[masterror(
    code = AppCode::NotFound,
    category = AppErrorKind::NotFound,
    message,
    redact(message, fields("user_id" = hash)),
    telemetry(
        Some(masterror::field::str("user_id", user_id.clone())),
        attempt.map(|value| masterror::field::u64("attempt", value))
    ),
    map.grpc = 5,
    map.problem = "https://errors.example.com/not-found"
)]
struct MissingFlag {
    user_id: String,
    flag: &'static str,
    attempt: Option<u64>,
    #[source]
    source: Option<std::io::Error>
}

let err = MissingFlag {
    user_id: "alice".into(),
    flag: "beta",
    attempt: Some(2),
    source: None
};
let converted: Error = err.into();
assert_eq!(converted.code, AppCode::NotFound);
assert_eq!(converted.kind, AppErrorKind::NotFound);
assert_eq!(converted.edit_policy, MessageEditPolicy::Redact);
assert!(converted.metadata().get("user_id").is_some());

assert_eq!(
    MissingFlag::HTTP_MAPPING,
    HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound)
);
  • code / category pick the public [AppCode] and internal [AppErrorKind].
  • message forwards the formatted [Display] output as the safe public message. Omit it to keep the message private.
  • redact(message) flips [MessageEditPolicy] to redactable at the transport boundary, fields("name" = hash, "card" = last4) overrides metadata policies (hash, last4, redact, none).
  • telemetry(...) accepts expressions that evaluate to Option<masterror::Field>. Each populated field is inserted into the resulting [Metadata]; use telemetry() when no fields are attached.
  • map.grpc / map.problem capture optional gRPC status codes (as i32) and RFC 7807 type URIs. The derive emits tables such as MyError::HTTP_MAPPING, MyError::GRPC_MAPPING and MyError::PROBLEM_MAPPING (or slice variants for enums) for downstream integrations.

All familiar field-level attributes (#[from], #[source], #[backtrace]) are still honoured. Sources and backtraces are automatically attached to the generated [masterror::Error].

Structured telemetry providers and AppError mappings

#[provide(...)] exposes typed context through std::error::Request, while #[app_error(...)] records how your domain error translates into AppError and AppCode. The derive mirrors thiserror's syntax. The generated From conversions produce an AppError carrying the mapped kind and code (plus the Display output as public message when the message flag is set) and attach the original domain error as the source: it stays downcastable via downcast_ref, shows up in chain()/root_cause(), and its #[provide] data is forwarded through the AppError on toolchains with error_generic_member_access. Source attachment requires the domain error to be Send + Sync + 'static; add the no_source flag to #[app_error(...)] to opt out and drop the domain error during conversion instead.

request_ref/request_value and the std::error::Request machinery require a nightly toolchain (error_generic_member_access); the crate detects compiler support at build time and only enables provider integration when available.

use std::error::request_ref;

use masterror::{AppCode, AppError, AppErrorKind, Error};

#[derive(Clone, Debug, PartialEq, Eq)]
struct TelemetrySnapshot {
    name:  &'static str,
    value: u64,
}

#[derive(Debug, Error)]
#[error("structured telemetry {snapshot:?}")]
#[app_error(kind = AppErrorKind::Service, code = AppCode::Service)]
struct StructuredTelemetryError {
    #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)]
    snapshot: TelemetrySnapshot,
}

let err = StructuredTelemetryError {
    snapshot: TelemetrySnapshot {
        name: "db.query",
        value: 42,
    },
};

let snapshot = request_ref::<TelemetrySnapshot>(&err).expect("telemetry");
assert_eq!(snapshot.value, 42);

let app: AppError = err.into();
assert!(matches!(app.kind, AppErrorKind::Service));

Optional telemetry only surfaces when present, so None does not register a provider. Owned snapshots can still be provided as values when the caller requests ownership:

use masterror::{AppCode, AppErrorKind, Error};

#[derive(Debug, Error)]
#[error("optional telemetry {telemetry:?}")]
#[app_error(kind = AppErrorKind::Internal, code = AppCode::Internal)]
struct OptionalTelemetryError {
    #[provide(ref = TelemetrySnapshot, value = TelemetrySnapshot)]
    telemetry: Option<TelemetrySnapshot>,
}

let noisy = OptionalTelemetryError {
    telemetry: Some(TelemetrySnapshot {
        name: "queue.depth",
        value: 17,
    }),
};
let silent = OptionalTelemetryError { telemetry: None };

assert!(request_ref::<TelemetrySnapshot>(&noisy).is_some());
assert!(request_ref::<TelemetrySnapshot>(&silent).is_none());

Enums support per-variant telemetry and conversion metadata. Each variant chooses its own AppErrorKind/AppCode mapping while the derive generates a single From<Enum> implementation:

#[derive(Debug, Error)]
enum EnumTelemetryError {
    #[error("named {label}")]
    #[app_error(kind = AppErrorKind::NotFound, code = AppCode::NotFound)]
    Named {
        label:    &'static str,
        #[provide(ref = TelemetrySnapshot)]
        snapshot: TelemetrySnapshot,
    },
    #[error("optional tuple")]
    #[app_error(kind = AppErrorKind::Timeout, code = AppCode::Timeout)]
    Optional(#[provide(ref = TelemetrySnapshot)] Option<TelemetrySnapshot>),
    #[error("owned tuple")]
    #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)]
    Owned(#[provide(value = TelemetrySnapshot)] TelemetrySnapshot),
}

let owned = EnumTelemetryError::Owned(TelemetrySnapshot {
    name: "redis.latency",
    value: 3,
});
let app: AppError = owned.into();
assert!(matches!(app.kind, AppErrorKind::Service));

Compared to thiserror, you retain the familiar deriving surface while gaining structured telemetry (#[provide]) and first-class conversions into AppError/AppCode without manual glue.

Problem JSON payloads and retry/authentication hints
use masterror::{AppError, AppErrorKind, ProblemJson};

let problem = ProblemJson::from_app_error(
    AppError::new(AppErrorKind::Unauthorized, "Token expired")
        .with_retry_after_secs(30)
        .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#)
);

assert_eq!(problem.status, 401);
assert_eq!(problem.retry_after, Some(30));
assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");
Environment detection with DisplayMode

DisplayMode detects the deployment environment (Prod, Local or Staging) and drives the Display output of AppError. DisplayMode::current() resolves the mode in this order and caches the result on first access (the environment is read once per process):

  1. MASTERROR_ENV environment variable (prod/production, local/dev/development, or staging/stage)
  2. KUBERNETES_SERVICE_HOST presence (selects Prod)
  3. Build configuration (debug_assertionsLocal, release → Prod; the only detection step available without the std feature)
use masterror::DisplayMode;

let mode = DisplayMode::current();

match mode {
    DisplayMode::Prod => println!("Running in production mode"),
    DisplayMode::Local => println!("Running in local development mode"),
    DisplayMode::Staging => println!("Running in staging mode"),
}

Display for AppError dispatches on the detected mode:

Mode Layout
Local Multi-line human-readable report: kind, code, message, source chain, metadata
Prod Compact single-line JSON: kind, code, optional message, metadata
Staging Same JSON as Prod plus a source_chain array

All layouts apply per-field redaction policies: Redact renders the [REDACTED] placeholder, Hash renders a SHA-256 hex digest, Last4 keeps only the trailing characters (fields that cannot be masked are omitted). Set MASTERROR_ENV=local on any host to force the human-readable layout, e.g. when debugging inside Kubernetes.

In Local mode the output looks like:

Error: Not found
Code: NOT_FOUND
Message: User not found

Context:
  user_id: 12345
  request_id: abc-def

In Prod mode the same error renders as:

{"kind":"NotFound","code":"NOT_FOUND","message":"User not found","metadata":{"request_id":"abc-def","user_id":12345}}

Colored Terminal Output:

Enable the colored feature for ANSI styling with automatic TTY detection. It affects only the Local layout; Prod and Staging JSON never contains escape sequences:

[dependencies]
masterror = { version = "0.29.0", features = ["colored"] }

Examples

Comprehensive real-world examples demonstrating masterror integration with popular frameworks:

Example Description Features
axum-rest-api REST API with RFC 7807 Problem Details HTTP endpoints, domain errors, integration tests
sqlx-database Database error handling with SQLx Connection errors, constraint violations, transactions
custom-domain-errors Payment processing domain errors Derive macro, error conversion, structured errors
basic-async Async error handling with tokio Error propagation, timeout handling, Result types

All examples are runnable; the axum-rest-api example additionally ships integration tests. See the examples/ directory for complete source code and documentation.


Resources

  • Explore the error-handling wiki for step-by-step guides, comparisons with thiserror/anyhow, and troubleshooting recipes.
  • Browse the crate documentation on docs.rs for API details, feature-specific guides and transport tables.
  • Check CHANGELOG.md for release highlights and migration notes.
  • Review RustManifest for the development standards and best practices this project follows.

Metrics

Metrics


License

MSRV: 1.96 · License: MIT · No unsafe