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

Фреймворк-независимые типы ошибок для приложений со стабильными кодами, отображениями HTTP/gRPC и встроенной телеметрией

Русский English 한국어

Crates.io docs.rs MSRV License


Что такое masterror?

masterror — это рабочее пространство для обработки ошибок в сервисах на Rust, которым нужно больше, чем Display и source(). Там, где thiserror ограничивается генерацией реализаций трейтов, а anyhow — распространением ошибок со стёртым типом, masterror доводит ошибку до самой транспортной границы:

  • AppError — насыщенное значение ошибки с семантической категорией (AppErrorKind), стабильным машиночитаемым кодом (AppCode), опциональным безопасным публичным сообщением, структурированными метаданными и транспортными подсказками (Retry-After, WWW-Authenticate).
  • Консервативные отображения HTTP и gRPC — каждый вид и код детерминированно отображается в HTTP-статус, дискриминант tonic::Code и URI type по RFC 7807.
  • Типизированная телеметрия — метаданные хранятся как типизированные поля (строки, целые числа, числа с плавающей точкой, длительности, IP-адреса, UUID, JSON) с политиками редактирования для каждого поля, а не как самодельные String-карты.
  • Нативные derive-макросы#[derive(Error)] повторяет синтаксис thiserror, а #[app_error(...)] и #[derive(Masterror)] встраивают доменные ошибки в AppError с кодами, категориями, редактированием и таблицами отображений.
  • Редактирование по замыслу — источники никогда не сериализуются для клиентов; сообщения, детали и поля метаданных могут быть удалены, захешированы или замаскированы на границе.

Без unsafe, с зафиксированным MSRV и поддержкой no_std при отключённой функции std, включённой по умолчанию.

Какую проблему он решает

Задачаthiserroranyhowmasterror
Derive для Display / source()ДаДа (тот же синтаксис)
Распространение со стёртым типом и контекстомДаДа (.ctx() / .context())
Стабильные машиночитаемые коды ошибокВручнуюВручнуюAppCode, часть wire-контракта
Отображение в HTTP-статусВручнуюВручнуюAppErrorKind::http_status(), стабильная таблица
Отображение в gRPC-статусВручнуюВручнуюCODE_MAPPINGS, конверсия в tonic::Status
RFC 7807 problem+jsonВручнуюВручнуюProblemJson::from_app_error
Структурированные типизированные метаданныеMetadata + конструкторы field::*
Редактирование секретов на границеMessageEditPolicy, FieldRedaction
Эмиссия tracing / metrics / backtraceЗа флагами возможностей, автоматически при создании

Enum, произведённый thiserror, говорит, что случилось. masterror дополнительно решает, что увидит клиент (статус, код, безопасное сообщение, problem+json), что увидят операторы (структурированные поля, события tracing, счётчики) и что никогда не утечёт (источники, отредактированные поля).

Основные возможности

ОбластьЧто вы получаете
Базовая таксономияAppError, AppErrorKind (23 стабильные категории), AppCode (коды в SCREAMING_SNAKE_CASE, поддержка собственных кодов), AppResult<T>
Derive-макросы#[derive(Error)], #[derive(Masterror)], #[app_error(...)], #[masterror(...)], провайдеры телеметрии #[provide(...)]
Управление потокомensure! / fail! — типизированные ранние возвраты без выделения памяти
КонтекстResultExt::ctx / ResultExt::context, билдер Context с отслеживанием вызывающей стороны
Wire-полезные нагрузкиErrorResponse (устаревший JSON), ProblemJson (RFC 7807) с подсказками повторных попыток и аутентификации
ТранспортыAxum IntoResponse, Actix ResponseError/Responder, tonic::Status, WASM JsValue, схема OpenAPI
Интеграцииsqlx, redis, reqwest, validator, config, tokio, teloxide, init data Telegram Mini Apps, Turnkey
НаблюдаемостьСобытия tracing, счётчики metrics, ленивый захват backtrace, цветной вывод в терминале, DisplayMode (prod/staging/local)

Быстрый пример

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");

Или объявите доменную ошибку один раз и позвольте derive-макросу выполнить отображение:

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));

Крейты рабочего пространства

КрейтРоль
masterrorОсновные типы ошибок, метаданные, транспорты, интеграции, прелюдия
masterror-deriveПроцедурные макросы за #[derive(Error)] и #[derive(Masterror)] (подключается автоматически)
masterror-templateОбщий парсер шаблонов #[error("...")]

Документация

Начало работы

Основные концепции

Интеграции

Продвинутое

Начало работы

Эта страница проведёт вас через установку masterror, возврат первого AppError, ранние возвраты с ensure!/fail!, использование прелюдии и написание первого derive.

Установка

Сборка по умолчанию включает только функцию std — без веб-фреймворков и бэкендов телеметрии:

[dependencies]
masterror = "0.28"

Включайте интеграции по мере необходимости (полный список — в Флагах возможностей):

[dependencies]
masterror = { version = "0.28", features = ["axum", "serde_json", "tracing"] }

MSRV — 1.96. Крейт запрещает unsafe и поддерживает no_std при сборке с default-features = false.

Ваша первая ошибка

AppError объединяет семантическую категорию (AppErrorKind) с опциональным публичным сообщением. AppResult<T> — псевдоним для 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);

У каждого вида есть именованный конструктор, так что в местах вызова редко приходится писать AppErrorKind явно:

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

Прикрепляйте структурированные метаданные и вышестоящий источник, не отказываясь от типизации:

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());

Источник доступен для логов и обхода через chain(), но никогда не сериализуется для клиентов.

ensure! и fail!

ensure! и fail! — типизированные альтернативы anyhow::ensure!/anyhow::bail!. Выражение ошибки вычисляется лениво, поэтому на успешном пути нет ни форматирования, ни выделения памяти:

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! также принимает развёрнутую форму для сложных условий:

use masterror::{AppError, AppResult};

fn bounded(value: i32, max: i32) -> AppResult<()> {
    masterror::ensure!(
        cond = value <= max,
        else = AppError::service("value too large")
    );
    Ok(())
}

Прелюдия

masterror::prelude реэкспортирует только основные типы (AppError, AppErrorKind, AppCode, AppResult, ErrorResponse, а также хелперы turnkey, когда включена соответствующая функция):

use masterror::prelude::*;

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

Реализации трейтов фреймворков (Axum IntoResponse, Actix Responder) активируются флагами возможностей и не требуют дополнительных импортов.

Добавление контекста к внешним ошибкам

ResultExt превращает любой Result<T, E: Error> в 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);

Полный API Context описан в разделе Контекст и метаданные.

Ваш первый derive

#[derive(Error)] повторяет синтаксис thiserror, а #[app_error(...)] добавляет конверсию в 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("...")] задаёт шаблон Display с плейсхолдерами {field}.
  • #[from] генерирует From<std::io::Error> для обёртки.
  • #[source] пробрасывает внутреннюю ошибку через source().
  • #[app_error(kind = ..., code = ..., message)] генерирует From<DomainError> for AppErrorfor AppCode); флаг message делает вывод Display публичным сообщением.

Enum работают так же — с атрибутами #[error] и #[app_error] для каждого варианта. Когда вам также нужны метаданные, политика редактирования и таблицы отображений gRPC/problem+json, используйте #[derive(Masterror)] — он описан в разделе Derive-макросы.

