- High-Level Design
- Module Structure
- Core Types
- Data Flow
- Extension Points
- Performance Characteristics
- Design Patterns
- Compile-Time Guarantees
This architecture adheres to the RustManifest principles, emphasizing clean code, zero-cost abstractions, comprehensive testing, and professional documentation standards.
masterror follows a layered architecture with clear separation between:
- Core layer: Framework-agnostic error types and metadata
- Conversion layer: Integration with third-party libraries
- Transport layer: HTTP, gRPC, and serialization adapters
- Derive layer: Procedural macros for ergonomic derivation
- Turnkey layer: Opinionated defaults for rapid adoption
┌─────────────────────────────────────────────────────────┐
│ Application Code │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Turnkey Layer (Optional) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Pre-built catalog, classifiers, helper functions │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Derive Layer │
│ ┌────────────────┬─────────────────┬─────────────┐ │
│ │ #[derive(Error)]│ #[derive(Master-│ #[provide] │ │
│ │ │ ror)] │ │ │
│ └────────────────┴─────────────────┴─────────────┘ │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Transport Layer │
│ ┌──────────┬───────────┬─────────┬──────────────┐ │
│ │ Axum │ Actix │ Tonic │ OpenAPI │ │
│ │ Responder│ Responder │ Status │ Schema Gen │ │
│ └──────────┴───────────┴─────────┴──────────────┘ │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Conversion Layer │
│ ┌───────┬────────┬───────┬────────┬─────────────┐ │
│ │ sqlx │reqwest │ redis │tokio │ validator │ │
│ │ │ │ │ │ ... │ │
│ └───────┴────────┴───────┴────────┴─────────────┘ │
└────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ Core Layer │
│ ┌──────────────────────────────────────────────────┐ │
│ │ AppError │ AppErrorKind │ AppCode │ Metadata │ │
│ └──────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
masterror/
├── masterror/ # Main crate (re-exports all layers)
├── masterror-derive/ # Procedural macros
└── masterror-template/ # Template parser (used by derive)
Dependency direction: masterror → masterror-derive → masterror-template
Derive macros expand to code that uses masterror public API, creating a build-time dependency cycle handled by Cargo's macro expansion phase.
Entry point, re-exports public API, manages feature flags.
Core error type implementation.
core.rs:AppErrorstruct definition andstd::error::Errorimplconstructors.rs: Convenience constructors (AppError::internal,::not_found, etc.)metadata.rs:MetadataandFieldtypes for structured contextcontext.rs:Contextbuilder for attaching metadata
AppErrorKind enum mapping to HTTP 4xx/5xx classes and internal categories.
pub enum AppErrorKind {
BadRequest, // 400
Unauthorized, // 401
Forbidden, // 403
NotFound, // 404
Conflict, // 409
Timeout, // 408
Service, // 502/503
Internal, // 500
// ...
}AppCode enum for fine-grained classification (100+ variants).
pub enum AppCode {
BadRequest,
InvalidFormat,
MissingField,
Unauthorized,
TokenExpired,
// ...
}Transport mapping definitions (HttpMapping, GrpcMapping, ProblemMapping).
Third-party error conversions via From trait impls.
sqlx.rs: Database errors →AppErrorKind::Conflict,::Service,::Internalreqwest.rs: HTTP client errors →AppErrorKind::Service,::Timeoutredis.rs: Redis errors →AppErrorKind::Service,::Internaltokio.rs: Async runtime errors →AppErrorKind::Internal,::Timeoutvalidator.rs: Validation errors →AppErrorKind::BadRequestconfig.rs: Configuration errors →AppErrorKind::Internalteloxide.rs: Telegram bot errors →AppErrorKind::Service,::BadRequestmultipart.rs: Multipart form errors →AppErrorKind::BadRequest
Each conversion:
- Maps error variant to
AppErrorKind - Preserves source error chain
- Attaches relevant telemetry (e.g., SQL constraint name, HTTP status code)
HTTP and serialization adapters.
core.rs:ErrorResponsetrait for framework-agnostic responsesproblem_json.rs: RFC 7807 Problem Details serializationaxum_impl.rs:impl IntoResponse for AppError(Axum)actix_impl.rs:impl ResponseError for AppError(Actix-web)mapping.rs: HTTP status code and gRPC status mappingsmetadata.rs: Metadata serialization with redaction
impl From<AppError> for tonic::Status with gRPC code mapping.
Extended Axum/Actix integrations beyond basic IntoResponse.
Located in masterror-derive/src/:
error_derive.rs:#[derive(Error)]implementationerror_trait.rs: Trait generation and method synthesismasterror_derive.rs:#[derive(Masterror)]with telemetry and redactionprovide_derive.rs:#[provide]forstd::error::Requestproviders
Opinionated defaults for rapid adoption.
domain.rs: Pre-built domain error typesclassifier.rs: Error classification heuristicsconversions.rs: Automatic conversions with telemetry
ensure! and fail! macros for control flow.
Commonly used types for glob imports.
ResultExt trait for ergonomic error context attachment.
WASM/browser compatibility.
browser_console_error.rs:console.error()integrationbrowser_console_ext.rs: Trait extensions for browser logging
pub struct AppError {
pub kind: AppErrorKind,
pub code: AppCode,
pub message: String,
pub edit_policy: MessageEditPolicy,
metadata: Metadata,
source: Option<Box<dyn Error + Send + Sync>>,
backtrace: Option<Backtrace>,
retry_after: Option<RetryAfter>,
www_authenticate: Option<String>,
}Invariants:
kindandcodemust be semantically consistent (enforced by constructors)sourcechain is immutable after constructionmetadatais append-only (fields can be added but not removed)messageis either user-facing or internal based onedit_policy
pub struct Metadata {
fields: Vec<Field>,
}
pub enum Field {
Str { key: &'static str, value: String, policy: RedactionPolicy },
I64 { key: &'static str, value: i64, policy: RedactionPolicy },
U64 { key: &'static str, value: u64, policy: RedactionPolicy },
F64 { key: &'static str, value: f64, policy: RedactionPolicy },
Duration { key: &'static str, value: Duration, policy: RedactionPolicy },
IpAddr { key: &'static str, value: IpAddr, policy: RedactionPolicy },
Json { key: &'static str, value: Value, policy: RedactionPolicy },
}Invariants:
- Keys are static strings (zero allocation overhead)
- Fields are ordered by insertion
- Redaction policy is immutable per field
- No duplicate keys (last insert wins)
Builder for attaching metadata:
pub struct Context {
kind: AppErrorKind,
code: AppCode,
message: String,
fields: Vec<Field>,
}Usage:
AppError::internal("db error")
.with_field(field::str("table", "users"))
.with_field(field::duration("query_time", elapsed))User Code
│
├─ AppError::new(kind, message)
│ │
│ └─> AppError { kind, code: default(kind), message, ... }
│
└─ AppError::internal(message).with_field(...)
│
└─> Context::new(kind, message)
│
└─> Context::with_field(field)
│
└─> Context::into_error()
│
└─> AppError { ..., metadata }
sqlx::Error
│
└─ From<sqlx::Error> for AppError
│
├─ match error_variant:
│ ├─ Database constraint → AppErrorKind::Conflict
│ ├─ Connection error → AppErrorKind::Service
│ └─ Query error → AppErrorKind::Internal
│
├─ Extract metadata (table, constraint, etc.)
│
└─> AppError {
kind: ...,
code: ...,
message: ...,
source: Some(sqlx_error),
metadata: [...]
}
AppError
│
└─ axum::IntoResponse::into_response()
│
├─ ProblemJson::from_app_error()
│ │
│ ├─ Map kind → HTTP status
│ ├─ Serialize metadata (apply redaction)
│ └─> RFC 7807 JSON payload
│
├─ Build headers
│ ├─ Content-Type: application/problem+json
│ ├─ Retry-After: ... (if set)
│ └─ WWW-Authenticate: ... (if set)
│
└─> axum::Response
#[derive(Error)]
#[error("db error: {source}")]
struct DbError {
#[source]
source: io::Error,
}
│ (proc macro expansion)
▼
impl fmt::Display for DbError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "db error: {}", self.source)
}
}
impl std::error::Error for DbError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
Implement From<CustomError> for AppError:
impl From<MyDomainError> for AppError {
fn from(err: MyDomainError) -> Self {
match err {
MyDomainError::NotFound(id) => {
AppError::not_found("resource missing")
.with_field(field::str("resource_id", id))
}
MyDomainError::InvalidInput(msg) => {
AppError::bad_request(msg)
}
}
}
}Define domain-specific field builders:
pub mod field {
pub fn user_id(value: impl Into<String>) -> Field {
Field::str_redacted("user_id", value, RedactionPolicy::Hash)
}
pub fn transaction_amount(cents: i64) -> Field {
Field::i64("transaction_cents", cents, RedactionPolicy::None)
}
}Override default mappings via derive attributes:
#[derive(Masterror)]
#[masterror(
category = AppErrorKind::Service,
map.grpc = 14, // UNAVAILABLE
map.problem = "https://api.example.com/errors/db-unavailable"
)]
struct DatabaseUnavailable;Implement RedactionPolicy trait:
impl RedactionPolicy {
pub fn custom_mask(&self, value: &str) -> String {
match self {
RedactionPolicy::Custom => mask_pii(value),
_ => self.apply_default(value),
}
}
}Implement std::error::Request providers:
#[derive(Error)]
#[error("telemetry snapshot")]
struct MyError {
#[provide(ref = TelemetrySnapshot)]
snapshot: TelemetrySnapshot,
}Zero-allocation paths:
- Error kind/code classification
- Static message errors (
AppError::internal("static")) - Metadata field key storage (uses
&'static str)
Single-allocation paths:
- Dynamic message errors (allocates
String) - Metadata field value storage (one allocation per field)
Multiple-allocation paths:
- Source error boxing (unavoidable for trait objects)
- Backtrace capture (when enabled)
- RFC 7807 JSON serialization
AppError::new(): O(1)with_field(): O(1) amortized (Vec::push)From<ThirdPartyError>: O(1) to O(k) where k = number of fields attachedProblemJson::from_app_error(): O(n) where n = number of metadata fields- HTTP response generation: O(n) for serialization
AppError: 120 bytes (on x86_64)
├─ kind: 1 byte (enum discriminant)
├─ code: 2 bytes (enum discriminant)
├─ message: 24 bytes (String)
├─ edit_policy: 1 byte (enum discriminant)
├─ metadata: 24 bytes (Vec<Field>)
├─ source: 16 bytes (Option<Box<...>>)
├─ backtrace: 16 bytes (Option<Backtrace>)
├─ retry_after: 16 bytes (Option<RetryAfter>)
└─ www_authenticate: 24 bytes (Option<String>)
Field: 40-48 bytes depending on variant (unoptimized enum layout).
Typical performance on modern x86_64 CPU:
- Error creation with metadata: ~50-100ns
- Context into_error conversion: ~80-150ns
- ProblemJson serialization: ~300-500ns
- Full HTTP response generation: ~800-1200ns
Regressions >10% from these baselines fail CI.
Context and AppError fluent APIs:
AppError::internal("db error")
.with_field(field::str("table", "users"))
.with_retry_after_duration(Duration::from_secs(30))Redaction policies encapsulate field masking strategies:
enum RedactionPolicy {
None,
Redact,
Hash,
Last4,
}Transport layers adapt AppError to framework-specific types:
impl IntoResponse for AppError {
fn into_response(self) -> Response {
// Adapt to Axum's Response type
}
}Context ensures errors are constructed correctly before conversion:
Context::new(kind, message) // Incomplete state
.with_field(field) // Still building
.into_error() // Transition to complete stateResultExt adds methods to standard Result:
pub trait ResultExt<T, E> {
fn with_context<F>(self, f: F) -> Result<T, AppError>
where
F: FnOnce(E) -> AppError;
}Procedural macros generate boilerplate implementations:
#[derive(Error)] // Generates Display, Error, From implsEnforced by:
[lints.rust]
unsafe_code = "forbid"- Error kinds are enum variants (no string matching)
- Metadata fields are strongly typed (no
HashMap<String, String>) - Source errors preserve type information via trait objects
All errors implement Send + Sync for concurrent usage:
impl Error for AppError + Send + Sync + 'staticEnforced by code review and testing. Only unreachable!() after exhaustive matches.
Feature combinations tested in CI via matrix:
features: [
["std"],
["std", "axum"],
["std", "actix"],
["std", "turnkey"],
["std", "axum", "sqlx", "tracing"],
]CI tests on MSRV (1.90) and stable to prevent accidental newer Rust usage.
Minimal setup (library use):
masterror = { version = "0.24", default-features = false }HTTP service:
masterror = { version = "0.24", features = ["std", "axum", "tracing"] }Full-stack service:
masterror = { version = "0.24", features = [
"std", "axum", "sqlx", "reqwest", "redis",
"tracing", "metrics", "backtrace"
] }Rapid prototype:
masterror = { version = "0.24", features = ["turnkey"] }Typical error overhead per request:
- CPU: 100-200ns error creation + 500-1000ns serialization
- Memory: 120 bytes base + 40 bytes per metadata field
- Allocations: 1-3 allocations per error (depending on metadata)
For high-throughput services (>100k req/s), consider:
- Reusing error instances via thread-local storage
- Limiting metadata fields to <5 per error
- Disabling backtrace capture in production
Tracing:
#[instrument(err)]
fn operation() -> Result<T, AppError> {
// Errors automatically logged with span context
}Metrics:
let counter = error_counter(err.kind, err.code);
counter.increment(1);- Async context propagation: Store metadata in tokio task-local storage
- OpenTelemetry native: Directly export errors as OTel events
- Error aggregation: Batch errors for distributed tracing
- Recovery strategies: Optional retry/fallback builders
- Core types: Stable, semver-compatible
- Transport adapters: Semver-minor for new adapters
- Derive macros: Syntax is stable, expansion may improve
- Turnkey module: May evolve with breaking changes (opt-in)
- Deprecated features remain for 2 minor versions
- Migration guides provided in CHANGELOG
- Compiler warnings guide users to replacements