Small, pragmatic error model for API-heavy Rust services.
Core is framework-agnostic; integrations are opt-in via feature flags.
Stable categories, conservative HTTP mapping, no unsafe
.
- Core types:
AppError
,AppErrorKind
,AppResult
,AppCode
,ErrorResponse
- Optional Axum/Actix integration
- Optional OpenAPI schema (via
utoipa
) - Conversions from
sqlx
,reqwest
,redis
,validator
,config
,tokio
[dependencies]
masterror = { version = "0.3", default-features = false }
# or with features:
# masterror = { version = "0.3", features = [
# "axum", "actix", "serde_json", "openapi",
# "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide"
# ] }
Since v0.3.0: stable AppCode
enum and extended ErrorResponse
with retry/authentication metadata.
Why this crate?
- Stable taxonomy. Small set of
AppErrorKind
categories mapping conservatively to HTTP. - Framework-agnostic. No assumptions, no
unsafe
, MSRV pinned. - Opt-in integrations. Zero default features; you enable what you need.
- Clean wire contract.
ErrorResponse { status, code, message, details?, retry?, www_authenticate? }
. - One log at boundary. Log once with
tracing
. - Less boilerplate. Built-in conversions, compact prelude.
- Consistent workspace. Same error surface across crates.
Installation
[dependencies]
# lean core
masterror = { version = "0.3", default-features = false }
# with Axum/Actix + JSON + integrations
# masterror = { version = "0.3", features = [
# "axum", "actix", "serde_json", "openapi",
# "sqlx", "reqwest", "redis", "validator", "config", "tokio", "teloxide"
# ] }
MSRV: 1.89
No unsafe: forbidden by crate.
Quick start
Create an error:
use masterror::{AppError, AppErrorKind};
let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set");
assert!(matches!(err.kind, AppErrorKind::BadRequest));
With prelude:
use masterror::prelude::*;
fn do_work(flag: bool) -> AppResult<()> {
if !flag {
return Err(AppError::bad_request("Flag must be set"));
}
Ok(())
}
Error response payload
use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse};
use std::time::Duration;
let app_err = AppError::new(AppErrorKind::Unauthorized, "Token expired");
let resp: ErrorResponse = (&app_err).into()
.with_retry_after_duration(Duration::from_secs(30))
.with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#);
assert_eq!(resp.status, 401);
Web framework integrations
Axum
// features = ["axum", "serde_json"]
use masterror::{AppError, AppResult};
use axum::{routing::get, Router};
async fn handler() -> AppResult<&'static str> {
Err(AppError::forbidden("No access"))
}
let app = Router::new().route("/demo", get(handler));
Actix
// features = ["actix", "serde_json"]
use actix_web::{get, App, HttpServer, Responder};
use masterror::prelude::*;
#[get("/err")]
async fn err() -> AppResult<&'static str> {
Err(AppError::forbidden("No access"))
}
#[get("/payload")]
async fn payload() -> impl Responder {
ErrorResponse::new(422, AppCode::Validation, "Validation failed")
.expect("status")
}
OpenAPI
[dependencies]
masterror = { version = "0.3", features = ["openapi", "serde_json"] }
utoipa = "5"
Feature flags
axum
— IntoResponseactix
— ResponseError/Responderopenapi
— utoipa schemaserde_json
— JSON detailssqlx
,redis
,reqwest
,validator
,config
,tokio
,multipart
,teloxide
,telegram-webapp-sdk
turnkey
— domain taxonomy and conversions for Turnkey errors
Conversions
std::io::Error
→ InternalString
→ BadRequestsqlx::Error
→ NotFound/Databaseredis::RedisError
→ Cachereqwest::Error
→ Timeout/Network/ExternalApiaxum::extract::multipart::MultipartError
→ BadRequestvalidator::ValidationErrors
→ Validationconfig::ConfigError
→ Configtokio::time::error::Elapsed
→ Timeoutteloxide_core::RequestError
→ RateLimited/Network/ExternalApi/Deserialization/Internaltelegram_webapp_sdk::utils::validate_init_data::ValidationError
→ TelegramAuth
Typical setups
Minimal core:
masterror = { version = "0.3", default-features = false }
API (Axum + JSON + deps):
masterror = { version = "0.3", features = [
"axum", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
API (Actix + JSON + deps):
masterror = { version = "0.3", features = [
"actix", "serde_json", "openapi",
"sqlx", "reqwest", "redis", "validator", "config", "tokio"
] }
Turnkey
// features = ["turnkey"]
use masterror::turnkey::{classify_turnkey_error, TurnkeyError, TurnkeyErrorKind};
use masterror::{AppError, AppErrorKind};
// Classify a raw SDK/provider error
let kind = classify_turnkey_error("429 Too Many Requests");
assert!(matches!(kind, TurnkeyErrorKind::RateLimited));
// Wrap into AppError
let e = TurnkeyError::new(TurnkeyErrorKind::RateLimited, "throttled upstream");
let app: AppError = e.into();
assert_eq!(app.kind, AppErrorKind::RateLimited);
Migration 0.2 → 0.3
- Use
ErrorResponse::new(status, AppCode::..., "msg")
instead of legacy - New helpers:
.with_retry_after_secs
,.with_retry_after_duration
,.with_www_authenticate
ErrorResponse::new_legacy
is temporary shim
Versioning & MSRV
Semantic versioning. Breaking API/wire contract → major bump.
MSRV = 1.89 (may raise in minor, never in patch).
Non-goals
- Not a general-purpose error aggregator like
anyhow
- Not a replacement for your domain errors
License
Apache-2.0 OR MIT, at your option.