Куда двигаться дальше


См. также: Флаги возможностей · Виды и коды ошибок · Derive-макросы · Контекст и метаданные · Миграция

Флаги возможностей

masterror держит сборку по умолчанию компактной: из коробки включена только функция std. Всё остальное — веб-транспорты, телеметрия, интеграции с библиотеками — подключается опционально. Эта страница — полный справочник по каждому флагу, объявленному в 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"
# ] }

Ядро

ФлагЧто включаетДоп. зависимости
std (по умолчанию)Поддержка стандартной библиотеки; требуется всем runtime-интеграциям. Отключите для no_std (см. Без std)

Веб-транспорты

ФлагЧто включаетДоп. зависимости
axumIntoResponse для AppError и ProblemJson с JSON-телами по RFC 7807; AppErrorKind::status_code()axum (json, multipart), serde_json
actixActix Web ResponseError для AppError и Responder для ProblemJsonactix-web
multipartОтображает axum::extract::multipart::MultipartErrorBadRequest (подразумевает axum)через axum
openapiutoipa::ToSchema для ErrorResponse и AppCode, чтобы полезные нагрузки ошибок попадали в спецификации OpenAPIutoipa
serde_jsonСтруктурированные JSON-details в AppError/ErrorResponse/ProblemJson; FieldValue::Json и field::jsonserde_json
tonicКонверсия ошибок в tonic::Status с санитизированными метаданными; экспортирует StatusConversionErrortonic

Телеметрия и наблюдаемость

ФлагЧто включаетДоп. зависимости
tracingСтруктурированные события tracing, эмитируемые при создании ошибокtracing, log, log-mdc
metricsИнкремент счётчика error_total{code,category} для каждого AppErrormetrics
backtraceЛенивый захват std::backtrace::Backtrace (учитывает RUST_BACKTRACE), билдер with_backtrace()
coloredЦветной многострочный вывод в терминале с автоматическим определением TTY; более насыщенный Display для AppErrorowo-colors

Асинхронные интеграции и ввод/вывод

Каждый флаг интеграции добавляет конверсию From<...> for AppError, классифицирующую ошибку библиотеки в рамках таксономии:

ФлагКонверсияДоп. зависимости
sqlxsqlx_core::ErrorNotFound / Database (компактный sqlx-core, без драйверов и TLS)sqlx-core
sqlx-migratesqlx::migrate::MigrateErrorDatabase (полный sqlx только с функцией migrate)sqlx
redisredis::RedisErrorCacheredis
reqwestreqwest::ErrorTimeout / Network / ExternalApireqwest
tokiotokio::time::error::ElapsedTimeouttokio (time)
validatorvalidator::ValidationErrorsValidationvalidator
configconfig::ConfigErrorConfigconfig

sqlx и sqlx-migrate разделены намеренно: для классификации ошибок достаточно sqlx-core, тогда как отображение ошибок миграций тянет полный крейт sqlx.

Обмен сообщениями и боты

ФлагКонверсияДоп. зависимости
teloxideteloxide_core::RequestErrorRateLimited / Network / ExternalApi / Deserialization / Internalteloxide-core
init-datainit_data_rs::InitDataErrorTelegramAuth (валидация init data Telegram Mini Apps)init-data-rs

Фронтенд и домен

ФлагЧто включаетДоп. зависимости
frontendМодуль frontend: конверсия ошибок в wasm_bindgen::JsValue и логирование через console.error в контекстах WASM/браузераwasm-bindgen, js-sys, serde-wasm-bindgen
turnkeyМодуль turnkey: TurnkeyErrorKind, TurnkeyError, classify_turnkey_error и конверсии в AppError
benchmarksНабор бенчмарков Criterion и инструментарий базовых линий CI (только для локального профилирования)

Базовые конверсии (доступны всегда)

Без каких-либо флагов возможностей AppError уже конвертируется из:

ИсточникЦелевой вид
std::io::ErrorInternal
StringBadRequest

Рецепты

REST API на Axum с problem+json, документацией OpenAPI и tracing:

masterror = { version = "0.28", features = ["axum", "openapi", "serde_json", "tracing"] }

Сервис с базой данных на sqlx, миграциями и метриками:

masterror = { version = "0.28", features = ["sqlx", "sqlx-migrate", "metrics", "tracing"] }

gRPC-сервис:

masterror = { version = "0.28", features = ["tonic", "tracing", "backtrace"] }

Telegram-бот с аутентификацией Mini App:

masterror = { version = "0.28", features = ["teloxide", "init-data", "reqwest", "tokio"] }

WASM-фронтенд:

masterror = { version = "0.28", features = ["frontend", "serde_json"] }

no_std для встраиваемых систем или библиотек:

masterror = { version = "0.28", default-features = false }

Примечания

  • Все флаги интеграций подразумевают std, кроме sqlx и sqlx-migrate, которые на уровне флага остаются независимыми от std.
  • axum и actix подтягивают serde_json транзитивно, потому что тела их ответов — JSON.
  • Флаги возможностей никогда не меняют wire-контракт уже включённых полей ErrorResponse/ProblemJson — они лишь добавляют возможности (например, serde_json превращает details из простого текста в структурированный JSON) или реализации трейтов.

См. также: Начало работы · Веб-фреймворки · Интеграции · Наблюдаемость · Без std

Виды и коды ошибок

Два типа образуют основу таксономии:

  • AppErrorKindвнутренняя семантическая категория сбоя. Небольшая, стабильная, независимая от фреймворков. Определяет HTTP-статус по умолчанию.
  • AppCodeпубличный машиночитаемый код, отдаваемый клиентам строкой в SCREAMING_SNAKE_CASE (например, "NOT_FOUND"). Часть wire-контракта.

Каждый AppError несёт оба. AppCode::from(kind) даёт каноническое отображение 1:1, а AppError::with_code(...) переопределяет публичный код, не меняя категорию.

Таксономия AppErrorKind

ВариантЗначениеHTTP
NotFoundРесурс не существует или не виден вызывающей стороне404
ValidationСтруктурированный ввод не прошёл валидацию422
ConflictКонфликт состояния (нарушение уникального ключа, несовпадение версий)409
UnauthorizedТребуется аутентификация или она не удалась401
ForbiddenАутентифицирован, но доступ запрещён403
NotImplementedОперация не поддерживается этим развёртыванием501
BadRequestНекорректный запрос или отсутствующие параметры400
TelegramAuthСбой процедуры аутентификации Telegram401
InvalidJwtJWT просрочен, повреждён или имеет неверную подпись/клеймы401
RateLimitedКлиент превысил лимиты запросов или квоту429
TimeoutОперация не завершилась вовремя504
NetworkОшибка сетевого уровня (DNS, соединение, TLS)503
DependencyUnavailableВнешняя зависимость недоступна или деградировала503
InternalНеожиданный сбой на стороне сервера500
DatabaseСбой базы данных (запрос, соединение, миграция)500
ServiceОбщий сбой сервисного слоя / бизнес-логики500
ConfigОтсутствующая или некорректная конфигурация500
TurnkeyСбой подсистемы Turnkey500
SerializationНе удалось закодировать данные500
DeserializationНе удалось декодировать данные500
ExternalApiВышестоящий API вернул ошибку500
QueueСбой публикации/потребления/подтверждения в очереди500
CacheСбой чтения/записи/кодирования кэша500
use masterror::AppErrorKind;

let kind = AppErrorKind::NotFound;
assert_eq!(kind.http_status(), 404);        // always available, u16
assert_eq!(kind.label(), "Not found");      // human-readable title
// With the `axum` feature: kind.status_code() -> axum::http::StatusCode

Правила, заложенные в отображение: проблемы инфраструктуры и ввода/вывода по умолчанию дают 5xx; Unauthorized (401) означает, что аутентификация не удалась, Forbidden (403) — что аутентификация прошла, но в доступе отказано; используйте Network для сбоев соединения/построения запроса и ExternalApi для ошибочных HTTP-статусов от вышестоящих сервисов.

AppCode

AppCode поставляет константы для каждого вида (AppCode::NotFound"NOT_FOUND", AppCode::RateLimited"RATE_LIMITED", …) плюс AppCode::UserAlreadyExists ("USER_ALREADY_EXISTS", отображается как конфликт). Он помечен #[non_exhaustive] и поддерживает пользовательские коды:

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);

Допустимые коды содержат только A-Z, 0-9 и одиночные разделители _ и сериализуются как обычные JSON-строки.

Таблица отображений HTTP / gRPC / problem+json

CODE_MAPPINGS (и функция поиска mapping_for_code) задают каноническое транспортное отображение для каждого встроенного кода. Неизвестные пользовательские коды сводятся к 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 совпадают с дискриминантами tonic::Code, поэтому функция tonic конвертирует их напрямую.

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");

Подсказки повторных попыток и аутентификации

Транспортные адаптеры транслируют две опциональные подсказки в HTTP-заголовки:

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");

У ErrorResponse эквивалентные билдеры — with_retry_after_secs, with_retry_after_duration и with_www_authenticate.

Семантика редактирования

Сообщения AppError задуманы безопасными для клиентов, но ошибку можно пометить как редактируемую, чтобы граница удалила сообщение:

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

Когда edit_policy равна Redact, ProblemJson отбрасывает detail, details и всю секцию metadata. Отдельные поля метаданных дополнительно несут собственную политику FieldRedaction (None, Redact, Hash, Last4), применяемую при сериализации — см. Контекст и метаданные. Источники ошибок (source_ref()) не сериализуются никогда, независимо от политики.

Wire-полезные нагрузки

ProblemJsonapplication/problem+json по RFC 7807, создаётся через ProblemJson::from_app_error (владеющий вариант) или ProblemJson::from_ref (заимствующий). Поля: type, title (метка вида), status, detail, опциональный details, code, grpc ({name, value}), metadata, плюс несериализуемые retry_after/www_authenticate для заголовков.

ErrorResponse — устаревшая плоская JSON-нагрузка: status, code, message, опциональные details, retry, www_authenticate. С функцией openapi реализует 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);

Для новых API предпочитайте ProblemJson; ErrorResponse остаётся для сервисов, уже привязанных к плоскому формату.


См. также: Начало работы · Derive-макросы · Контекст и метаданные · Веб-фреймворки

Derive-макросы

masterror поставляет два derive-макроса через прилагаемый крейт masterror-derive:

  • #[derive(Error)] — прямая замена thiserror::Error (те же атрибуты #[error], #[from], #[source], #[backtrace]), расширенная конверсиями #[app_error(...)] и телеметрией #[provide(...)].
  • #[derive(Masterror)] — строится на том же синтаксисе и встраивает доменную ошибку напрямую в masterror::Error с метаданными, политикой редактирования и таблицами транспортных отображений через #[masterror(...)].

Оба реэкспортируются из корня: use masterror::{Error, Masterror};.

Шаблоны #[error("...")]

Шаблон определяет генерируемую реализацию Display. Плейсхолдеры ссылаются на поля по имени ({field}), индексу кортежа ({0}) или через явные аргументы. Разбор выполняет общий крейт masterror-template, семантика повторяет thiserror.

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);

Трейты форматирования и спецификаторы

Плейсхолдеры поддерживают полный набор форматтеров — {x:?}, {x:#?}, {x:x}, {x:#X}, {x:b}, {x:o}, {x:e}, {x:E}, {x:p} — а display-спецификаторы вроде {value:>8} или {ratio:.3} пробрасываются как есть. Для программного анализа шаблонов masterror::error::template предоставляет ErrorTemplate, TemplateFormatter и TemplateFormatterKind.

Аргументы форматирования и проекции

Шаблоны принимают именованные и позиционные аргументы, включая выражения над self и проекции полей через сокращение .field:

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 и 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 требует ровно одно поле и не сочетается с fmt или строкой шаблона. fmt = path указывает на функцию, принимающую ссылки на все поля и Formatter последним аргументом.

Атрибуты полей

АтрибутЭффект
#[source]Поле возвращается из source(). Поддерживается Option<E>.
#[from]Генерирует From<FieldType> для обёртки; подразумевает #[source] на том же поле.
#[backtrace]Поле хранит std::backtrace::Backtrace (или Option<Backtrace>), доступный через интроспекцию ошибки, либо делегирует к бэктрейсу источника в сочетании с #[source].

Автовывод: поле с именем source автоматически считается источником, а поле типа std::backtrace::Backtrace (или Option<Backtrace>) распознаётся как бэктрейс без атрибута.

Enum принимают #[error] и #[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(...)] — конверсии в AppError

Описывает, как производная ошибка транслируется в AppError/AppCode. Опции: kind (обязательная), code (опциональная), message (флаг), no_source (флаг).

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 = ... выбирает AppErrorKind; генерирует From<T> for AppError.
  • code = ... дополнительно генерирует From<T> for AppCode.
  • message пробрасывает вывод Display как публичное сообщение; опустите его, чтобы сообщение осталось внутренним.
  • no_source отключает привязку источника и отбрасывает доменную ошибку при конверсии (для типов, не являющихся Send + Sync + 'static).

Сгенерированный From<T> for AppError прикрепляет исходную доменную ошибку как source: она остаётся доступной через downcast_ref, видна в chain()/root_cause(), а данные #[provide] пробрасываются через AppError на тулчейнах с error_generic_member_access. Для привязки источника требуется T: Send + Sync + 'static.

Enum выбирают отображение для каждого варианта, при этом derive всё равно генерирует единственную реализацию From<Enum> for AppError.

#[provide(...)] — типизированная телеметрия

Предоставляет типизированный контекст через std::error::Request (nightly error_generic_member_access; компилируется автоматически при доступности). Поля Option регистрируют провайдер, только когда заполнены:

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
}

Потребители извлекают снимок вызовом std::error::request_ref::<TelemetrySnapshot>(&err) на доменной ошибке или на сконвертированном AppError — конверсия пробрасывает провайдеры через прикреплённый source.

#[derive(Masterror)] — сквозные доменные ошибки

#[derive(Masterror)] генерирует Display, std::error::Error, From<T> for masterror::Error и таблицы транспортных отображений на этапе компиляции — всё конфигурируется одним атрибутом #[masterror(...)]:

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(...)]

ОпцияЗначение
code = AppCode::...Публичный машиночитаемый код
category = AppErrorKind::...Семантическая категория (определяет HTTP-статус)
messageСделать отформатированный вывод Display безопасным публичным сообщением
redact(message)Установить MessageEditPolicy::Redact, чтобы транспорты удаляли сообщение
redact(fields("name" = hash, "card" = last4))Переопределить политики метаданных для полей: hash, last4, redact, none
telemetry(expr, ...)Выражения, вычисляющиеся в Option<masterror::Field>; заполненные поля вставляются в Metadata. telemetry() — если полей нет
map.grpc = <i32>Код статуса gRPC (совпадает с дискриминантами tonic::Code)
map.problem = "<uri>"URI type по RFC 7807

Генерируемые таблицы отображений

Для структур derive порождает ассоциированные константы; для enum — массив и срезы, агрегирующие отображения по вариантам:

ФормаКонстанты
СтруктураT::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]

Типы-дескрипторы живут в masterror::mapping (HttpMapping::status() выводит HTTP-код из категории; GrpcMapping::status() возвращает i32; ProblemMapping::type_uri() возвращает URI).

#[from], #[source] и #[backtrace] продолжают работать под #[derive(Masterror)]; источники и захваченные бэктрейсы автоматически прикрепляются к результирующему masterror::Error, а источники, обёрнутые в Arc, переиспользуются без дополнительного клонирования.

Выбор между derive-макросами

ПотребностьИспользуйте
Display + source + From в стиле thiserror#[derive(Error)]
Плюс конверсия в AppError/AppCode#[derive(Error)] + #[app_error(...)]
Типизированный контекст через std::error::Requestдобавьте #[provide(...)]
Метаданные, политика редактирования, таблицы gRPC/problem+json#[derive(Masterror)] + #[masterror(...)]

См. также: Начало работы · Виды и коды ошибок · Контекст и метаданные · Миграция

Контекст и метаданные

masterror заменяет контекст, склеенный из строк (format!("failed to X: {e}")), тремя структурированными механизмами: билдером Context, типизированными полями Metadata и политиками редактирования, применяемыми на транспортной границе.

ResultExt: продвижение внешних ошибок

ResultExt реализован для каждого Result<T, E>, где E: Error + Send + Sync + 'static, и предлагает два метода:

.context(msg) — в стиле anyhow

Оборачивает ошибку сообщением; исходная ошибка становится источником:

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());

Если нижележащая ошибка уже является masterror::Error, .context() сохраняет её классификацию: вид, код, метаданные, политика редактирования, подсказка повторной попытки и детали переносятся, заменяется только сообщение, а исходная ошибка сохраняется как источник.

.ctx(|| Context) — полный контроль

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());

Замыкание вычисляется только на пути ошибки.

Билдер Context

МетодЭффект
Context::new(kind)Целевая категория; AppCode по умолчанию берётся из канонического отображения для этого вида
.code(AppCode)Переопределить публичный код
.category(kind)Сменить категорию; код остаётся синхронизированным, если не был переопределён
.with(field)Прикрепить Field метаданных
.redact(bool)Переключить редактирование сообщения (MessageEditPolicy::Redact / Preserve)
.redact_field(name, FieldRedaction)Переопределить политику редактирования именованного поля
.track_caller()Записать место вызова в метаданные caller.file, caller.line, caller.column

Поля метаданных

Metadata — отсортированная карта типизированных полей с inline-размещением (0–4 поля остаются на стеке). Создавайте поля через модуль masterror::field:

КонструкторВариант FieldValue
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 или v6)
field::json("key", json!({...}))Json (требует функции serde_json)

Прикрепляйте поля при создании ошибок или через 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) расширяет карту из итератора, with_metadata(meta) заменяет контейнер, а Metadata::insert возвращает прежнее значение при перезаписи ключа.

Политики редактирования

Политика сообщения: MessageEditPolicy

Preserve (по умолчанию) сохраняет публичное сообщение; Redact велит транспортам удалить его. Устанавливается через .redactable() на ошибке, .redact(true) на Context или redact(message) в #[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());

Политика поля: FieldRedaction

Каждое поле несёт собственную политику, применяемую при сериализации метаданных в ProblemJson:

ПолитикаЭффект на публичную нагрузку
NoneЗначение сохраняется как есть
RedactПоле удаляется целиком
HashЗначение заменяется дайджестом SHA-256
Last4Маскируется всё, кроме последних четырёх символов
use masterror::{AppError, FieldRedaction, field};

let err = AppError::internal("payment failed")
    .with_field(field::str("card_number", "4111111111111111"))
    .redact_field("card_number", FieldRedaction::Last4);

Имена, похожие на секреты, автоматически получают безопасное значение по умолчанию при создании поля: имена, содержащие password, secret, authorization, cookie, session, jwt, bearer, otp, pin, по умолчанию получают Redact; имена в духе токенов и ключей (api_token, refresh_token, key, apikey) — Hash; сегменты карт/счетов в сочетании с числовым сегментом (card_number, iban_no, account_id) — Last4. Распознавание регистронезависимо. Явный redact_field/with_redaction всегда имеет приоритет.

Цепочки ошибок

Ошибки сохраняют полную причинную цепочку. chain() итерирует от самой ошибки до первопричины; root_cause() сразу переходит к самой глубокой ошибке:

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(...) — предпочтительный способ прикрепить вышестоящую ошибку: он принимает владеемые ошибки или разделяемые значения Arc<dyn Error + Send + Sync> и переиспользует существующие аллокации. with_source(...) / with_source_arc(...) — их низкоуровневые эквиваленты.

Даункастинг

Проверяйте прикреплённый источник через is и 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, когда непосредственный источник имеет тип E (не обходит всю цепочку).
  • downcast_ref::<E>() — заимствует источник как E.
  • downcast::<E>() / downcast_mut::<E>() — пока заглушки (downcast всегда возвращает Err(self), downcast_mut всегда возвращает None), поэтому предпочитайте downcast_ref.

Для более глубоких проверок обходите chain() и вызывайте source.is::<E>() / source.downcast_ref::<E>() на каждом элементе.

Бэктрейсы

С функцией backtrace метод err.backtrace() возвращает лениво захваченный std::backtrace::Backtrace, учитывающий RUST_BACKTRACE, а with_backtrace(bt) прикрепляет явно захваченный. Бэктрейсы разделяются через Arc при переоборачивании ошибок, поэтому цепочки .context() не выполняют повторный захват.


См. также: Начало работы · Виды и коды ошибок · Derive-макросы · Наблюдаемость · Лучшие практики

Веб-фреймворки

masterror отображает ошибки на HTTP на транспортной границе. Доменный код возвращает AppResult<T>; адаптер фреймворка преобразует ошибку в ответ RFC 7807 application/problem+json, сбрасывает телеметрию и применяет редактирование. В крейте существует ровно одна реализация IntoResponse / ResponseError для AppError — вам никогда не придётся писать отображение вручную.

Флаги функций

ФлагЧто включает
axumIntoResponse для AppError, ProblemJson, ErrorResponse; подтягивает serde_json
actixResponseError для AppError; Responder для ProblemJson, ErrorResponse
multipartFrom<axum::extract::multipart::MultipartError> для Error (подразумевает axum)
openapiСхема utoipa для ErrorResponse
[dependencies]
masterror = { version = "0.28", features = ["axum"] }   # or ["actix"]

Формат на проводе

Оба адаптера сериализуют ProblemJson:

ПолеТипПримечания
typeстрока-URIКанонический класс проблемы, например https://errors.masterror.rs/not-found
titleстрокаКраткое описание, выводимое из AppErrorKind
statusчислоКод статуса HTTP
detailстрока?Публичное сообщение; опускается, когда ошибка редактируемая
detailsобъект?Структурированные детали (функция serde_json)
codeстрокаСтабильный машиночитаемый AppCode, например NOT_FOUND
grpcобъект?Отображение gRPC { name, value } для мультипротокольных клиентов
metadataобъект?Очищенные поля из Metadata; опускается при редактировании

Транспортные подсказки становятся заголовками, а не полями тела:

  • AppError::with_retry_after_secs(n)Retry-After: n
  • AppError::with_www_authenticate(challenge)WWW-Authenticate: challenge

Внутренние источники (цепочка std::error::Error) только логируются и никогда не сериализуются клиентам.

Axum

Функция axum реализует IntoResponse для AppError, ProblemJson и ErrorResponse, а также собственный метод AppError::http_status(), возвращающий axum::http::StatusCode, выводимый из вида ошибки. Преобразование в ответ сбрасывает телеметрию (событие tracing, счётчик метрик, ленивый бэктрейс) — см. Наблюдаемость.

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));

401 с подсказками:

use masterror::AppError;

let err = AppError::unauthorized("missing token")
    .with_retry_after_secs(7)
    .with_www_authenticate("Bearer realm=\"api\"");

выдаёт статус 401, заголовки Retry-After: 7 и WWW-Authenticate: Bearer realm="api", а также тело:

{
  "type": "https://errors.masterror.rs/unauthorized",
  "title": "Unauthorized",
  "status": 401,
  "detail": "missing token",
  "code": "UNAUTHORIZED",
  "grpc": { "name": "UNAUTHENTICATED", "value": 16 }
}

Доменные ошибки в обработчиках

Паттерн из examples/axum-rest-api: выведите доменный enum через derive, один раз преобразуйте его в AppError, а затем переиспользуйте 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()
    }
}

С #[app_error(kind = ..., code = ...)] на derive реализация From<UserError> for AppError генерируется за вас — см. Derive-макросы.

Actix Web

Функция actix реализует actix_web::ResponseError для AppError, поэтому обработчики, возвращающие AppResult<T>, работают из коробки. error_response() испускает телеметрию и строит ту же полезную нагрузку problem+json через 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
}

Клиент получает 403 с телом:

{
  "type": "https://errors.masterror.rs/forbidden",
  "title": "Forbidden",
  "status": 403,
  "detail": "no access",
  "code": "FORBIDDEN",
  "grpc": { "name": "PERMISSION_DENIED", "value": 7 }
}

ProblemJson и ErrorResponse также реализуют Responder, поэтому обработчик может возвращать их напрямую. Отображение статусов использует ту же стабильную таблицу AppErrorKind → StatusCode, что и Axum.

Multipart

multipart (подразумевает axum) преобразует axum::extract::multipart::MultipartError в Error с AppErrorKind::BadRequest, сохраняя сообщение парсера:

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(())
}

Некорректные клиентские полезные нагрузки проявляются как 400 Bad Request, а не как 500.

Ручное построение ответов

Для тестов или собственных транспортов полезную нагрузку можно построить без фреймворка:

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) заимствует вместо поглощения, а ProblemJson::from_error_response(resp) повышает устаревший тип на проводе ErrorResponse.

См. также: Виды и коды ошибок · Интеграции · Наблюдаемость · Флаги возможностей

Интеграции

Опциональные флаги функций добавляют конверсии From<...> из популярных сторонних типов ошибок в masterror::Error, так что один ? в месте вызова даёт классифицированную ошибку со структурированными метаданными. Каждая конверсия выбирает стабильный AppErrorKind и присоединяет телеметрические поля (никогда — секреты) для наблюдаемости.

Матрица конверсий

ФлагИсходный типРезультирующий AppErrorKind
sqlxsqlx_core::error::ErrorNotFound, Conflict, Validation, Timeout, DependencyUnavailable, Config, BadRequest, Serialization, Deserialization, Network, Database, Internal
sqlx-migratesqlx::migrate::MigrateErrorDatabase (с метаданными фазы миграции)
redisredis::RedisErrorCache (по умолчанию), Timeout, DependencyUnavailable
reqwestreqwest::ErrorTimeout, Network, RateLimited, DependencyUnavailable, ExternalApi
validatorvalidator::ValidationErrorsValidation
configconfig::ConfigErrorConfig (с метаданными config.phase)
tokiotokio::time::error::ElapsedTimeout
serde_jsonserde_json::ErrorSerialization (I/O), Deserialization (синтаксис/данные/EOF)
teloxideteloxide_core::RequestErrorExternalApi, Unauthorized, RateLimited, Network, Deserialization, Internal
init-datainit_data_rs::InitDataErrorTelegramAuth
tonicmasterror::Errortonic::Statusисходящее отображение, см. ниже
multipartaxum::extract::multipart::MultipartErrorBadRequest (см. Веб-фреймворки)

sqlx и sqlx-migrate

sqlx зависит только от sqlx-core (без драйверов, без TLS). Ключевые отображения:

  • Error::RowNotFoundNotFound
  • Таймаут пула → Timeout; закрытый пул и сбои I/O → DependencyUnavailable; ошибки TLS → Network
  • Нарушения ограничений классифицируются по виду ошибки sqlx: нарушения уникальности и внешних ключей → Conflict, нарушения not-null / check → Validation, всё остальное → Database
  • Кодирование → Serialization, декодирование → Deserialization

Ошибки базы данных сохраняют SQLSTATE и имена ограничений в метаданных. Известные коды SQLSTATE переопределяют публичный AppCode: 23505USER_ALREADY_EXISTS, 23503CONFLICT, 23502/23514VALIDATION. Транзиентные SQLSTATE (40001 — сбой сериализации, 55P03 — блокировка недоступна) присоединяют подсказки для повторных попыток.

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 подтягивает полный sqlx (всё ещё без функций по умолчанию) и отображает sqlx::migrate::MigrateError в Database, записывая фазу и версию миграции в метаданные.

redis

Все значения redis::RedisError по умолчанию отображаются в Cache; ошибки с характером таймаута становятся Timeout, а сбои соединения — DependencyUnavailable. Категория и код ошибки сохраняются в метаданных.

reqwest

Рассматривает reqwest как клиент внешнего HTTP API:

  • is_timeout()Timeout
  • is_connect() / is_request()Network
  • Ошибки HTTP-статусов: 429RateLimited, 408Timeout, 5xxDependencyUnavailable, остальные → ExternalApi
  • всё остальное → ExternalApi

Метаданные записывают конечную точку, статус и низкоуровневые флаги; URL помечается для хеширования/редактирования в публичных полезных нагрузках.

validator

validator::ValidationErrorsValidation, с агрегированным контекстом в метаданных: имена невалидных полей (validation.fields), количество полей и ошибок, а также первые коды валидации (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::ConfigErrorConfig, с полем метаданных config.phase (not_found, file_parse, type, …), идентифицирующим стадию сбоя.
  • tokio::time::error::ElapsedTimeout с полем метаданных timeout.source = "tokio::time::timeout". Ошибка не несёт собственного сообщения, поэтому клиенты видят фиксированный заголовок вида "Operation timed out".
  • serde_json::Error классифицируется через Error::classify(): I/O → Serialization; синтаксис, данные и EOF → Deserialization.

teloxide

Отображение teloxide_core::RequestError:

ВариантAppErrorKind
ApiExternalApi (невалидный токен → Unauthorized)
MigrateToChatIdExternalApi
RetryAfterRateLimited
NetworkNetwork
InvalidJsonDeserialization
IoInternal

init-data (Telegram Mini Apps)

Каждый вариант init_data_rs::InitDataError (отсутствующий/невалидный хеш, истёкшая полезная нагрузка, сбои подписи) отображается в TelegramAuth, отделяя сбои аутентификации Mini App от обычных некорректных запросов.

tonic (исходящий gRPC)

tonic конвертирует в обратном направлении: masterror::Errortonic::Status через From. AppCode отображается на канонический tonic::Code через ту же таблицу CODE_MAPPINGS, что используется для HTTP. Статус несёт записи метаданных app-code, app-http-status и app-problem-type, а также подсказки о повторных попытках и www-authenticate, если они заданы. У редактируемых ошибок сообщение заменяется меткой вида, а метаданные удаляются.

use masterror::AppError;
use tonic::{Code, Status};

let status = Status::from(AppError::not_found("missing"));
assert_eq!(status.code(), Code::NotFound);

frontend (WASM / браузер)

Флаг frontend добавляет trait masterror::frontend::BrowserConsoleExt для AppError и ErrorResponse на базе wasm-bindgen:

  • to_js_value() — сериализовать ошибку в wasm_bindgen::JsValue
  • log_to_browser_console() — вывести её через console.error

Оба метода работают на целевых платформах wasm32; на нативных платформах они возвращают BrowserConsoleError::UnsupportedTarget. Режимы сбоя (отсутствие консоли, невызываемый console.error, сбой сериализации) покрыты enum BrowserConsoleError.

use masterror::{AppError, frontend::BrowserConsoleExt};

let err = AppError::not_found("user not found");
err.log_to_browser_console()?;

turnkey

Флаг turnkey предоставляет небольшую стабильную доменную таксономию в masterror::turnkey:

TurnkeyErrorKindAppErrorKind
UniqueLabelConflict
RateLimitedRateLimited
TimeoutTimeout
AuthUnauthorized
NetworkNetwork
ServiceTurnkey

TurnkeyError::new(kind, msg) строит доменную ошибку; From<TurnkeyError> for AppError и From<TurnkeyErrorKind> for AppErrorKind выполняют отображение. classify_turnkey_error(&str) эвристически классифицирует сырое сообщение провайдера (без учёта регистра, с учётом границ слов) в 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);

См. также: Флаги возможностей · Веб-фреймворки · Виды и коды ошибок · Наблюдаемость

Наблюдаемость

masterror рассматривает телеметрию как часть жизненного цикла ошибки. Каждый AppError отслеживает флаг изменения; телеметрия испускается один раз на каждое изменение состояния — при конструировании, после мутации или когда ошибка пересекает транспортную границу (IntoResponse в Axum, error_response() в Actix, конверсия в Status для tonic). Вручную почти ничего вызывать не нужно.

Флаги функций

ФлагЧто добавляет
tracingСтруктурированное событие tracing на каждую ошибку, trace_id через log-mdc
metricsСчётчик error_total{code,category} через крейт metrics
backtraceЛенивый захват std::backtrace::Backtrace, управляемый RUST_BACKTRACE
coloredANSI-раскраска терминального вывода с детекцией TTY
[dependencies]
masterror = { version = "0.28", features = ["tracing", "metrics", "backtrace"] }

Tracing

С включённым tracing каждая ошибка испускает одно событие уровня ERROR с target masterror::error:

ПолеСодержимое
codeСтрока AppCode, например NOT_FOUND
categoryМетка AppErrorKind, например Database
messageПубличное сообщение, если есть
retry_secondsРекомендация о повторе, если задана
redactableРедактируется ли сообщение на транспортных границах
metadata_lenКоличество присоединённых полей метаданных
www_authenticateВызов аутентификации, если задан
trace_idБерётся из ключа trace_id контекста log-mdc, если присутствует

Испускание учитывает подписчика: если ни один подписчик не заинтересован в событиях уровня ERROR для этого target, событие остаётся в ожидании и повторяется при следующем сбросе, так что ничего не теряется при поздней установке подписчика.

Чтобы соотносить ошибки с запросами, сохраните trace ID в MDC в middleware запроса:

log_mdc::insert("trace_id", request_id);

Каждая ошибка, сконструированная пока ключ установлен, несёт его в событии.

Метрики

С включённым metrics каждая свежеизменённая ошибка инкрементирует:

error_total{code="NOT_FOUND", category="NotFound"}

Обе метки — стабильные строки (AppCode::as_str() и метка AppErrorKind), поэтому дашборды и алерты переживают рефакторинг ваших доменных типов. Подключайте любой recorder metrics (Prometheus, StatsD, …) как обычно; masterror использует только metrics::counter!.

Бэктрейсы

С включённым backtrace снимок Backtrace захватывается лениво в момент сброса телеметрии, а не при каждом конструировании. Захват управляется RUST_BACKTRACE: не задано, пустое, 0, off и false отключают его; любое другое значение включает. Настройка читается один раз и кэшируется на процесс.

#[cfg(feature = "backtrace")] {
use masterror::AppError;

let err = AppError::internal("db down");
if let Some(bt) = err.backtrace() {
    eprintln!("{bt}");
}
}

Можно также присоединить заранее захваченный трейс через AppError::with_backtrace(backtrace) — он имеет приоритет над ленивым захватом.

Ручной сброс через .log()

Конструкторы и конверсии испускают телеметрию автоматически. После мутации ошибки (добавление полей, изменение рекомендации о повторе) можно принудительно испустить её заново:

use masterror::{AppError, field};

let err = AppError::service("upstream degraded")
    .with_field(field::str("upstream", "billing"));
err.log();

log() идемпотентен в рамках состояния: если с последнего испускания ничего не изменилось, он ничего не делает. HTTP/gRPC-адаптеры делают такой же сброс на границе, поэтому ошибка, которая сконструирована, обогащена и затем возвращена из обработчика Axum, испускается один раз на состояние — один раз при конструировании и один раз на границе для обогащённого состояния — и никогда дважды для одного состояния.

Инспекция цепочки

Независимо от флагов, AppError предоставляет инструменты, нужные конвейерам логирования:

#[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() выдаёт тройки (key, value, policy), чтобы слой логирования мог соблюдать редактирование полей — см. Контекст и метаданные.

Цветной терминальный вывод

Флаг colored добавляет masterror::colored::style для CLI-инструментов. Цвета применяются только когда stderr — это TTY, NO_COLOR не задан, TERM не равен dumb и терминал поддерживает ANSI; иначе текст проходит без изменений. Результат детекции кэшируется на процесс.

ФункцияСтильДля чего
error_kind_criticalкрасныйкритические виды сбоев
error_kind_warningжёлтыйвосстановимые виды/предупреждения
error_codeголубоймашинные коды
error_messageярко-белыйосновное сообщение
source_contextприглушённыйвторичная информация об источнике
metadata_keyзелёныйимена структурированных полей
#[cfg(feature = "colored")] {
use masterror::colored::style;

eprintln!(
    "{}: {}",
    style::error_code("ERR_DB_001"),
    style::error_message("Database connection failed")
);
}

Запускаемое демо: examples/colored_cli.rs.

См. также: Флаги возможностей · Контекст и метаданные · Веб-фреймворки · Лучшие практики

Поддержка no_std

masterror собирается без стандартной библиотеки Rust. Корень крейта объявляет #![cfg_attr(not(feature = "std"), no_std)], и включённая по умолчанию функция std — единственное, что отделяет вас от сборки, дружелюбной к embedded/WASM:

[dependencies]
masterror = { version = "0.28", default-features = false }

alloc обязателен

masterror — это no_std, но не no_alloc. Крейт безусловно объявляет extern crate alloc и использует Cow<'static, str>, String, Arc и BTreeMap для сообщений, метаданных и цепочек источников. Вашей целевой платформе нужен глобальный аллокатор; окружения только с core не поддерживаются.

Что работает без std

Всё фреймворк-независимое ядро:

ОбластьДоступно в no_std
Основные типыError / AppError, AppResult, AppErrorKind, AppCode
МетаданныеMetadata, Field, FieldValue, FieldRedaction, хелперы field::*
КонтекстContext, ResultExt::{ctx, context}
Управление потокомensure!, fail!
Derive#[derive(Error)], #[derive(Masterror)] со всеми атрибутами
Типы на проводеProblemJson, ErrorResponse, CODE_MAPPINGS, mapping_for_code
Интроспекцияchain(), root_cause(), is/downcast/downcast_ref/downcast_mut, render_message()
Serdeserde с alloc (JSON-сериализация типов на проводе)

Источники ошибок работают через core::error::Error: крейт реализует и использует core::error::Error (внутренний псевдоним CoreError) вместо std::error::Error, поэтому with_source(...), цепочки источников и даункастинг полностью функциональны в сборках no_std.

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);

Что требует std

Каждая runtime-интеграция явно включает std обратно в определении своей функции. Из Cargo.toml:

  • tracing, metrics, backtrace, colored
  • axum, actix, multipart, tonic, openapi
  • serde_json, redis, validator, config, tokio, reqwest, teloxide, init-data, frontend, turnkey

backtrace нуждается в std::backtrace::Backtrace и доступе к окружению; colored — в детекции TTY; веб- и клиентским интеграциям нужны их хост-крейты, которые сами работают только со std.

Матрица функций в CI

CI-задача no_std (.github/workflows/ci.yml) проверяет эти комбинации на каждом pull request и push в main:

ЗадачаКомандаЧто проверяет
barecargo check --no-default-featuresнастоящую сборку no_std + alloc
std-onlycargo check --features stdстандартную поверхность std
tracingcargo check --no-default-features --features tracingчто одиночная телеметрическая функция собирается автономно
metricscargo check --no-default-features --features metricsто же для metrics
coloredcargo check --no-default-features --features coloredто же для colored
all-featurescargo check --all-featuresполное объединение функций

Обратите внимание на семантику: только задача bare — настоящая компиляция no_std. tracing = [..., "std"], metrics = [..., "std"] и colored = [..., "std"] транзитивно включают std обратно, поэтому эти задачи проверяют, что каждая телеметрическая функция самодостаточна при отключённых значениях по умолчанию — а не то, что телеметрия работает без стандартной библиотеки. Если нужна телеметрия — нужен std.

Практическая настройка

Библиотечные крейты, которые хотят оставаться транспортно-независимыми и совместимыми с no_std:

[dependencies]
masterror = { version = "0.28", default-features = false }

[features]
std = ["masterror/std"]

Бинарный или сервисный крейт затем включает std плюс нужные ему интеграции:

[dependencies]
masterror = { version = "0.28", features = ["axum", "tracing", "metrics"] }

Поскольку AppErrorKind, AppCode и типы на проводе живут в ядре no_std, доменные крейты могут классифицировать ошибки и даже строить полезные нагрузки ProblemJson, тогда как отображение на HTTP происходит только в сервисном крейте — см. Лучшие практики.

Тулчейн

Крейт нацелен на edition 2024 с rust-version = "1.96" в Cargo.toml. core::error::Error (основа цепочек источников в no_std) стабилен начиная с Rust 1.81, поэтому никакие nightly-функции не задействованы.

См. также: Флаги возможностей · Начало работы · Лучшие практики

Лучшие практики

Паттерны, которые делают сервисы на masterror предсказуемыми: типизированные доменные ошибки, одна стабильная таксономия кодов, отображение на транспорты на границе и публичные сообщения, которые никогда не раскрывают внутренности.

Выводите доменные ошибки через derive и отображайте их один раз

Моделируйте каждый ограниченный контекст как enum с #[derive(Error)] и объявляйте отображение на AppError прямо на месте через #[app_error(...)]. Derive генерирует Display, From<...> для обёрнутых источников и конверсию в AppError/AppCode — никакого рукописного match в каждом месте вызова.

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);

Указывайте message только на вариантах, чей вывод Display безопасно показывать клиентам. Опускайте его (как на Storage), чтобы текст остался внутренним — клиент увидит только заголовок вида. Полный справочник атрибутов: Derive-макросы.

Одна таксономия AppCode на сервис

AppCode — ваш публичный API-контракт; клиенты ветвятся по нему. Держите набор небольшим, документированным и отдельным для каждого сервиса:

  • Предпочитайте встроенные коды (NOT_FOUND, CONFLICT, VALIDATION, …) — они уже несут канонические отображения HTTP/gRPC/problem-type.
  • Заводите собственные коды централизованно, а не в местах вызова:
use masterror::AppCode;

pub const CODE_PLAN_LIMIT: AppCode = AppCode::new("PLAN_LIMIT_EXCEEDED");

AppCode::newconst и паникует на этапе компиляции на всём, что не SCREAMING_SNAKE_CASE; для строк времени выполнения используйте AppCode::try_new. Переименование кода — ломающее изменение API; относитесь к добавлениям как к добавлению варианта enum.

Отображайте на транспорты только на границе

Доменный и сервисный слои возвращают AppResult<T> и ничего не знают об HTTP. Единственная реализация IntoResponse/ResponseError/Status в крейте выполняет отображение в слое обработчиков:

async fn get_user(id: u64, repo: &Repo) -> masterror::AppResult<User> {
    repo.find(id).await?          // sqlx::Error -> AppError::NotFound/Database
}

Никогда не конструируйте коды статусов вручную в бизнес-логике и никогда не реализуйте вторую конверсию в ответ — стабильная таблица AppErrorKind → status в Веб-фреймворках — единственный источник истины.

Редактируйте чувствительные данные, сохраняйте телеметрию

Два независимых рычага:

  • Редактирование сообщенияerr.redactable() (или redact(message) в #[masterror(...)]) скрывает detail из полезных нагрузок на проводе, тогда как логи его сохраняют.
  • Редактирование полей — политика для каждого поля, применяемая при сериализации метаданных:
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 сохраняет корреляционную ценность (одинаковый вход → одинаковый дайджест), не раскрывая исходную строку; Last4 подходит для суффиксов карт/токенов; Redact удаляет значение полностью. По умолчанию — None. Редактируйте всё, что идентифицирует пользователя, по умолчанию и осознанно отказывайтесь от этого, а не наоборот.

Context против derive

  • Derive — когда тип ошибки часть вашего доменного словаря: у него есть варианты, он появляется в сигнатурах, а его отображение статично.
  • Context (через ResultExt::ctx) — когда инфраструктурная ошибка оборачивается ситуативно в месте вызова и классификация зависит от операции, а не от типа:
#[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 ленив — замыкание выполняется только на пути ошибки. Используйте простой .context("message"), когда достаточно человекочитаемой заметки. Подробности: Контекст и метаданные.

Тестируйте виды и коды, а не строки

Утверждайте стабильную таксономию, никогда — отформатированные сообщения:

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 позволяет интеграционным тестам проверять точный контракт на проводе без поднятия сервера.
  • mapping_for_code(&code) предоставляет канонический HTTP-статус, gRPC-код и URI problem-type для табличных тестов.
  • Для тестов редактирования утверждайте problem.detail.is_none() на ошибке с redactable() и проверяйте политики через metadata().iter_with_redaction().

Публичное сообщение против внутренней телеметрии

Полезное правило для каждой конструируемой ошибки:

КаналСодержимое
message / detailОриентировано на человека, без чувствительных данных, достаточно стабильно для показа пользователю
Поля MetadataID, попытки, конечные точки, длительности — для логов/метрик, с политиками редактирования
Цепочка sourceСырые нижележащие ошибки — логируются, никогда не сериализуются клиентам

masterror принудительно обеспечивает последнюю строку (источники никогда не записываются в полезные нагрузки на проводе), но первые две — ваша ответственность: если строка содержит что-то, что вы не напечатали бы в браузере, поместите это в метаданные с политикой редактирования или пометьте ошибку redactable().

См. также: Derive-макросы · Контекст и метаданные · Виды и коды ошибок · Миграция

Миграция

masterror спроектирован как прямая замена одновременно thiserror (синтаксис derive) и anyhow (эргономика). Большинство миграций — это замена зависимости плюс постепенное принятие типизированных возможностей. Запускаемые пошаговые примеры: examples/migrate_from_thiserror.rs и examples/migrate_from_anyhow.rs.

С thiserror

Шаг 1 механический — поменяйте импорт; синтаксис derive совместим:

-use thiserror::Error;
+use masterror::Error;

Совместимость атрибутов

thiserrormasterrorПримечания
#[error("...")] с плейсхолдерами {field}так же1:1, включая позиционные {0}
Спецификаторы формата :>8, :.3, :x, :p, :eтак жеTemplateFormatter повторяет детекцию форматтеров thiserror
#[error(transparent)]так жетребует обёрток с одним полем, пробрасывающих Display/source
#[from]так жегенерирует From<...>, проверяет форму обёртки
#[source]так жеподключает цепочку source()
#[backtrace]так жеучитывается на полях
#[app_error(kind = ..., code = ..., message)]добавлено: генерирует From<YourError> for AppErrorAppCode); message пробрасывает Display как публичное сообщение
#[provide(ref = T, value = T)]добавлено: типизированная телеметрия через std::error::Request; поля Option<T> предоставляют значение только при Some
#[derive(Masterror)] + #[masterror(...)]добавлено: полное отображение с category, redact(message, fields(...)), telemetry(...), map.grpc, map.problem

Существующие enum продолжают компилироваться без изменений. Что даёт их аннотирование:

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);

Enum отображаются повариантно — каждый вариант несёт собственный #[app_error(...)], а derive генерирует единственный From<Enum> for AppError.

Рекомендуемый порядок: (1) поменять импорт, (2) добавить #[app_error] типам, пересекающим границу API, (3) заменить рукописные реализации From<DomainError> for AppError сгенерированными, (4) принять #[masterror(...)] там, где нужны редактирование или метаданные.

С anyhow

anyhowmasterrorПримечания
anyhow::Result<T>masterror::AppResult<T>псевдоним для Result<T, AppError>
anyhow::Errormasterror::AppError / Errorнесёт вид, код и метаданные вместо «блоба»
.context("msg").context("msg")идентично, через masterror::ResultExt
.with_context(|| ...).ctx(|| Context::new(kind)...)ленив, как в anyhow, но строит типизированный Context (вид, код, поля, редактирование)
bail!(err)fail!(err)принимает выражение типизированной ошибки, без машинерии форматирования
ensure!(cond, "msg {x}")ensure!(cond, AppError::...)условие + типизированная ошибка; без форматирования на успешном пути
err.chain()err.chain()тот же итератор по цепочке источников
err.root_cause()err.root_cause()так же
err.is::<E>() / downcast / downcast_ref / downcast_mutте же имена на AppErrorпаритет по даункастингу
Обёртывание в стиле #[from]Реализации From<...> за флагами функцийsqlx/redis/reqwest/… приходят уже классифицированными

.context() работает ровно так, как вы ожидаете:

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! меняют строковое форматирование anyhow на типизированные ошибки:

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(())
}

Выражение ошибки вычисляется только при срабатывании условия, поэтому счастливый путь остаётся без выделений памяти — та же гарантия, что даёт anyhow, плюс машиночитаемый код.

Где у anyhow нет эквивалента

Миграция даёт возможности, у которых нет аналога в anyhow:

  • Типизированная таксономияAppErrorKind (внутренний) и AppCode (публичный, SCREAMING_SNAKE_CASE) вместо строкового контекста. См. Виды и коды ошибок.
  • Транспортные отображения — RFC 7807 problem+json для Axum/Actix и tonic::Status для gRPC, выводимые из одной таблицы кодов. См. Веб-фреймворки.
  • Телеметрия — автоматические события tracing, метрики error_total{code,category} и ленивые бэктрейсы на границе. См. Наблюдаемость.
  • Редактирование — сообщения redactable() и политики Hash/Last4/Redact для каждого поля, соблюдаемые каждым транспортом. См. Лучшие практики.
  • Структурированные метаданные — типизированные field::str/u64/duration/ip/... вместо форматирования значений в сообщение.

На что обратить внимание

  • У формы anyhow ensure!(cond, "format {}", x) с форматированным сообщением нет прямого двойника: конструируйте ошибку явно (AppError::bad_request(format!(...))) или, что лучше, используйте статическое сообщение плюс поля метаданных.
  • anyhow::Error принимает любой E: Error + Send + Sync. В masterror вы выбираете вид в момент оборачивания (Context::new(kind) или конверсия From) — эта точка принятия решения и есть возможность, а не трение: именно здесь происходит классификация.
  • И ensure!, и fail! разворачиваются в return Err(...), поэтому работают в любой функции, возвращающей Result<_, E>, где ваше выражение уже является типом ошибки — конверсия Into не вставляется.

См. также: Начало работы · Derive-макросы · Контекст и метаданные · Лучшие практики