From 817615ac27b92dbe086e415fe75b2f46abeb1e93 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 07:52:50 +0700 Subject: [PATCH 01/25] Expose error kind messages in responses --- CHANGELOG.md | 11 +++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 15 +++++++-------- README.ru.md | 4 ++-- src/response/mapping.rs | 4 ++-- src/response/tests.rs | 14 ++++++++++++-- 7 files changed, 36 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56d1f7d..ddcd51b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.11.2] - 2025-10-28 + +### Changed +- Surfaced the [`AppErrorKind`] display text as the fallback `ErrorResponse` + message so clients receive semantic descriptions without providing a custom + message. + +### Tests +- Added regression coverage ensuring bare `AppError` kinds map to their + corresponding default message. + ## [0.11.1] - 2025-10-27 ### Documentation diff --git a/Cargo.lock b/Cargo.lock index 907611e..5f1fd71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.11.1" +version = "0.11.2" dependencies = [ "actix-web", "axum", diff --git a/Cargo.toml b/Cargo.toml index beb989f..cb7a8ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.11.1" +version = "0.11.2" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 32b36a4..eb16e1b 100644 --- a/README.md +++ b/README.md @@ -36,9 +36,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.11.1", default-features = false } +masterror = { version = "0.11.2", default-features = false } # or with features: -# masterror = { version = "0.11.1", features = [ +# masterror = { version = "0.11.2", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -73,10 +73,10 @@ masterror = { version = "0.11.1", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.11.1", default-features = false } +masterror = { version = "0.11.2", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.11.1", features = [ +# masterror = { version = "0.11.2", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -632,13 +632,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.11.1", default-features = false } +masterror = { version = "0.11.2", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.11.1", features = [ +masterror = { version = "0.11.2", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -647,7 +647,7 @@ masterror = { version = "0.11.1", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.11.1", features = [ +masterror = { version = "0.11.2", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -718,4 +718,3 @@ MSRV = 1.90 (may raise in minor, never in patch). Apache-2.0 OR MIT, at your option. - diff --git a/README.ru.md b/README.ru.md index b846592..ca3c421 100644 --- a/README.ru.md +++ b/README.ru.md @@ -37,9 +37,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.11.1", default-features = false } +masterror = { version = "0.11.2", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.11.1", features = [ +# masterror = { version = "0.11.2", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", diff --git a/src/response/mapping.rs b/src/response/mapping.rs index 866b8c5..36eaed3 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -26,7 +26,7 @@ impl From for ErrorResponse { let code = AppCode::from(kind); let message = match message { Some(msg) => msg.into_owned(), - None => String::from("An error occurred") + None => kind.to_string() }; Self { @@ -48,7 +48,7 @@ impl From<&AppError> for ErrorResponse { let message = err .message .clone() - .unwrap_or(Cow::Borrowed("An error occurred")) + .unwrap_or_else(|| Cow::Owned(err.kind.to_string())) .into_owned(); Self { diff --git a/src/response/tests.rs b/src/response/tests.rs index 38e7be8..43fa5f0 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -144,7 +144,7 @@ fn from_app_error_uses_default_message_when_none() { let e: ErrorResponse = (&app).into(); assert_eq!(e.status, 500); assert!(matches!(e.code, AppCode::Internal)); - assert_eq!(e.message, "An error occurred"); + assert_eq!(e.message, AppErrorKind::Internal.to_string()); } #[test] @@ -168,7 +168,17 @@ fn from_owned_app_error_defaults_message_when_absent() { assert_eq!(resp.status, 500); assert!(matches!(resp.code, AppCode::Internal)); - assert_eq!(resp.message, "An error occurred"); + assert_eq!(resp.message, AppErrorKind::Internal.to_string()); +} + +#[test] +fn from_app_error_bare_uses_kind_display_as_message() { + let app = AppError::bare(AppErrorKind::Timeout); + let resp: ErrorResponse = app.into(); + + assert_eq!(resp.status, 504); + assert!(matches!(resp.code, AppCode::Timeout)); + assert_eq!(resp.message, AppErrorKind::Timeout.to_string()); } // --- Display formatting -------------------------------------------------- From 19dbce016abee3f4e49b8ed4059923e1ea0e3332 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 08:25:52 +0700 Subject: [PATCH 02/25] feat: add metadata container and richer app error --- CHANGELOG.md | 16 +++ Cargo.lock | 3 +- Cargo.toml | 5 +- README.md | 23 ++-- README.ru.md | 13 +- README.template.md | 9 +- src/app_error.rs | 7 +- src/app_error/constructors.rs | 9 +- src/app_error/core.rs | 203 ++++++++++++++++++++++----- src/app_error/metadata.rs | 253 ++++++++++++++++++++++++++++++++++ src/app_error/tests.rs | 66 ++++++++- src/lib.rs | 31 ++++- src/response/core.rs | 1 + src/response/mapping.rs | 22 +-- 14 files changed, 573 insertions(+), 88 deletions(-) create mode 100644 src/app_error/metadata.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ddcd51b..e4546e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.12.0] - 2025-10-29 + +### Added +- Introduced typed `Metadata` storage with `Field`/`FieldValue` builders and helper functions in `field::*`. +- Captured error sources and backtraces inside the new `app_error::Error` container, exposing `MessageEditPolicy` to control redaction. + +### Changed +- Replaced the legacy `AppError` struct with the richer `Error` model carrying `AppCode`, metadata, retry/auth hints and transport policy. +- Updated response mapping and constructors to preserve machine-readable codes without extra allocations. + +### Documentation +- Refreshed crate docs, README (EN/RU) and examples to highlight metadata helpers and the new error contract. + +### Tests +- Added regression coverage ensuring codes, metadata and sources survive conversions without unnecessary cloning. + ## [0.11.2] - 2025-10-28 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 5f1fd71..87dfc89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.11.2" +version = "0.12.0" dependencies = [ "actix-web", "axum", @@ -1630,6 +1630,7 @@ dependencies = [ "tracing", "trybuild", "utoipa", + "uuid", "validator", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index cb7a8ab..83bd271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.11.2" +version = "0.12.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -110,6 +110,9 @@ telegram-webapp-sdk = { version = "0.2", optional = true } wasm-bindgen = { version = "0.2", optional = true } js-sys = { version = "0.3", optional = true } serde-wasm-bindgen = { version = "0.6", optional = true } +uuid = { version = "1", default-features = false, features = [ + "std" +] } [dev-dependencies] serde_json = "1" diff --git a/README.md b/README.md index eb16e1b..2695548 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,12 @@ and typed telemetry. 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` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain mappings and structured telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) +- Structured metadata helpers via `field::*` builders - Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` - Turnkey domain taxonomy and helpers (`turnkey` feature) @@ -36,9 +37,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } # or with features: -# masterror = { version = "0.11.2", features = [ +# masterror = { version = "0.12.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -59,6 +60,7 @@ masterror = { version = "0.11.2", default-features = false } - **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? }`. +- **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` @@ -73,10 +75,10 @@ masterror = { version = "0.11.2", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.11.2", features = [ +# masterror = { version = "0.12.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -95,10 +97,13 @@ masterror = { version = "0.11.2", default-features = false } Create an error: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ With prelude: @@ -632,13 +637,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.11.2", features = [ +masterror = { version = "0.12.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -647,7 +652,7 @@ masterror = { version = "0.11.2", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.11.2", features = [ +masterror = { version = "0.12.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index ca3c421..54a1279 100644 --- a/README.ru.md +++ b/README.ru.md @@ -19,12 +19,13 @@ ## Основные возможности -- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`. +- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata`. - Деривы `#[derive(Error)]`, `#[app_error]`, `#[provide]` для типизированного телеметрического контекста и прямых конверсий доменных ошибок. - Адаптеры для Axum и Actix плюс логирование в браузер/`JsValue` для WASM (по фичам). - Генерация схем OpenAPI через `utoipa`. +- Структурированные поля `Metadata` через билдеры `field::*`. - Конверсии из распространённых библиотек (`sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` и др.). - Готовый прелюдия-модуль и расширение `turnkey` с собственной таксономией @@ -37,9 +38,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.11.2", default-features = false } +masterror = { version = "0.12.0", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.11.2", features = [ +# masterror = { version = "0.12.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -54,10 +55,13 @@ masterror = { version = "0.11.2", default-features = false } Создание ошибки вручную: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Флаг должен быть установлен"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ Использование прелюдии: @@ -82,6 +86,7 @@ fn do_work(flag: bool) -> AppResult<()> { - `validator` — преобразование `ValidationErrors` в валидационные ошибки API. - `config` — типизированные ошибки конфигурации. - `tokio` — маппинг таймаутов (`tokio::time::error::Elapsed`). +- `metadata` — типизированные поля `Metadata` без аллокаций строк. - `multipart` — обработка ошибок извлечения multipart в Axum. - `teloxide` — маппинг `teloxide_core::RequestError` в доменные категории. - `telegram-webapp-sdk` — обработка ошибок валидации данных Telegram WebApp. diff --git a/README.template.md b/README.template.md index 456a20b..3a757cc 100644 --- a/README.template.md +++ b/README.template.md @@ -19,11 +19,12 @@ and typed telemetry. 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` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain mappings and structured telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) +- Structured metadata helpers via `field::*` builders - Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` - Turnkey domain taxonomy and helpers (`turnkey` feature) @@ -56,6 +57,7 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } - **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? }`. +- **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` @@ -89,10 +91,13 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } Create an error: ~~~rust -use masterror::{AppError, AppErrorKind}; +use masterror::{AppError, AppErrorKind, field}; let err = AppError::new(AppErrorKind::BadRequest, "Flag must be set"); assert!(matches!(err.kind, AppErrorKind::BadRequest)); +let err_with_meta = AppError::service("downstream") + .with_field(field::str("request_id", "abc123")); +assert_eq!(err_with_meta.metadata().len(), 1); ~~~ With prelude: diff --git a/src/app_error.rs b/src/app_error.rs index d76600f..cc5e928 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -11,6 +11,8 @@ //! [`AppErrorKind`]. //! - **Optional message:** human-readable, safe-to-expose text. Do not put //! secrets here. +//! - **Structured metadata:** attach typed key/value pairs for diagnostics via +//! [`Metadata`]. //! - **No panics:** all helpers avoid `unwrap/expect`. //! - **Transport-agnostic:** mapping to HTTP lives in `kind.rs` and //! `convert/*`. @@ -59,8 +61,11 @@ mod constructors; mod core; +mod metadata; -pub use core::{AppError, AppResult}; +pub use core::{AppError, AppResult, Error, MessageEditPolicy}; + +pub use metadata::{Field, FieldValue, Metadata, field}; #[cfg(test)] mod tests; diff --git a/src/app_error/constructors.rs b/src/app_error/constructors.rs index c95d421..20f7521 100644 --- a/src/app_error/constructors.rs +++ b/src/app_error/constructors.rs @@ -62,12 +62,9 @@ impl AppError { /// assert!(err.message.is_none()); /// ``` pub fn database(msg: Option>) -> Self { - Self { - kind: AppErrorKind::Database, - message: msg, - retry: None, - www_authenticate: None - } + let mut err = Self::bare(AppErrorKind::Database); + err.message = msg; + err } /// Build a `Database` error with a message. diff --git a/src/app_error/core.rs b/src/app_error/core.rs index b6863a1..6b27f33 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,36 +1,74 @@ -use std::borrow::Cow; +use std::{ + backtrace::Backtrace, + borrow::Cow, + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult} +}; use tracing::error; -use crate::{Error, RetryAdvice, code::AppCode, kind::AppErrorKind}; +use super::metadata::{Field, Metadata}; +use crate::{AppCode, AppErrorKind, RetryAdvice}; -/// Thin error wrapper: kind + optional message. -/// -/// `Display` prints only the `kind`. The optional `message` is intended for -/// logs and (when appropriate) public JSON payloads. Keep messages concise and -/// free of sensitive data. -#[derive(Debug, Error)] -#[error("{kind}")] -pub struct AppError { - /// Semantic category of the error. +/// Controls whether the public message may be redacted before exposure. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MessageEditPolicy { + /// Message must be preserved as-is. + Preserve, + /// Message may be redacted or replaced at the transport boundary. + Redact +} + +impl Default for MessageEditPolicy { + fn default() -> Self { + Self::Preserve + } +} + +/// Rich application error preserving domain code, taxonomy and metadata. +#[derive(Debug)] +pub struct Error { + /// Stable machine-readable error code. + pub code: AppCode, + /// Semantic error category. pub kind: AppErrorKind, /// Optional, public-friendly message. pub message: Option>, + /// Structured metadata for telemetry. + pub metadata: Metadata, + /// Policy describing whether the message can be redacted. + pub edit_policy: MessageEditPolicy, /// Optional retry advice rendered as `Retry-After`. pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. - pub www_authenticate: Option + pub www_authenticate: Option, + source: Option>, + backtrace: Option +} + +impl Display for Error { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.kind, f) + } +} + +impl StdError for Error { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source + .as_ref() + .map(|source| &**source as &(dyn StdError + 'static)) + } } /// Conventional result alias for application code. /// -/// The alias defaults to [`AppError`] but accepts a custom error type when the +/// The alias defaults to [`Error`] but accepts a custom error type when the /// context requires a different domain error. /// /// # Examples /// /// ```rust -/// use std::io::Error; +/// use std::io::Error as IoError; /// /// use masterror::AppResult; /// @@ -38,20 +76,20 @@ pub struct AppError { /// Ok(7) /// } /// -/// fn io_logic() -> AppResult<(), Error> { +/// fn io_logic() -> AppResult<(), IoError> { /// Ok(()) /// } /// /// assert_eq!(app_logic().unwrap(), 7); /// assert!(io_logic().is_ok()); /// ``` -pub type AppResult = Result; +pub type AppResult = Result; -impl AppError { - /// Create a new [`AppError`] with a kind and message. +impl Error { + /// Create a new [`Error`] with a kind and message. /// - /// This is equivalent to [`AppError::with`], provided for API symmetry and - /// to keep doctests readable. + /// This is equivalent to [`Error::with`], provided for API symmetry and to + /// keep doctests readable. /// /// # Examples /// @@ -60,35 +98,55 @@ impl AppError { /// let err = AppError::new(AppErrorKind::BadRequest, "invalid payload"); /// assert!(err.message.is_some()); /// ``` + #[must_use] pub fn new(kind: AppErrorKind, msg: impl Into>) -> Self { Self::with(kind, msg) } /// Create an error with the given kind and message. /// - /// Prefer named helpers (e.g. [`AppError::not_found`]) where it clarifies + /// Prefer named helpers (e.g. [`Error::not_found`]) where it clarifies /// intent. + #[must_use] pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { Self { + code: AppCode::from(kind), kind, message: Some(msg.into()), + metadata: Metadata::new(), + edit_policy: MessageEditPolicy::Preserve, retry: None, - www_authenticate: None + www_authenticate: None, + source: None, + backtrace: None } } /// Create a message-less error with the given kind. /// /// Useful when the kind alone conveys sufficient information to the client. + #[must_use] pub fn bare(kind: AppErrorKind) -> Self { Self { + code: AppCode::from(kind), kind, message: None, + metadata: Metadata::new(), + edit_policy: MessageEditPolicy::Preserve, retry: None, - www_authenticate: None + www_authenticate: None, + source: None, + backtrace: None } } + /// Override the machine-readable [`AppCode`]. + #[must_use] + pub fn with_code(mut self, code: AppCode) -> Self { + self.code = code; + self + } + /// Attach retry advice to the error. /// /// When mapped to HTTP, this becomes the `Retry-After` header. @@ -107,24 +165,95 @@ impl AppError { self } + /// Attach additional metadata to the error. + #[must_use] + pub fn with_field(mut self, field: Field) -> Self { + self.metadata.insert(field); + self + } + + /// Extend metadata from an iterator of fields. + #[must_use] + pub fn with_fields(mut self, fields: impl IntoIterator) -> Self { + self.metadata.extend(fields); + self + } + + /// Replace metadata entirely. + #[must_use] + pub fn with_metadata(mut self, metadata: Metadata) -> Self { + self.metadata = metadata; + self + } + + /// Mark the message as redactable. + #[must_use] + pub fn redactable(mut self) -> Self { + self.edit_policy = MessageEditPolicy::Redact; + self + } + + /// Attach a source error for diagnostics. + #[must_use] + pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { + self.source = Some(Box::new(source)); + self + } + + /// Attach a captured backtrace. + #[must_use] + pub fn with_backtrace(mut self, backtrace: Backtrace) -> Self { + self.backtrace = Some(backtrace); + self + } + + /// Borrow the attached metadata. + #[must_use] + pub fn metadata(&self) -> &Metadata { + &self.metadata + } + + /// Borrow the backtrace if present. + #[must_use] + pub fn backtrace(&self) -> Option<&Backtrace> { + self.backtrace.as_ref() + } + + /// Borrow the source if present. + #[must_use] + pub fn source_ref(&self) -> Option<&(dyn StdError + Send + Sync + 'static)> { + self.source.as_deref() + } + + /// Human-readable message or the kind fallback. + #[must_use] + pub fn render_message(&self) -> Cow<'_, str> { + match &self.message { + Some(msg) => Cow::Borrowed(msg.as_ref()), + None => Cow::Owned(self.kind.to_string()) + } + } + /// Log the error once at the boundary with stable fields. /// - /// Emits a `tracing::error!` with `kind`, `code` and optional `message`. - /// No internals or sources are leaked. - /// - /// # Examples - /// - /// ```rust - /// use masterror::{AppError, AppErrorKind}; - /// let err = AppError::internal("boom"); - /// // In production, call this at the boundary (e.g. HTTP mapping). - /// err.log(); - /// ``` + /// Emits a `tracing::error!` with `kind`, `code`, optional `message` and + /// metadata length. No internals or sources are leaked. pub fn log(&self) { - let code = AppCode::from(self.kind); match &self.message { - Some(m) => error!(kind = ?self.kind, code = %code, message = %m), - None => error!(kind = ?self.kind, code = %code) + Some(m) => error!( + kind = ?self.kind, + code = %self.code, + message = %m, + metadata_len = self.metadata.len() + ), + None => error!( + kind = ?self.kind, + code = %self.code, + metadata_len = self.metadata.len() + ) } } } + +/// Backwards-compatible export using the historical name. +pub use Error as AppError; diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs new file mode 100644 index 0000000..754793b --- /dev/null +++ b/src/app_error/metadata.rs @@ -0,0 +1,253 @@ +use std::{ + borrow::Cow, + collections::BTreeMap, + fmt::{Display, Formatter, Result as FmtResult} +}; + +use uuid::Uuid; + +/// Value stored inside [`Metadata`]. +/// +/// The enum keeps the most common telemetry-friendly primitives without forcing +/// callers to allocate temporary strings. Strings use [`Cow`] so `'static` +/// literals avoid allocation while owned [`String`]s are supported when +/// necessary. +#[derive(Clone, Debug, PartialEq)] +pub enum FieldValue { + /// Human-readable string. + Str(Cow<'static, str>), + /// Signed 64-bit integer. + I64(i64), + /// Unsigned 64-bit integer. + U64(u64), + /// Boolean flag. + Bool(bool), + /// UUID represented with the canonical binary type. + Uuid(Uuid) +} + +impl Display for FieldValue { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + match self { + Self::Str(value) => Display::fmt(value, f), + Self::I64(value) => Display::fmt(value, f), + Self::U64(value) => Display::fmt(value, f), + Self::Bool(value) => Display::fmt(value, f), + Self::Uuid(value) => Display::fmt(value, f) + } + } +} + +/// Single metadata field – name plus value. +#[derive(Clone, Debug, PartialEq)] +pub struct Field { + name: &'static str, + value: FieldValue +} + +impl Field { + /// Create a new [`Field`]. + #[must_use] + pub const fn new(name: &'static str, value: FieldValue) -> Self { + Self { + name, + value + } + } + + /// Field name. + #[must_use] + pub const fn name(&self) -> &'static str { + self.name + } + + /// Field value. + #[must_use] + pub const fn value(&self) -> &FieldValue { + &self.value + } + + /// Consume the field and return owned components. + #[must_use] + pub fn into_parts(self) -> (&'static str, FieldValue) { + (self.name, self.value) + } +} + +/// Structured metadata attached to [`crate::AppError`]. +/// +/// Internally backed by a deterministic [`BTreeMap`] keyed by `'static` field +/// names. Use the helpers in [`field`] to build [`Field`] values without manual +/// enum construction. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Metadata { + fields: BTreeMap<&'static str, FieldValue> +} + +impl Metadata { + /// Create an empty metadata container. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Build metadata from an iterator of [`Field`] values. + #[must_use] + pub fn from_fields(fields: impl IntoIterator) -> Self { + let mut map = BTreeMap::new(); + for Field { + name, + value + } in fields + { + map.insert(name, value); + } + Self { + fields: map + } + } + + /// Number of fields stored in the metadata. + #[must_use] + pub fn len(&self) -> usize { + self.fields.len() + } + + /// Whether the metadata is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.fields.is_empty() + } + + /// Insert or replace a field and return the previous value. + pub fn insert(&mut self, field: Field) -> Option { + let (name, value) = field.into_parts(); + self.fields.insert(name, value) + } + + /// Extend metadata with additional fields. + pub fn extend(&mut self, fields: impl IntoIterator) { + for field in fields { + self.insert(field); + } + } + + /// Borrow a field value by name. + #[must_use] + pub fn get(&self, name: &'static str) -> Option<&FieldValue> { + self.fields.get(name) + } + + /// Iterator over metadata fields in sorted order. + pub fn iter(&self) -> impl Iterator { + self.fields.iter().map(|(k, v)| (*k, v)) + } +} + +impl IntoIterator for Metadata { + type Item = Field; + type IntoIter = std::iter::Map< + std::collections::btree_map::IntoIter<&'static str, FieldValue>, + fn((&'static str, FieldValue)) -> Field + >; + + fn into_iter(self) -> Self::IntoIter { + fn into_field(entry: (&'static str, FieldValue)) -> Field { + Field::new(entry.0, entry.1) + } + self.fields + .into_iter() + .map(into_field as fn((&'static str, FieldValue)) -> Field) + } +} + +/// Factories for [`Field`] values. +pub mod field { + use std::borrow::Cow; + + use uuid::Uuid; + + use super::{Field, FieldValue}; + + /// Build a string metadata field. + #[must_use] + pub fn str(name: &'static str, value: impl Into>) -> Field { + Field::new(name, FieldValue::Str(value.into())) + } + + /// Build an `i64` metadata field. + #[must_use] + pub fn i64(name: &'static str, value: i64) -> Field { + Field::new(name, FieldValue::I64(value)) + } + + /// Build a `u64` metadata field. + #[must_use] + pub fn u64(name: &'static str, value: u64) -> Field { + Field::new(name, FieldValue::U64(value)) + } + + /// Build a boolean metadata field. + #[must_use] + pub fn bool(name: &'static str, value: bool) -> Field { + Field::new(name, FieldValue::Bool(value)) + } + + /// Build a UUID metadata field. + #[must_use] + pub fn uuid(name: &'static str, value: Uuid) -> Field { + Field::new(name, FieldValue::Uuid(value)) + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use uuid::Uuid; + + use super::{FieldValue, Metadata, field}; + + #[test] + fn metadata_roundtrip() { + let mut meta = Metadata::new(); + meta.insert(field::str("request_id", Cow::Borrowed("abc"))); + meta.insert(field::i64("count", 42)); + + assert_eq!( + meta.get("request_id"), + Some(&FieldValue::Str(Cow::Borrowed("abc"))) + ); + assert_eq!(meta.get("count"), Some(&FieldValue::I64(42))); + } + + #[test] + fn metadata_from_fields_is_deterministic() { + let uuid = Uuid::nil(); + let meta = + Metadata::from_fields([field::uuid("trace_id", uuid), field::bool("cached", true)]); + let collected: Vec<_> = meta.iter().collect(); + assert_eq!(collected.len(), 2); + assert_eq!(collected[0].0, "cached"); + assert_eq!(collected[1].0, "trace_id"); + } + + #[test] + fn inserting_field_replaces_previous_value() { + let mut meta = Metadata::from_fields([field::i64("count", 1)]); + let replaced = meta.insert(field::i64("count", 2)); + assert_eq!(replaced, Some(FieldValue::I64(1))); + assert_eq!(meta.get("count"), Some(&FieldValue::I64(2))); + } + + #[test] + fn field_into_parts_returns_components() { + let field = field::u64("elapsed_ms", 30); + let clone = field.clone(); + assert_eq!(clone.name(), field.name()); + assert_eq!(clone.value(), field.value()); + let (owned_name, owned_value) = clone.into_parts(); + assert_eq!(owned_name, field.name()); + assert_eq!(owned_value, field.value().clone()); + } +} diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 7d4d6c3..b18ae4f 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,7 +1,7 @@ -use std::borrow::Cow; +use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; -use super::{AppResult, core::AppError}; -use crate::AppErrorKind; +use super::{AppError, FieldValue, MessageEditPolicy, field}; +use crate::{AppCode, AppErrorKind}; // --- Helpers ------------------------------------------------------------- @@ -137,6 +137,62 @@ fn retry_and_www_authenticate_are_attached() { assert_eq!(err.www_authenticate.as_deref(), Some("Bearer")); } +#[test] +fn render_message_does_not_allocate_for_borrowed_str() { + let err = AppError::new(AppErrorKind::BadRequest, "borrowed"); + let rendered = err.render_message(); + assert!(matches!(rendered, Cow::Borrowed("borrowed"))); + assert!(std::ptr::eq(rendered.as_ref(), "borrowed")); +} + +#[test] +fn metadata_and_code_are_preserved() { + let err = AppError::service("downstream") + .with_field(field::str("request_id", "abc-123")) + .with_field(field::i64("attempt", 2)) + .with_code(AppCode::Service); + + assert_eq!(err.code, AppCode::Service); + let metadata = err.metadata(); + assert_eq!(metadata.len(), 2); + assert_eq!( + metadata.get("request_id"), + Some(&FieldValue::Str(Cow::Borrowed("abc-123"))) + ); + assert_eq!(metadata.get("attempt"), Some(&FieldValue::I64(2))); +} + +#[derive(Debug)] +struct DummyError; + +impl Display for DummyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("dummy") + } +} + +impl StdError for DummyError {} + +#[test] +fn source_is_preserved_without_extra_allocation() { + let source = Arc::new(DummyError); + let err = AppError::internal("boom").with_source(source.clone()); + + assert_eq!(Arc::strong_count(&source), 2); + + let stored = err.source_ref().expect("source"); + let stored_arc = stored + .downcast_ref::>() + .expect("arc should be preserved"); + assert!(Arc::ptr_eq(stored_arc, &source)); +} + +#[test] +fn redactable_policy_is_exposed() { + let err = AppError::internal("boom").redactable(); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); +} + #[test] fn log_uses_kind_and_code() { // Smoke test to ensure the method is callable; tracing output isn't asserted @@ -147,11 +203,11 @@ fn log_uses_kind_and_code() { #[test] fn result_alias_is_generic() { - fn app() -> AppResult { + fn app() -> super::AppResult { Ok(1) } - fn other() -> AppResult { + fn other() -> super::AppResult { Ok(2) } diff --git a/src/lib.rs b/src/lib.rs index ce7fe29..8834eed 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,15 +7,19 @@ //! feature flags. //! //! Core types: -//! - [`AppError`] — thin wrapper around a semantic error kind and optional -//! message +//! - [`AppError`] — rich error capturing code, taxonomy, message, metadata and +//! transport hints //! - [`AppErrorKind`] — stable internal taxonomy of application errors //! - [`AppResult`] — convenience alias for returning [`AppError`] //! - [`ErrorResponse`] — stable wire-level JSON payload for HTTP APIs //! - [`AppCode`] — public, machine-readable error code for clients +//! - [`Metadata`] — structured telemetry attached to [`AppError`] +//! - [`field`] — helper functions to build [`Metadata`] without manual enums //! //! Key properties: //! - Stable, predictable error categories (`AppErrorKind`). +//! - Explicit, overridable machine-readable codes (`AppCode`). +//! - Structured metadata for observability without ad-hoc `String` maps. //! - Conservative and stable HTTP mappings. //! - Internal error sources are never serialized to clients (only logged). //! - Messages are safe to expose (human-oriented, non-sensitive). @@ -54,10 +58,11 @@ //! //! # Derive macros and telemetry //! -//! The [`masterror::Error`](crate::Error) derive mirrors `thiserror` while -//! adding `#[app_error]` and `#[provide]` attributes. Annotate your domain -//! errors once to surface structured telemetry via [`std::error::Request`] and -//! generate conversions into [`AppError`] / [`AppCode`]. +//! The [`masterror::Error`](derive@crate::Error) derive mirrors `thiserror` +//! while adding `#[app_error]` and `#[provide]` attributes. Annotate your +//! domain errors once to surface structured telemetry via +//! [`std::error::Request`] and generate conversions into [`AppError`] / +//! [`AppCode`]. //! //! ```rust //! use masterror::{AppCode, AppError, AppErrorKind, Error}; @@ -125,6 +130,16 @@ //! assert!(matches!(err.kind, AppErrorKind::BadRequest)); //! ``` //! +//! Attach structured metadata for telemetry and logging: +//! ```rust +//! use masterror::{AppError, AppErrorKind, field}; +//! +//! let err = AppError::service("downstream degraded") +//! .with_field(field::str("request_id", "abc123")) +//! .with_field(field::i64("attempt", 2)); +//! assert_eq!(err.metadata().len(), 2); +//! ``` +//! //! [`AppErrorKind`] controls the default HTTP status mapping. //! [`AppCode`] provides a stable machine-readable code for clients. //! Together, they form the wire contract in [`ErrorResponse`]. @@ -230,7 +245,9 @@ pub mod turnkey; /// Minimal prelude re-exporting core types for handler signatures. pub mod prelude; -pub use app_error::{AppError, AppResult}; +pub use app_error::{ + AppError, AppResult, Error, Field, FieldValue, MessageEditPolicy, Metadata, field +}; pub use code::AppCode; pub use kind::AppErrorKind; /// Re-export derive macros so users only depend on [`masterror`]. diff --git a/src/response/core.rs b/src/response/core.rs index f6bb5fe..ccd4768 100644 --- a/src/response/core.rs +++ b/src/response/core.rs @@ -62,6 +62,7 @@ impl ErrorResponse { /// # Errors /// /// Returns [`AppError`] if `status` is not a valid HTTP status code. + #[allow(clippy::result_large_err)] pub fn new(status: u16, code: AppCode, message: impl Into) -> AppResult { StatusCode::from_u16(status) .map_err(|_| AppError::bad_request(format!("invalid HTTP status: {status}")))?; diff --git a/src/response/mapping.rs b/src/response/mapping.rs index 36eaed3..e907581 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -1,10 +1,7 @@ -use std::{ - borrow::Cow, - fmt::{Display, Formatter, Result as FmtResult} -}; +use std::fmt::{Display, Formatter, Result as FmtResult}; use super::core::ErrorResponse; -use crate::{AppCode, AppError}; +use crate::AppError; impl Display for ErrorResponse { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { @@ -16,14 +13,15 @@ impl Display for ErrorResponse { impl From for ErrorResponse { fn from(err: AppError) -> Self { let AppError { + code, kind, message, retry, - www_authenticate + www_authenticate, + .. } = err; let status = kind.http_status(); - let code = AppCode::from(kind); let message = match message { Some(msg) => msg.into_owned(), None => kind.to_string() @@ -43,17 +41,11 @@ impl From for ErrorResponse { impl From<&AppError> for ErrorResponse { fn from(err: &AppError) -> Self { let status = err.kind.http_status(); - let code = AppCode::from(err.kind); - - let message = err - .message - .clone() - .unwrap_or_else(|| Cow::Owned(err.kind.to_string())) - .into_owned(); + let message = err.render_message().into_owned(); Self { status, - code, + code: err.code, message, details: None, retry: err.retry, From d3925f37e0eb17530df2a76fa70e5bf16ebb1b7c Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 09:15:59 +0700 Subject: [PATCH 03/25] Allow ResultExt large error lint --- CHANGELOG.md | 16 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++-- README.ru.md | 4 +- src/app_error.rs | 2 + src/app_error/context.rs | 141 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 +- src/result_ext.rs | 152 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 src/app_error/context.rs create mode 100644 src/result_ext.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index e4546e5..957b2cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.12.1] - 2025-10-30 + +### Added +- Introduced the `Context` builder for enriching error conversions with + metadata, caller tracking, and redaction hints via `ResultExt::ctx`. +- Implemented the `ResultExt` trait to wrap fallible operations into + `masterror::Error` without extra allocations while merging context fields. + +### Documentation +- Added rustdoc examples showcasing `Context` chaining and the new + `ResultExt` helper. + +### Tests +- Added unit coverage for `ResultExt::ctx`, ensuring happy-path results pass + through and error branches preserve metadata and sources. + ## [0.12.0] - 2025-10-29 ### Added diff --git a/Cargo.lock b/Cargo.lock index 87dfc89..2c79379 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.12.0" +version = "0.12.1" dependencies = [ "actix-web", "axum", diff --git a/Cargo.toml b/Cargo.toml index 83bd271..523c059 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.12.0" +version = "0.12.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 2695548..49416f2 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } # or with features: -# masterror = { version = "0.12.0", features = [ +# masterror = { version = "0.12.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -75,10 +75,10 @@ masterror = { version = "0.12.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.12.0", features = [ +# masterror = { version = "0.12.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -637,13 +637,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.12.0", features = [ +masterror = { version = "0.12.1", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -652,7 +652,7 @@ masterror = { version = "0.12.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.12.0", features = [ +masterror = { version = "0.12.1", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 54a1279..855fe37 100644 --- a/README.ru.md +++ b/README.ru.md @@ -38,9 +38,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.12.0", default-features = false } +masterror = { version = "0.12.1", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.12.0", features = [ +# masterror = { version = "0.12.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", diff --git a/src/app_error.rs b/src/app_error.rs index cc5e928..c34406c 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -60,11 +60,13 @@ //! transport boundary (e.g. in `IntoResponse`) to avoid duplicate logs. mod constructors; +mod context; mod core; mod metadata; pub use core::{AppError, AppResult, Error, MessageEditPolicy}; +pub use context::Context; pub use metadata::{Field, FieldValue, Metadata, field}; #[cfg(test)] diff --git a/src/app_error/context.rs b/src/app_error/context.rs new file mode 100644 index 0000000..61b1901 --- /dev/null +++ b/src/app_error/context.rs @@ -0,0 +1,141 @@ +use std::{error::Error as StdError, panic::Location}; + +use super::{ + core::{AppError, Error, MessageEditPolicy}, + metadata::{Field, FieldValue} +}; +use crate::{AppCode, AppErrorKind}; + +/// Builder describing how to convert an external error into [`AppError`]. +/// +/// The context captures the target [`AppCode`], [`AppErrorKind`], optional +/// metadata fields and redaction policy. It is primarily consumed by +/// [`ResultExt`](crate::ResultExt) when promoting `Result` values into +/// [`AppError`]. +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Error as IoError, ErrorKind}; +/// +/// use masterror::{AppErrorKind, Context, ResultExt, field}; +/// +/// fn failing_io() -> Result<(), IoError> { +/// Err(IoError::from(ErrorKind::Other)) +/// } +/// +/// let err = failing_io() +/// .ctx(|| { +/// Context::new(AppErrorKind::Service) +/// .with(field::str("operation", "sync")) +/// .redact(true) +/// .track_caller() +/// }) +/// .unwrap_err(); +/// +/// assert_eq!(err.kind, AppErrorKind::Service); +/// assert!(err.metadata().get("operation").is_some()); +/// ``` +#[derive(Debug, Clone)] +pub struct Context { + code: AppCode, + category: AppErrorKind, + fields: Vec, + edit_policy: MessageEditPolicy, + caller_location: Option<&'static Location<'static>>, + code_overridden: bool +} + +impl Context { + /// Create a new [`Context`] targeting the provided [`AppErrorKind`]. + /// + /// The initial [`AppCode`] defaults to the canonical mapping for the + /// supplied kind. Use [`Context::code`] to override it. + #[must_use] + pub fn new(category: AppErrorKind) -> Self { + Self { + code: AppCode::from(category), + category, + fields: Vec::new(), + edit_policy: MessageEditPolicy::Preserve, + caller_location: None, + code_overridden: false + } + } + + /// Override the public [`AppCode`]. + #[must_use] + pub fn code(mut self, code: AppCode) -> Self { + self.code = code; + self.code_overridden = true; + self + } + + /// Update the [`AppErrorKind`]. + /// + /// When the code has not been overridden explicitly, it is kept in sync + /// with the new kind. + #[must_use] + pub fn category(mut self, category: AppErrorKind) -> Self { + self.category = category; + if !self.code_overridden { + self.code = AppCode::from(category); + } + self + } + + /// Attach a metadata [`Field`]. + #[must_use] + pub fn with(mut self, field: Field) -> Self { + self.fields.push(field); + self + } + + /// Toggle message redaction policy. + #[must_use] + pub fn redact(mut self, redact: bool) -> Self { + self.edit_policy = if redact { + MessageEditPolicy::Redact + } else { + MessageEditPolicy::Preserve + }; + self + } + + /// Capture caller location and store it as metadata. + #[must_use] + #[track_caller] + pub fn track_caller(mut self) -> Self { + self.caller_location = Some(Location::caller()); + self + } + + pub(crate) fn into_error(mut self, source: E) -> Error + where + E: StdError + Send + Sync + 'static + { + if let Some(location) = self.caller_location { + self.fields.push(Field::new( + "caller.file", + FieldValue::Str(location.file().into()) + )); + self.fields.push(Field::new( + "caller.line", + FieldValue::U64(u64::from(location.line())) + )); + self.fields.push(Field::new( + "caller.column", + FieldValue::U64(u64::from(location.column())) + )); + } + + let mut error = AppError::bare(self.category).with_code(self.code); + if !self.fields.is_empty() { + error = error.with_fields(self.fields); + } + if matches!(self.edit_policy, MessageEditPolicy::Redact) { + error = error.redactable(); + } + error.with_source(source) + } +} diff --git a/src/lib.rs b/src/lib.rs index 8834eed..67dbcd2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -233,6 +233,7 @@ mod kind; #[doc(hidden)] pub mod provide; mod response; +mod result_ext; #[cfg(feature = "frontend")] #[cfg_attr(docsrs, doc(cfg(feature = "frontend")))] @@ -246,7 +247,7 @@ pub mod turnkey; pub mod prelude; pub use app_error::{ - AppError, AppResult, Error, Field, FieldValue, MessageEditPolicy, Metadata, field + AppError, AppResult, Context, Error, Field, FieldValue, MessageEditPolicy, Metadata, field }; pub use code::AppCode; pub use kind::AppErrorKind; @@ -278,3 +279,4 @@ pub use kind::AppErrorKind; /// ``` pub use masterror_derive::*; pub use response::{ErrorResponse, RetryAdvice}; +pub use result_ext::ResultExt; diff --git a/src/result_ext.rs b/src/result_ext.rs new file mode 100644 index 0000000..87f3a9f --- /dev/null +++ b/src/result_ext.rs @@ -0,0 +1,152 @@ +use std::error::Error as StdError; + +use crate::app_error::{Context, Error}; + +/// Extension trait for enriching `Result` errors with [`Context`]. +/// +/// The [`ctx`](ResultExt::ctx) method converts the error side of a `Result` +/// into [`Error`] while attaching metadata, category and edit policy captured +/// by [`Context`]. +/// +/// # Examples +/// +/// ```rust +/// use std::io::{Error as IoError, ErrorKind}; +/// +/// use masterror::{AppErrorKind, Context, ResultExt, field}; +/// +/// fn validate() -> Result<(), IoError> { +/// Err(IoError::from(ErrorKind::Other)) +/// } +/// +/// let err = validate() +/// .ctx(|| Context::new(AppErrorKind::Validation).with(field::str("phase", "validate"))) +/// .unwrap_err(); +/// +/// assert_eq!(err.kind, AppErrorKind::Validation); +/// assert!(err.metadata().get("phase").is_some()); +/// ``` +pub trait ResultExt { + /// Convert an error into [`Error`] using [`Context`] supplied by `build`. + #[allow(clippy::result_large_err)] + fn ctx(self, build: impl FnOnce() -> Context) -> Result + where + E: StdError + Send + Sync + 'static; +} + +impl ResultExt for Result { + fn ctx(self, build: impl FnOnce() -> Context) -> Result + where + E: StdError + Send + Sync + 'static + { + self.map_err(|err| build().into_error(err)) + } +} + +#[cfg(test)] +mod tests { + use std::{ + borrow::Cow, + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult}, + sync::Arc + }; + + use super::ResultExt; + use crate::{ + AppCode, AppErrorKind, + app_error::{Context, FieldValue, MessageEditPolicy}, + field + }; + + #[derive(Debug)] + struct DummyError; + + impl Display for DummyError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.write_str("dummy") + } + } + + impl StdError for DummyError {} + + #[test] + fn ctx_preserves_ok() { + let res: Result = Ok(5); + let value = res + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect("ok"); + assert_eq!(value, 5); + } + + #[test] + fn ctx_wraps_err_with_context() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result + .ctx(|| { + Context::new(AppErrorKind::Service) + .with(field::str("operation", "sync")) + .redact(true) + .track_caller() + }) + .expect_err("err"); + + assert_eq!(err.kind, AppErrorKind::Service); + assert_eq!(err.code, AppCode::Service); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); + + let metadata = err.metadata(); + assert_eq!( + metadata.get("operation"), + Some(&FieldValue::Str(Cow::Borrowed("sync"))) + ); + let caller_file = metadata.get("caller.file").expect("caller file field"); + assert_eq!(caller_file, &FieldValue::Str(Cow::Borrowed(file!()))); + assert!(metadata.get("caller.line").is_some()); + assert!(metadata.get("caller.column").is_some()); + } + + #[derive(Debug, Clone)] + struct SharedError(Arc); + + #[derive(Debug)] + struct InnerError; + + impl Display for InnerError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + f.write_str("inner") + } + } + + impl StdError for InnerError {} + + impl Display for SharedError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&*self.0, f) + } + } + + impl StdError for SharedError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&*self.0) + } + } + + #[test] + fn ctx_preserves_source_without_extra_arc_clone() { + let inner = Arc::new(InnerError); + let shared = SharedError(inner.clone()); + let err = Result::<(), SharedError>::Err(shared.clone()) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + + drop(shared); + assert_eq!(Arc::strong_count(&inner), 2); + + let stored = err + .source_ref() + .and_then(|src| src.downcast_ref::()) + .expect("shared source"); + assert!(Arc::ptr_eq(&stored.0, &inner)); + } +} From f9b3f062d21e7d2fdc12e1c09b9e5cd8ff3cbfb3 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 10:12:51 +0700 Subject: [PATCH 04/25] Add Masterror derive attribute support --- CHANGELOG.md | 22 + Cargo.lock | 4 +- Cargo.toml | 6 +- README.md | 90 +++- README.ru.md | 76 ++- README.template.md | 76 ++- masterror-derive/Cargo.toml | 2 +- masterror-derive/src/input.rs | 273 +++++++++- masterror-derive/src/lib.rs | 28 + masterror-derive/src/masterror_impl.rs | 508 ++++++++++++++++++ src/app_error/core.rs | 9 +- src/lib.rs | 76 ++- src/mapping.rs | 129 +++++ tests/error_derive_from_trybuild.rs | 12 + tests/masterror_macro.rs | 157 ++++++ .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.rs | 9 + tests/ui/masterror/fail/duplicate_attr.stderr | 13 + .../ui/masterror/fail/duplicate_telemetry.rs | 13 + .../masterror/fail/duplicate_telemetry.stderr | 13 + tests/ui/masterror/fail/empty_redact.rs | 8 + tests/ui/masterror/fail/empty_redact.stderr | 13 + .../ui/masterror/fail/enum_missing_variant.rs | 12 + .../fail/enum_missing_variant.stderr | 14 + tests/ui/masterror/fail/missing_attr.rs | 7 + tests/ui/masterror/fail/missing_attr.stderr | 5 + tests/ui/masterror/fail/missing_category.rs | 8 + .../ui/masterror/fail/missing_category.stderr | 13 + tests/ui/masterror/fail/missing_code.rs | 8 + tests/ui/masterror/fail/missing_code.stderr | 13 + tests/ui/masterror/fail/unknown_option.rs | 8 + tests/ui/masterror/fail/unknown_option.stderr | 13 + tests/ui/masterror/pass/struct.rs | 19 + 41 files changed, 1642 insertions(+), 47 deletions(-) create mode 100644 masterror-derive/src/masterror_impl.rs create mode 100644 src/mapping.rs create mode 100644 tests/masterror_macro.rs create mode 100644 tests/ui/masterror/fail/duplicate_attr.rs create mode 100644 tests/ui/masterror/fail/duplicate_attr.stderr create mode 100644 tests/ui/masterror/fail/duplicate_telemetry.rs create mode 100644 tests/ui/masterror/fail/duplicate_telemetry.stderr create mode 100644 tests/ui/masterror/fail/empty_redact.rs create mode 100644 tests/ui/masterror/fail/empty_redact.stderr create mode 100644 tests/ui/masterror/fail/enum_missing_variant.rs create mode 100644 tests/ui/masterror/fail/enum_missing_variant.stderr create mode 100644 tests/ui/masterror/fail/missing_attr.rs create mode 100644 tests/ui/masterror/fail/missing_attr.stderr create mode 100644 tests/ui/masterror/fail/missing_category.rs create mode 100644 tests/ui/masterror/fail/missing_category.stderr create mode 100644 tests/ui/masterror/fail/missing_code.rs create mode 100644 tests/ui/masterror/fail/missing_code.stderr create mode 100644 tests/ui/masterror/fail/unknown_option.rs create mode 100644 tests/ui/masterror/fail/unknown_option.stderr create mode 100644 tests/ui/masterror/pass/struct.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 957b2cb..2db66fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.13.0] - 2025-09-23 + +### Added +- Introduced `#[derive(Masterror)]` and the `#[masterror(...)]` attribute to + convert domain errors directly into [`masterror::Error`] while capturing + metadata, message redaction policy and optional transport mappings. +- Added transport mapping descriptors in `mapping::{HttpMapping, GrpcMapping, + ProblemMapping}` generated by the new derive for HTTP/gRPC/problem-json + integrations. + +### Changed +- Re-exported the `Masterror` derive from the crate root alongside the existing + `Error` derive. + +### Documentation +- Expanded crate docs and both READMEs with `Masterror` examples, telemetry + guidance and redaction policy notes. + +### Tests +- Added integration tests and trybuild coverage exercising the + `#[masterror(...)]` attribute and generated mapping tables. + ## [0.12.1] - 2025-10-30 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2c79379..a038364 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.12.1" +version = "0.13.0" dependencies = [ "actix-web", "axum", @@ -1637,7 +1637,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.6.6" +version = "0.7.0" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 523c059..7437da7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.12.1" +version = "0.13.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -71,11 +71,11 @@ turnkey = [] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.6.6" } +masterror-derive = { version = "0.7.0" } masterror-template = { version = "0.3.6" } [dependencies] -masterror-derive = { version = "0.6" } +masterror-derive = { version = "0.7" } masterror-template = { workspace = true } tracing = "0.1" diff --git a/README.md b/README.md index 49416f2..1844265 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,9 @@ 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`, `Metadata` -- Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain - mappings and structured telemetry +- Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]`, `#[provide]` for domain mappings and structured + telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) - Structured metadata helpers via `field::*` builders @@ -37,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } # or with features: -# masterror = { version = "0.12.1", features = [ +# masterror = { version = "0.13.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -75,10 +76,10 @@ masterror = { version = "0.12.1", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.12.1", features = [ +# masterror = { version = "0.13.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -179,6 +180,77 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. +#### `#[derive(Masterror)]` and `#[masterror(...)]` + +`#[derive(Masterror)]` wires a domain error directly into [`masterror::Error`], +augmenting it with metadata, redaction policy and optional transport mappings. +The accompanying `#[masterror(...)]` attribute mirrors the `#[app_error]` +syntax while remaining explicit about telemetry: + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + 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, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` pick the public [`AppCode`] and internal + [`AppErrorKind`]. +- `message` forwards the formatted [`Display`] output as the safe public + message. Omit it to keep the message private. +- `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport + boundary. +- `telemetry(...)` accepts expressions that evaluate to + `Option`. Each populated field is inserted into the + resulting [`Metadata`]; use `telemetry()` when no fields are attached. +- `map.grpc` / `map.problem` capture optional gRPC status codes (as `i32`) and + RFC 7807 `type` URIs. The derive emits tables such as + `MyError::HTTP_MAPPING`, `MyError::GRPC_MAPPING` and + `MyError::PROBLEM_MAPPING` (or slice variants for enums) for downstream + integrations. + +All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) +are still honoured. Sources and backtraces are automatically attached to the +generated [`masterror::Error`]. + #### Display shorthand projections `#[error("...")]` supports the same shorthand syntax as `thiserror` for @@ -637,13 +709,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.12.1", features = [ +masterror = { version = "0.13.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -652,7 +724,7 @@ masterror = { version = "0.12.1", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.12.1", features = [ +masterror = { version = "0.13.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 855fe37..43fb663 100644 --- a/README.ru.md +++ b/README.ru.md @@ -20,8 +20,9 @@ ## Основные возможности - Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata`. -- Деривы `#[derive(Error)]`, `#[app_error]`, `#[provide]` для типизированного - телеметрического контекста и прямых конверсий доменных ошибок. +- Деривы `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]`, `#[provide]` для типизированного телеметрического + контекста и прямых конверсий доменных ошибок. - Адаптеры для Axum и Actix плюс логирование в браузер/`JsValue` для WASM (по фичам). - Генерация схем OpenAPI через `utoipa`. @@ -38,9 +39,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.12.1", default-features = false } +masterror = { version = "0.13.0", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.12.1", features = [ +# masterror = { version = "0.13.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -193,6 +194,73 @@ assert!(matches!(app.kind, AppErrorKind::Service)); информацию и прямой маппинг в `AppError`/`AppCode` без ручных реализаций `From`. +## `#[derive(Masterror)]` и атрибут `#[masterror(...)]` + +Когда нужно сразу получить [`masterror::Error`], используйте `#[derive(Masterror)]`. +Атрибут `#[masterror(...)]` описывает код, категорию, телеметрию, политику +редактирования и транспортные подсказки: + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("пользователь {user_id} без флага {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + 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, + #[source] + source: Option, +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None, +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` задают публичный [`AppCode`] и внутренний + [`AppErrorKind`]. +- `message` включает текст, возвращаемый [`Display`], в публичное сообщение. +- `redact(message)` выставляет [`MessageEditPolicy`] в режим редактирования на + транспортной границе. +- `telemetry(...)` принимает выражения, возвращающие + `Option`. Каждое присутствующее поле добавляется в + [`Metadata`]; пустые выражения пропускаются. +- `map.grpc` / `map.problem` позволяют зафиксировать код gRPC (целое `i32`) и + URI для problem+json. Дерив генерирует таблицы `TYPE::HTTP_MAPPING`, + `TYPE::GRPC_MAPPING` и `TYPE::PROBLEM_MAPPING` (или срезы для перечислений) + для дальнейшей интеграции. + +Атрибуты `#[from]`, `#[source]`, `#[backtrace]` продолжают работать: источники и +бектрейсы автоматически прикрепляются к результирующему [`masterror::Error`]. + ## Форматирование шаблонов `#[error]` Шаблон `#[error("...")]` по умолчанию использует `Display`, но любая diff --git a/README.template.md b/README.template.md index 3a757cc..1b399e7 100644 --- a/README.template.md +++ b/README.template.md @@ -20,8 +20,9 @@ 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`, `Metadata` -- Derive macros: `#[derive(Error)]`, `#[app_error]`, `#[provide]` for domain - mappings and structured telemetry +- Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]`, `#[provide]` for domain mappings and structured + telemetry - Optional Axum/Actix integration and browser/WASM console logging - Optional OpenAPI schema (via `utoipa`) - Structured metadata helpers via `field::*` builders @@ -173,6 +174,77 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. +#### `#[derive(Masterror)]` and `#[masterror(...)]` + +`#[derive(Masterror)]` wires a domain error directly into [`masterror::Error`], +augmenting it with metadata, redaction policy and optional transport mappings. +The accompanying `#[masterror(...)]` attribute mirrors the `#[app_error]` +syntax while remaining explicit about telemetry: + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + 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, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` pick the public [`AppCode`] and internal + [`AppErrorKind`]. +- `message` forwards the formatted [`Display`] output as the safe public + message. Omit it to keep the message private. +- `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport + boundary. +- `telemetry(...)` accepts expressions that evaluate to + `Option`. Each populated field is inserted into the + resulting [`Metadata`]; use `telemetry()` when no fields are attached. +- `map.grpc` / `map.problem` capture optional gRPC status codes (as `i32`) and + RFC 7807 `type` URIs. The derive emits tables such as + `MyError::HTTP_MAPPING`, `MyError::GRPC_MAPPING` and + `MyError::PROBLEM_MAPPING` (or slice variants for enums) for downstream + integrations. + +All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) +are still honoured. Sources and backtraces are automatically attached to the +generated [`masterror::Error`]. + #### Display shorthand projections `#[error("...")]` supports the same shorthand syntax as `thiserror` for diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index c9ba1a6..dadb1cd 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.6.6" +version = "0.7.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index 73408b8..64a00e0 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -33,7 +33,8 @@ pub struct StructData { pub display: DisplaySpec, #[allow(dead_code)] pub format_args: FormatArgsSpec, - pub app_error: Option + pub app_error: Option, + pub masterror: Option } #[derive(Debug)] @@ -44,6 +45,7 @@ pub struct VariantData { #[allow(dead_code)] pub format_args: FormatArgsSpec, pub app_error: Option, + pub masterror: Option, pub span: Span } @@ -55,6 +57,19 @@ pub struct AppErrorSpec { pub attribute_span: Span } +#[derive(Clone, Debug)] +pub struct MasterrorSpec { + pub code: Expr, + pub category: ExprPath, + pub expose_message: bool, + pub redact_message: bool, + pub telemetry: Vec, + pub map_grpc: Option, + pub map_problem: Option, + #[allow(dead_code)] + pub attribute_span: Span +} + #[derive(Debug)] pub enum Fields { Unit, @@ -524,6 +539,7 @@ fn parse_struct( ) -> Result { let display = extract_display_spec(attrs, ident.span(), errors)?; let app_error = extract_app_error_spec(attrs, errors)?; + let masterror = extract_masterror_spec(attrs, errors)?; let fields = Fields::from_syn(&data.fields, errors); validate_from_usage(&fields, &display, errors); @@ -534,7 +550,8 @@ fn parse_struct( fields, display, format_args: FormatArgsSpec::default(), - app_error + app_error, + masterror }))) } @@ -573,6 +590,7 @@ fn parse_variant(variant: syn::Variant, errors: &mut Vec) -> Result) -> Result +) -> Result, ()> { + let mut spec = None; + let mut had_error = false; + + for attr in attrs { + if !path_is(attr, "masterror") { + continue; + } + + if spec.is_some() { + errors.push(Error::new_spanned( + attr, + "duplicate #[masterror(...)] attribute" + )); + had_error = true; + continue; + } + + match parse_masterror_attribute(attr) { + Ok(parsed) => spec = Some(parsed), + Err(err) => { + errors.push(err); + had_error = true; + } + } + } + + if had_error { Err(()) } else { Ok(spec) } +} + fn extract_app_error_spec( attrs: &[Attribute], errors: &mut Vec @@ -734,6 +786,223 @@ fn parse_app_error_attribute(attr: &Attribute) -> Result { }) } +fn parse_masterror_attribute(attr: &Attribute) -> Result { + attr.parse_args_with(|input: ParseStream| { + let mut code = None; + let mut category = None; + let mut expose_message = false; + let mut redact_message = false; + let mut telemetry = None; + let mut map_grpc = None; + let mut map_problem = None; + + while !input.is_empty() { + let ident: Ident = input.call(Ident::parse_any)?; + match ident.to_string().as_str() { + "code" => { + if code.is_some() { + return Err(Error::new(ident.span(), "duplicate code specification")); + } + input.parse::()?; + let value: Expr = input.parse()?; + code = Some(value); + } + "category" => { + if category.is_some() { + return Err(Error::new(ident.span(), "duplicate category specification")); + } + input.parse::()?; + let value: ExprPath = input.parse()?; + category = Some(value); + } + "message" => { + if expose_message { + return Err(Error::new(ident.span(), "duplicate message flag")); + } + expose_message = parse_flag_value(input)?; + } + "redact" => { + if redact_message { + return Err(Error::new(ident.span(), "duplicate redact(...) block")); + } + redact_message = parse_redact_block(input, ident.span())?; + } + "telemetry" => { + if telemetry.is_some() { + return Err(Error::new(ident.span(), "duplicate telemetry(...) block")); + } + telemetry = Some(parse_telemetry_block(input, ident.span())?); + } + "map" => { + input.parse::()?; + let sub: Ident = input.call(Ident::parse_any)?; + match sub.to_string().as_str() { + "grpc" => { + if map_grpc.is_some() { + return Err(Error::new( + sub.span(), + "duplicate map.grpc specification" + )); + } + input.parse::()?; + let value: Expr = input.parse()?; + map_grpc = Some(value); + } + "problem" => { + if map_problem.is_some() { + return Err(Error::new( + sub.span(), + "duplicate map.problem specification" + )); + } + input.parse::()?; + let value: Expr = input.parse()?; + map_problem = Some(value); + } + other => { + return Err(Error::new( + sub.span(), + format!("unknown #[masterror] mapping `map.{other}`") + )); + } + } + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown #[masterror] option `{other}`") + )); + } + } + + if input.peek(Token![,]) { + input.parse::()?; + } else if !input.is_empty() { + return Err(Error::new( + input.span(), + "expected `,` or end of input in #[masterror(...)]" + )); + } + } + + let code = match code { + Some(value) => value, + None => { + return Err(Error::new( + attr.span(), + "missing `code = ...` in #[masterror(...)]" + )); + } + }; + + let category = match category { + Some(value) => value, + None => { + return Err(Error::new( + attr.span(), + "missing `category = ...` in #[masterror(...)]" + )); + } + }; + + Ok(MasterrorSpec { + code, + category, + expose_message, + redact_message, + telemetry: telemetry.unwrap_or_default(), + map_grpc, + map_problem, + attribute_span: attr.span() + }) + }) +} + +fn parse_flag_value(input: ParseStream) -> Result { + if input.peek(Token![=]) { + input.parse::()?; + let value: LitBool = input.parse()?; + Ok(value.value) + } else { + Ok(true) + } +} + +fn parse_redact_block(input: ParseStream, span: Span) -> Result { + let content; + syn::parenthesized!(content in input); + + if content.is_empty() { + return Err(Error::new(span, "redact(...) requires at least one option")); + } + + let mut redact_message = false; + + while !content.is_empty() { + let ident: Ident = content.call(Ident::parse_any)?; + match ident.to_string().as_str() { + "message" => { + if redact_message { + return Err(Error::new(ident.span(), "duplicate redact(message) option")); + } + if content.peek(Token![=]) { + content.parse::()?; + let value: LitBool = content.parse()?; + redact_message = value.value; + } else { + redact_message = true; + } + } + other => { + return Err(Error::new( + ident.span(), + format!("unknown redact option `{other}`") + )); + } + } + + if content.peek(Token![,]) { + content.parse::()?; + } else if !content.is_empty() { + return Err(Error::new( + content.span(), + "expected `,` or end of input in redact(...)" + )); + } + } + + Ok(redact_message) +} + +fn parse_telemetry_block(input: ParseStream, span: Span) -> Result, Error> { + let content; + syn::parenthesized!(content in input); + + let mut entries = Vec::new(); + + while !content.is_empty() { + let expr: Expr = content.parse()?; + entries.push(expr); + + if content.peek(Token![,]) { + content.parse::()?; + if content.is_empty() { + return Err(Error::new( + span, + "expected expression after comma in telemetry(...)" + )); + } + } else if !content.is_empty() { + return Err(Error::new( + content.span(), + "expected `,` or end of input in telemetry(...)" + )); + } + } + + Ok(entries) +} + fn parse_error_attribute(attr: &Attribute) -> Result { mod kw { syn::custom_keyword!(transparent); diff --git a/masterror-derive/src/lib.rs b/masterror-derive/src/lib.rs index cffacd7..ded7de6 100644 --- a/masterror-derive/src/lib.rs +++ b/masterror-derive/src/lib.rs @@ -8,6 +8,7 @@ mod display; mod error_trait; mod from_impl; mod input; +mod masterror_impl; mod span; mod template_support; @@ -24,6 +25,18 @@ pub fn derive_error(tokens: TokenStream) -> TokenStream { } } +#[proc_macro_derive( + Masterror, + attributes(error, source, from, backtrace, masterror, provide) +)] +pub fn derive_masterror(tokens: TokenStream) -> TokenStream { + let input = parse_macro_input!(tokens as DeriveInput); + match expand_masterror(input) { + Ok(stream) => stream.into(), + Err(err) => err.to_compile_error().into() + } +} + fn expand(input: DeriveInput) -> Result { let parsed = input::parse_input(input)?; let display_impl = display::expand(&parsed)?; @@ -38,3 +51,18 @@ fn expand(input: DeriveInput) -> Result { #(#app_error_impls)* }) } + +fn expand_masterror(input: DeriveInput) -> Result { + let parsed = input::parse_input(input)?; + let display_impl = display::expand(&parsed)?; + let error_impl = error_trait::expand(&parsed)?; + let from_impls = from_impl::expand(&parsed)?; + let masterror_impl = masterror_impl::expand(&parsed)?; + + Ok(quote! { + #display_impl + #error_impl + #(#from_impls)* + #masterror_impl + }) +} diff --git a/masterror-derive/src/masterror_impl.rs b/masterror-derive/src/masterror_impl.rs new file mode 100644 index 0000000..e51b7d0 --- /dev/null +++ b/masterror-derive/src/masterror_impl.rs @@ -0,0 +1,508 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{format_ident, quote}; +use syn::{Error, Expr, ExprPath, Index}; + +use crate::input::{ + ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_option_type +}; + +pub fn expand(input: &ErrorInput) -> Result { + match &input.data { + ErrorData::Struct(data) => expand_struct(input, data), + ErrorData::Enum(variants) => expand_enum(input, variants) + } +} + +fn expand_struct(input: &ErrorInput, data: &StructData) -> Result { + let spec = data.masterror.as_ref().ok_or_else(|| { + Error::new( + input.ident.span(), + "#[derive(Masterror)] requires #[masterror(...)] on structs" + ) + })?; + + let conversion = struct_conversion_impl(input, data, spec); + let mappings = struct_mapping_impl(input, spec); + + Ok(quote! { + #conversion + #mappings + }) +} + +fn expand_enum(input: &ErrorInput, variants: &[VariantData]) -> Result { + ensure_all_variants_have_masterror(variants)?; + + let conversion = enum_conversion_impl(input, variants); + let mappings = enum_mapping_impl(input, variants); + + Ok(quote! { + #conversion + #mappings + }) +} + +fn ensure_all_variants_have_masterror(variants: &[VariantData]) -> Result<(), Error> { + for variant in variants { + if variant.masterror.is_none() { + return Err(Error::new( + variant.span, + "all variants must use #[masterror(...)] to derive masterror::Error conversion" + )); + } + } + Ok(()) +} + +struct BoundField<'a> { + field: &'a Field, + binding: Ident +} + +fn struct_conversion_impl( + input: &ErrorInput, + data: &StructData, + spec: &MasterrorSpec +) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let code = &spec.code; + let category = &spec.category; + + let message_init = message_initialization(spec.expose_message, quote!(&value)); + let (destructure, bound_fields) = bind_struct_fields(ident, &data.fields); + let field_usage = field_usage_tokens(&bound_fields); + let telemetry_init = telemetry_initialization(&spec.telemetry); + let metadata_attach = metadata_attach_tokens(); + let redact_tokens = redact_tokens(spec.redact_message); + let source_tokens = source_attachment_tokens(&bound_fields); + let backtrace_tokens = backtrace_attachment_tokens(&data.fields, &bound_fields); + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::Error #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #message_init + #destructure + #field_usage + #telemetry_init + let mut __masterror_error = match __masterror_message { + Some(message) => masterror::Error::with((#category), message), + None => masterror::Error::bare((#category)) + }; + __masterror_error = __masterror_error.with_code((#code)); + #metadata_attach + #redact_tokens + #source_tokens + #backtrace_tokens + __masterror_error + } + } + } +} + +fn enum_conversion_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let mut arms = Vec::new(); + + let mut message_arms = Vec::new(); + + for variant in variants { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + let (pattern, bound_fields) = bind_variant_fields(ident, variant); + let field_usage = field_usage_tokens(&bound_fields); + let telemetry_init = telemetry_initialization(&spec.telemetry); + let metadata_attach = metadata_attach_tokens(); + let redact_tokens = redact_tokens(spec.redact_message); + let source_tokens = source_attachment_tokens(&bound_fields); + let backtrace_tokens = backtrace_attachment_tokens(&variant.fields, &bound_fields); + message_arms.push(enum_message_arm(ident, variant, spec.expose_message)); + + arms.push(quote! { + #pattern => { + #field_usage + #telemetry_init + let mut __masterror_error = match __masterror_message { + Some(message) => masterror::Error::with((#category), message), + None => masterror::Error::bare((#category)) + }; + __masterror_error = __masterror_error.with_code((#code)); + #metadata_attach + #redact_tokens + #source_tokens + #backtrace_tokens + __masterror_error + } + }); + } + + let message_match = quote! { + let __masterror_message: Option = match &value { + #(#message_arms)* + }; + }; + + quote! { + impl #impl_generics core::convert::From<#ident #ty_generics> for masterror::Error #where_clause { + fn from(value: #ident #ty_generics) -> Self { + #message_match + match value { + #(#arms),* + } + } + } + } +} + +fn enum_message_arm( + enum_ident: &Ident, + variant: &VariantData, + expose_message: bool +) -> TokenStream { + if expose_message { + let binding = format_ident!("__masterror_variant_ref"); + let pattern = enum_message_pattern(enum_ident, variant, Some(&binding)); + quote! { + #pattern => Some(std::string::ToString::to_string(#binding)), + } + } else { + let pattern = enum_message_pattern(enum_ident, variant, None); + quote! { + #pattern => None, + } + } +} + +fn enum_message_pattern( + enum_ident: &Ident, + variant: &VariantData, + binding: Option<&Ident> +) -> TokenStream { + let variant_ident = &variant.ident; + match (&variant.fields, binding) { + (Fields::Unit, Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident), + (Fields::Unit, None) => quote!(#enum_ident::#variant_ident), + (Fields::Named(_), Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident { .. }), + (Fields::Named(_), None) => quote!(#enum_ident::#variant_ident { .. }), + (Fields::Unnamed(_), Some(binding)) => quote!(#binding @ #enum_ident::#variant_ident(..)), + (Fields::Unnamed(_), None) => quote!(#enum_ident::#variant_ident(..)) + } +} + +fn field_usage_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream { + if bound_fields.is_empty() { + return TokenStream::new(); + } + + let names = bound_fields.iter().map(|field| &field.binding); + quote! { + let _ = (#(&#names),*); + } +} + +fn struct_mapping_impl(input: &ErrorInput, spec: &MasterrorSpec) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let code = &spec.code; + let category = &spec.category; + let grpc_mapping = + mapping_option_tokens(spec.map_grpc.as_ref(), code, category, MappingKind::Grpc); + let problem_mapping = mapping_option_tokens( + spec.map_problem.as_ref(), + code, + category, + MappingKind::Problem + ); + + quote! { + impl #impl_generics #ident #ty_generics #where_clause { + /// HTTP mapping for this error type. + pub const HTTP_MAPPING: masterror::mapping::HttpMapping = + masterror::mapping::HttpMapping::new((#code), (#category)); + + /// gRPC mapping for this error type. + pub const GRPC_MAPPING: Option = #grpc_mapping; + + /// Problem JSON mapping for this error type. + pub const PROBLEM_MAPPING: Option = #problem_mapping; + } + } +} + +fn enum_mapping_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenStream { + let ident = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + + let http_entries: Vec<_> = variants + .iter() + .map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + quote!(masterror::mapping::HttpMapping::new((#code), (#category))) + }) + .collect(); + + let grpc_entries: Vec<_> = variants + .iter() + .filter_map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + spec.map_grpc.as_ref().map( + |expr| quote!(masterror::mapping::GrpcMapping::new((#code), (#category), (#expr))) + ) + }) + .collect(); + + let problem_entries: Vec<_> = variants + .iter() + .filter_map(|variant| { + let spec = variant.masterror.as_ref().expect("presence checked"); + let code = &spec.code; + let category = &spec.category; + spec.map_problem.as_ref().map(|expr| { + quote!(masterror::mapping::ProblemMapping::new((#code), (#category), (#expr))) + }) + }) + .collect(); + + let http_len = Index::from(http_entries.len()); + + let grpc_slice = if grpc_entries.is_empty() { + quote!(&[] as &[masterror::mapping::GrpcMapping]) + } else { + quote!(&[#(#grpc_entries),*]) + }; + + let problem_slice = if problem_entries.is_empty() { + quote!(&[] as &[masterror::mapping::ProblemMapping]) + } else { + quote!(&[#(#problem_entries),*]) + }; + + quote! { + impl #impl_generics #ident #ty_generics #where_clause { + /// HTTP mappings for enum variants. + pub const HTTP_MAPPINGS: [masterror::mapping::HttpMapping; #http_len] = [#(#http_entries),*]; + + /// gRPC mappings for enum variants. + pub const GRPC_MAPPINGS: &'static [masterror::mapping::GrpcMapping] = #grpc_slice; + + /// Problem JSON mappings for enum variants. + pub const PROBLEM_MAPPINGS: &'static [masterror::mapping::ProblemMapping] = #problem_slice; + } + } +} + +fn message_initialization(enabled: bool, value: TokenStream) -> TokenStream { + if enabled { + quote! { + let __masterror_message = Some(std::string::ToString::to_string(#value)); + } + } else { + quote! { + let __masterror_message: Option = None; + } + } +} + +fn bind_struct_fields<'a>( + ident: &Ident, + fields: &'a Fields +) -> (TokenStream, Vec>) { + match fields { + Fields::Unit => (quote!(let _ = value;), Vec::new()), + Fields::Named(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + let pattern_tokens = quote!(let #ident { #(#pattern),* } = value;); + (pattern_tokens, bound) + } + Fields::Unnamed(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + let pattern_tokens = quote!(let #ident(#(#pattern),*) = value;); + (pattern_tokens, bound) + } + } +} + +fn bind_variant_fields<'a>( + enum_ident: &Ident, + variant: &'a VariantData +) -> (TokenStream, Vec>) { + let variant_ident = &variant.ident; + + match &variant.fields { + Fields::Unit => (quote!(#enum_ident::#variant_ident), Vec::new()), + Fields::Named(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + (quote!(#enum_ident::#variant_ident { #(#pattern),* }), bound) + } + Fields::Unnamed(list) => { + let mut pattern = Vec::new(); + let mut bound = Vec::new(); + for field in list { + let binding = binding_ident(field); + let pattern_binding = binding.clone(); + pattern.push(quote!(#pattern_binding)); + bound.push(BoundField { + field, + binding + }); + } + (quote!(#enum_ident::#variant_ident(#(#pattern),*)), bound) + } + } +} + +fn telemetry_initialization(entries: &[Expr]) -> TokenStream { + if entries.is_empty() { + quote!(let __masterror_metadata: Option = None;) + } else { + let inserts = entries.iter().map(|expr| { + quote! { + if let Some(field) = (#expr) { + __masterror_metadata_inner.insert(field); + } + } + }); + quote! { + let mut __masterror_metadata_inner = masterror::Metadata::new(); + #(#inserts)* + let __masterror_metadata = if __masterror_metadata_inner.is_empty() { + None + } else { + Some(__masterror_metadata_inner) + }; + } + } +} + +fn metadata_attach_tokens() -> TokenStream { + quote! { + if let Some(metadata) = __masterror_metadata { + __masterror_error = __masterror_error.with_metadata(metadata); + } + } +} + +fn redact_tokens(enabled: bool) -> TokenStream { + if enabled { + quote!( + __masterror_error = __masterror_error.redactable(); + ) + } else { + TokenStream::new() + } +} + +fn source_attachment_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream { + for bound in bound_fields { + if bound.field.attrs.has_source() { + let binding = &bound.binding; + if is_option_type(&bound.field.ty) { + return quote! { + if let Some(source) = #binding { + __masterror_error = __masterror_error.with_source(source); + } + }; + } else { + return quote! { + __masterror_error = __masterror_error.with_source(#binding); + }; + } + } + } + TokenStream::new() +} + +fn backtrace_attachment_tokens(fields: &Fields, bound_fields: &[BoundField<'_>]) -> TokenStream { + let Some(backtrace_field) = fields.backtrace_field() else { + return TokenStream::new(); + }; + let index = backtrace_field.index(); + let Some(binding) = bound_fields + .iter() + .find(|bound| bound.field.index == index) + .map(|bound| &bound.binding) + else { + return TokenStream::new(); + }; + + if is_option_type(&backtrace_field.field().ty) { + quote! { + if let Some(trace) = #binding { + __masterror_error = __masterror_error.with_backtrace(trace); + } + } + } else { + quote! { + __masterror_error = __masterror_error.with_backtrace(#binding); + } + } +} + +#[derive(Clone, Copy)] +enum MappingKind { + Grpc, + Problem +} + +fn mapping_option_tokens( + expr: Option<&Expr>, + code: &Expr, + category: &ExprPath, + kind: MappingKind +) -> TokenStream { + match expr { + Some(value) => match kind { + MappingKind::Grpc => { + quote!(Some(masterror::mapping::GrpcMapping::new((#code), (#category), (#value)))) + } + MappingKind::Problem => { + quote!(Some(masterror::mapping::ProblemMapping::new((#code), (#category), (#value)))) + } + }, + None => quote!(None) + } +} + +fn binding_ident(field: &Field) -> Ident { + field + .ident + .clone() + .unwrap_or_else(|| format_ident!("__field{}", field.index, span = field.span)) +} diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 6b27f33..3787d9e 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -11,20 +11,15 @@ use super::metadata::{Field, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; /// Controls whether the public message may be redacted before exposure. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] pub enum MessageEditPolicy { /// Message must be preserved as-is. + #[default] Preserve, /// Message may be redacted or replaced at the transport boundary. Redact } -impl Default for MessageEditPolicy { - fn default() -> Self { - Self::Preserve - } -} - /// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] pub struct Error { diff --git a/src/lib.rs b/src/lib.rs index 67dbcd2..b5653b7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,6 +84,75 @@ //! Use `#[provide]` to forward typed telemetry that downstream consumers can //! extract from [`AppError`] via `std::error::Request`. //! +//! ## Masterror derive: end-to-end domain errors +//! +//! `#[derive(Masterror)]` builds on top of `#[derive(Error)]`, wiring a domain +//! error directly into [`struct@crate::Error`] with typed telemetry, redaction +//! policy and transport hints. The `#[masterror(...)]` attribute mirrors the +//! `thiserror` style while keeping redaction decisions and metadata in one +//! place. +//! +//! ```rust +//! 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), +//! 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, +//! #[source] +//! source: Option +//! } +//! +//! let err = MissingFlag { +//! user_id: "alice".into(), +//! flag: "beta", +//! attempt: Some(2), +//! source: None +//! }; +//! let converted: Error = err.into(); +//! assert_eq!(converted.code, AppCode::NotFound); +//! assert_eq!(converted.kind, AppErrorKind::NotFound); +//! assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +//! assert!(converted.metadata().get("user_id").is_some()); +//! assert_eq!( +//! MissingFlag::HTTP_MAPPING, +//! HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +//! ); +//! ``` +//! +//! - `code` — public [`AppCode`]. +//! - `category` — semantic [`AppErrorKind`]. +//! - `message` — expose the formatted [`core::fmt::Display`] output as the +//! public message. +//! - `redact(message)` — mark the message as redactable at the transport +//! boundary. +//! - `telemetry(...)` — list of expressions producing +//! `Option` to be inserted into [`Metadata`]. +//! - `map.grpc` / `map.problem` — optional gRPC status (as `i32`) and +//! problem+json type for generated mapping tables. Access them via +//! `TYPE::HTTP_MAPPING`, `TYPE::GRPC_MAPPING`/`MAPPINGS` and +//! `TYPE::PROBLEM_MAPPING`/`MAPPINGS`. +//! +//! The derive continues to honour `#[from]`, `#[source]` and `#[backtrace]` +//! field attributes, automatically attaching sources and captured backtraces to +//! the resulting [`struct@Error`]. +//! //! # Domain integrations: Turnkey //! //! With the `turnkey` feature enabled, the crate exports a `turnkey` module @@ -246,12 +315,15 @@ pub mod turnkey; /// Minimal prelude re-exporting core types for handler signatures. pub mod prelude; +/// Transport mapping descriptors for generated domain errors. +pub mod mapping; + pub use app_error::{ AppError, AppResult, Context, Error, Field, FieldValue, MessageEditPolicy, Metadata, field }; pub use code::AppCode; pub use kind::AppErrorKind; -/// Re-export derive macros so users only depend on [`masterror`]. +/// Re-export derive macros so users only depend on this crate. /// /// # Examples /// @@ -277,6 +349,6 @@ pub use kind::AppErrorKind; /// .into(); /// assert!(matches!(code, AppCode::BadRequest)); /// ``` -pub use masterror_derive::*; +pub use masterror_derive::{Error, Masterror}; pub use response::{ErrorResponse, RetryAdvice}; pub use result_ext::ResultExt; diff --git a/src/mapping.rs b/src/mapping.rs new file mode 100644 index 0000000..8b56f3d --- /dev/null +++ b/src/mapping.rs @@ -0,0 +1,129 @@ +//! Transport mapping descriptors generated by `#[derive(Masterror)]`. +//! +//! The derive macro produces compile-time tables describing how each domain +//! error maps to transport-specific representations. Use these helpers to +//! integrate with HTTP, gRPC or RFC 7807 problem+json responses without +//! duplicating per-variant logic. + +use crate::{AppCode, AppErrorKind}; + +/// HTTP mapping for a domain error. +/// +/// Stores the stable public [`AppCode`] and semantic [`AppErrorKind`]. The +/// HTTP status code can be derived from the kind via +/// [`AppErrorKind::http_status`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HttpMapping { + code: AppCode, + kind: AppErrorKind +} + +impl HttpMapping { + /// Create a new HTTP mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind) -> Self { + Self { + code, + kind + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// Derive the HTTP status code from the error kind. + #[must_use] + pub fn status(&self) -> u16 { + self.kind.http_status() + } +} + +/// gRPC mapping for a domain error. +/// +/// Stores the [`AppCode`], [`AppErrorKind`] and a gRPC status code (as `i32`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct GrpcMapping { + code: AppCode, + kind: AppErrorKind, + status: i32 +} + +impl GrpcMapping { + /// Create a new gRPC mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind, status: i32) -> Self { + Self { + code, + kind, + status + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// gRPC status code (matching `tonic::Code` discriminants). + #[must_use] + pub const fn status(&self) -> i32 { + self.status + } +} + +/// RFC 7807 problem+json mapping. +/// +/// Associates an error with the [`AppCode`], [`AppErrorKind`] and a canonical +/// problem `type` URI. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ProblemMapping { + code: AppCode, + kind: AppErrorKind, + r#type: &'static str +} + +impl ProblemMapping { + /// Create a new problem+json mapping entry. + #[must_use] + pub const fn new(code: AppCode, kind: AppErrorKind, type_uri: &'static str) -> Self { + Self { + code, + kind, + r#type: type_uri + } + } + + /// Stable machine-readable error code. + #[must_use] + pub const fn code(&self) -> AppCode { + self.code + } + + /// Semantic application error category. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } + + /// Canonical problem `type` URI. + #[must_use] + pub const fn type_uri(&self) -> &'static str { + self.r#type + } +} diff --git a/tests/error_derive_from_trybuild.rs b/tests/error_derive_from_trybuild.rs index 0879942..8f267bf 100644 --- a/tests/error_derive_from_trybuild.rs +++ b/tests/error_derive_from_trybuild.rs @@ -41,3 +41,15 @@ fn app_error_attribute_compile_failures() { let t = TestCases::new(); t.compile_fail("tests/ui/app_error/fail/*.rs"); } + +#[test] +fn masterror_attribute_passes() { + let t = TestCases::new(); + t.pass("tests/ui/masterror/pass/*.rs"); +} + +#[test] +fn masterror_attribute_compile_failures() { + let t = TestCases::new(); + t.compile_fail("tests/ui/masterror/fail/*.rs"); +} diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs new file mode 100644 index 0000000..e9815e0 --- /dev/null +++ b/tests/masterror_macro.rs @@ -0,0 +1,157 @@ +#![allow(non_shorthand_field_patterns)] + +use masterror::{ + AppCode, AppErrorKind, Error as MasterrorError, Masterror, MessageEditPolicy, + mapping::{GrpcMapping, HttpMapping, ProblemMapping} +}; + +#[derive(Debug, Masterror)] +#[error("missing feature flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message), + 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, + #[source] + source: Option +} + +#[derive(Debug, Masterror)] +enum ApiError { + #[error("invalid payload: {details}")] + #[masterror( + code = AppCode::BadRequest, + category = AppErrorKind::BadRequest, + message, + telemetry(Some(masterror::field::str("details", details))), + map.problem = "https://errors.example.com/bad-request" + )] + BadPayload { + details: &'static str, + #[allow(non_shorthand_field_patterns)] + #[source] + _source: std::io::Error + }, + #[error("storage offline")] + #[masterror( + code = AppCode::Service, + category = AppErrorKind::Service, + telemetry(), + map.grpc = 14 + )] + StorageOffline +} + +#[test] +fn struct_masterror_conversion_populates_metadata_and_source() { + let source = std::io::Error::new(std::io::ErrorKind::Other, "backend down"); + let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(3), + source: Some(source) + }; + + let converted: MasterrorError = err.into(); + + assert_eq!(converted.code, AppCode::NotFound); + assert_eq!(converted.kind, AppErrorKind::NotFound); + assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); + assert!( + converted + .message + .as_deref() + .is_some_and(|message| message.contains("beta")) + ); + + let user_id = converted + .metadata() + .get("user_id") + .and_then(|value| match value { + masterror::FieldValue::Str(value) => Some(value.as_ref()), + _ => None + }); + assert_eq!(user_id, Some("alice")); + + let attempt = converted + .metadata() + .get("attempt") + .and_then(|value| match value { + masterror::FieldValue::U64(value) => Some(*value), + _ => None + }); + assert_eq!(attempt, Some(3)); + + assert!(converted.source_ref().is_some()); + + assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) + ); + assert_eq!(MissingFlag::HTTP_MAPPING.status(), 404); + + let grpc = MissingFlag::GRPC_MAPPING.expect("grpc mapping"); + assert_eq!(grpc.status(), 5); + assert_eq!(grpc.kind(), AppErrorKind::NotFound); + + let problem = MissingFlag::PROBLEM_MAPPING.expect("problem mapping"); + assert_eq!(problem.type_uri(), "https://errors.example.com/not-found"); +} + +#[test] +fn enum_masterror_conversion_handles_variants() { + let io_error = std::io::Error::new(std::io::ErrorKind::InvalidInput, "format"); + let payload = ApiError::BadPayload { + details: "missing field", + _source: io_error + }; + + let converted: MasterrorError = payload.into(); + assert_eq!(converted.code, AppCode::BadRequest); + assert_eq!(converted.kind, AppErrorKind::BadRequest); + assert!(converted.metadata().get("details").is_some_and( + |value| matches!(value, masterror::FieldValue::Str(detail) if detail == "missing field") + )); + assert!(converted.source_ref().is_some()); + + let offline: MasterrorError = ApiError::StorageOffline.into(); + assert_eq!(offline.code, AppCode::Service); + assert_eq!(offline.kind, AppErrorKind::Service); + assert!(offline.metadata().is_empty()); + + assert_eq!(ApiError::HTTP_MAPPINGS.len(), 2); + assert!( + ApiError::HTTP_MAPPINGS + .iter() + .any(|mapping| mapping.kind() == AppErrorKind::BadRequest) + ); + + assert_eq!( + ApiError::GRPC_MAPPINGS, + &[GrpcMapping::new( + AppCode::Service, + AppErrorKind::Service, + 14 + )] + ); + + assert_eq!( + ApiError::PROBLEM_MAPPINGS, + &[ProblemMapping::new( + AppCode::BadRequest, + AppErrorKind::BadRequest, + "https://errors.example.com/bad-request" + )] + ); +} diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.rs b/tests/ui/masterror/fail/duplicate_attr.rs new file mode 100644 index 0000000..026649f --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_attr.rs @@ -0,0 +1,9 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("dup")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] +struct Duplicate; + +fn main() {} diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr new file mode 100644 index 0000000..113a10d --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -0,0 +1,13 @@ +error: duplicate #[masterror(...)] attribute + --> tests/ui/masterror/fail/duplicate_attr.rs:6:1 + | +6 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/duplicate_attr.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.rs b/tests/ui/masterror/fail/duplicate_telemetry.rs new file mode 100644 index 0000000..fb0b43d --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_telemetry.rs @@ -0,0 +1,13 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + telemetry(), + telemetry() +)] +struct DuplicateTelemetry; + +fn main() {} diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr new file mode 100644 index 0000000..9ada290 --- /dev/null +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -0,0 +1,13 @@ +error: duplicate telemetry(...) block + --> tests/ui/masterror/fail/duplicate_telemetry.rs:9:5 + | +9 | telemetry() + | ^^^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/duplicate_telemetry.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.rs b/tests/ui/masterror/fail/empty_redact.rs new file mode 100644 index 0000000..0a4c67d --- /dev/null +++ b/tests/ui/masterror/fail/empty_redact.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, redact())] +struct EmptyRedact; + +fn main() {} diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr new file mode 100644 index 0000000..fd151cc --- /dev/null +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -0,0 +1,13 @@ +error: redact(...) requires at least one option + --> tests/ui/masterror/fail/empty_redact.rs:5:74 + | +5 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, redact())] + | ^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/empty_redact.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.rs b/tests/ui/masterror/fail/enum_missing_variant.rs new file mode 100644 index 0000000..d6ae160 --- /dev/null +++ b/tests/ui/masterror/fail/enum_missing_variant.rs @@ -0,0 +1,12 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +enum Mixed { + #[error("with")] + #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal)] + With, + #[error("missing")] + Missing +} + +fn main() {} diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr new file mode 100644 index 0000000..5a25e12 --- /dev/null +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -0,0 +1,14 @@ +error: all variants must use #[masterror(...)] to derive masterror::Error conversion + --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 + | +8 | / #[error("missing")] +9 | | Missing + | |___________^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_attr.rs b/tests/ui/masterror/fail/missing_attr.rs new file mode 100644 index 0000000..92197f2 --- /dev/null +++ b/tests/ui/masterror/fail/missing_attr.rs @@ -0,0 +1,7 @@ +use masterror::Masterror; + +#[derive(Debug, Masterror)] +#[error("no attribute")] +struct Missing; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_attr.stderr b/tests/ui/masterror/fail/missing_attr.stderr new file mode 100644 index 0000000..3c757ed --- /dev/null +++ b/tests/ui/masterror/fail/missing_attr.stderr @@ -0,0 +1,5 @@ +error: #[derive(Masterror)] requires #[masterror(...)] on structs + --> tests/ui/masterror/fail/missing_attr.rs:5:8 + | +5 | struct Missing; + | ^^^^^^^ diff --git a/tests/ui/masterror/fail/missing_category.rs b/tests/ui/masterror/fail/missing_category.rs new file mode 100644 index 0000000..2b8a52c --- /dev/null +++ b/tests/ui/masterror/fail/missing_category.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal)] +struct MissingCategory; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr new file mode 100644 index 0000000..bdadf45 --- /dev/null +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -0,0 +1,13 @@ +error: missing `category = ...` in #[masterror(...)] + --> tests/ui/masterror/fail/missing_category.rs:5:1 + | +5 | #[masterror(code = AppCode::Internal)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `AppCode` + --> tests/ui/masterror/fail/missing_category.rs:1:17 + | +1 | use masterror::{AppCode, Masterror}; + | ^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.rs b/tests/ui/masterror/fail/missing_code.rs new file mode 100644 index 0000000..72c21e0 --- /dev/null +++ b/tests/ui/masterror/fail/missing_code.rs @@ -0,0 +1,8 @@ +use masterror::{AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(category = AppErrorKind::Internal)] +struct MissingCode; + +fn main() {} diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr new file mode 100644 index 0000000..037fac8 --- /dev/null +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -0,0 +1,13 @@ +error: missing `code = ...` in #[masterror(...)] + --> tests/ui/masterror/fail/missing_code.rs:5:1 + | +5 | #[masterror(category = AppErrorKind::Internal)] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: unused import: `AppErrorKind` + --> tests/ui/masterror/fail/missing_code.rs:1:17 + | +1 | use masterror::{AppErrorKind, Masterror}; + | ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.rs b/tests/ui/masterror/fail/unknown_option.rs new file mode 100644 index 0000000..67aa586 --- /dev/null +++ b/tests/ui/masterror/fail/unknown_option.rs @@ -0,0 +1,8 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("oops")] +#[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, unknown)] +struct Unknown; + +fn main() {} diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr new file mode 100644 index 0000000..1822edf --- /dev/null +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -0,0 +1,13 @@ +error: unknown #[masterror] option `unknown` + --> tests/ui/masterror/fail/unknown_option.rs:5:74 + | +5 | #[masterror(code = AppCode::Internal, category = AppErrorKind::Internal, unknown)] + | ^^^^^^^ + +warning: unused imports: `AppCode` and `AppErrorKind` + --> tests/ui/masterror/fail/unknown_option.rs:1:17 + | +1 | use masterror::{AppCode, AppErrorKind, Masterror}; + | ^^^^^^^ ^^^^^^^^^^^^ + | + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/pass/struct.rs b/tests/ui/masterror/pass/struct.rs new file mode 100644 index 0000000..19c2b5d --- /dev/null +++ b/tests/ui/masterror/pass/struct.rs @@ -0,0 +1,19 @@ +use masterror::{AppCode, AppErrorKind, Masterror}; + +#[derive(Debug, Masterror)] +#[error("simple {value}")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + telemetry(), + map.problem = "urn:example:internal" +)] +struct Simple { + value: u8 +} + +fn main() { + let err = Simple { value: 1 }; + let converted: masterror::Error = err.into(); + assert_eq!(converted.code, AppCode::Internal); +} From dd570156127722bf46450814e00bbb4036bbe452 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:27:04 +0700 Subject: [PATCH 05/25] Fix clippy lint and update version --- CHANGELOG.md | 6 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +++++++------- README.ru.md | 4 ++-- src/app_error/tests.rs | 2 ++ src/response/details.rs | 2 ++ .../ui/app_error/fail/enum_missing_variant.stderr | 7 +++---- tests/ui/app_error/fail/missing_code.stderr | 4 ++-- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../ui/formatter/fail/implicit_after_named.stderr | 1 - tests/ui/formatter/fail/unsupported_flag.stderr | 4 ++-- .../ui/formatter/fail/unsupported_formatter.stderr | 4 ++-- tests/ui/formatter/fail/uppercase_binary.stderr | 4 ++-- tests/ui/formatter/fail/uppercase_pointer.stderr | 4 ++-- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- tests/ui/masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../ui/masterror/fail/enum_missing_variant.stderr | 7 +++---- tests/ui/masterror/fail/missing_category.stderr | 4 ++-- tests/ui/masterror/fail/missing_code.stderr | 4 ++-- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 23 files changed, 47 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db66fb..da23ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.13.1] - 2025-09-23 + +### Fixed +- Documented allowances for `clippy::result_large_err` on APIs that intentionally + expose the rich `AppError` payload, restoring lint-clean builds. + ## [0.13.0] - 2025-09-23 ### Added diff --git a/Cargo.lock b/Cargo.lock index a038364..a743fc1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,7 +1606,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.13.0" +version = "0.13.1" dependencies = [ "actix-web", "axum", diff --git a/Cargo.toml b/Cargo.toml index 7437da7..dfc4e1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.13.0" +version = "0.13.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 1844265..8459c40 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.13.0", default-features = false } +masterror = { version = "0.13.1", default-features = false } # or with features: -# masterror = { version = "0.13.0", features = [ +# masterror = { version = "0.13.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -76,10 +76,10 @@ masterror = { version = "0.13.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.13.0", default-features = false } +masterror = { version = "0.13.1", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.13.0", features = [ +# masterror = { version = "0.13.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -709,13 +709,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.13.0", default-features = false } +masterror = { version = "0.13.1", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.13.0", features = [ +masterror = { version = "0.13.1", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -724,7 +724,7 @@ masterror = { version = "0.13.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.13.0", features = [ +masterror = { version = "0.13.1", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 43fb663..e16cf76 100644 --- a/README.ru.md +++ b/README.ru.md @@ -39,9 +39,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.13.0", default-features = false } +masterror = { version = "0.13.1", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.13.0", features = [ +# masterror = { version = "0.13.1", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index b18ae4f..b0cfa9a 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -203,6 +203,8 @@ fn log_uses_kind_and_code() { #[test] fn result_alias_is_generic() { + // The alias intentionally preserves the full AppError payload size. + #[allow(clippy::result_large_err)] fn app() -> super::AppResult { Ok(1) } diff --git a/src/response/details.rs b/src/response/details.rs index ceca799..92472ca 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -54,6 +54,8 @@ impl ErrorResponse { /// assert!(resp.details.is_some()); /// # } /// ``` + // AppError carries telemetry metadata; keep the rich payload despite the lint. + #[allow(clippy::result_large_err)] pub fn with_details(self, payload: T) -> AppResult where T: Serialize diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index bbc297c..d000de1 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("without")] -9 | | Without, - | |___________^ +8 | #[error("without")] + | ^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 4f02301..70ccade 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index 021c135..c615e98 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^^^^^^^^^^^^^^^^^^^^^ + | ^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b8f363..5b08225 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index be76742..d416399 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,5 +8,4 @@ error: multiple unused formatting arguments | argument never used | argument never used | - = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index b8bf229..d7acdb1 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index a6a40c2..5869420 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 | 4 | #[error("{value:y}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index 3d332c7..bbe04b4 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 | 4 | #[error("{value:B}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 0bd10fa..2c30e71 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 | 4 | #[error("{value:P}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index 113a10d..c3fb86b 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index 9ada290..b331baa 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index fd151cc..b2658a1 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 5a25e12..83d517f 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("missing")] -9 | | Missing - | |___________^ +8 | #[error("missing")] + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index bdadf45..f929951 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 037fac8..34abc91 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index 1822edf..d579838 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default From 747a4dd4f36c0ae29ac8fa6eb3698a98314a819b Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:11:39 +0700 Subject: [PATCH 06/25] Add telemetry hook and metrics integration --- CHANGELOG.md | 21 ++++ Cargo.lock | 99 ++++++++++++++- Cargo.toml | 23 +++- README.md | 31 +++-- src/app_error.rs | 11 +- src/app_error/constructors.rs | 4 +- src/app_error/context.rs | 11 +- src/app_error/core.rs | 229 +++++++++++++++++++++++++++------- src/app_error/tests.rs | 209 +++++++++++++++++++++++++++++++ src/convert/actix.rs | 1 + src/convert/axum.rs | 18 +-- src/response/axum_impl.rs | 5 +- 12 files changed, 578 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da23ee8..a2526e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,27 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.14.0] - 2025-09-24 + +### Added +- Introduced optional `tracing`, `metrics` and `backtrace` features. When + enabled they emit structured `tracing` events, increment the + `error_total{code,category}` counter and capture lazy [`Backtrace`] snapshots + from a new `AppError::emit_telemetry` hook. + +### Changed +- Reworked the `AppError` core to emit telemetry exactly once, track dirty + mutations and expose a crate-private `new_raw` constructor for contexts that + enrich errors before flushing instrumentation. +- Updated Axum and Actix integrations to rely on the telemetry hook instead of + manually logging errors while preserving backward-compatible APIs. + +### Tests +- Added tracing dispatcher coverage to assert a single telemetry event with MDC + propagated `trace_id` values. +- Installed a deterministic metrics recorder in unit tests to confirm + `error_total` increments once per error. + ## [0.13.1] - 2025-09-23 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index a743fc1..0400bbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -193,6 +193,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -1585,6 +1597,12 @@ version = "0.4.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +[[package]] +name = "log-mdc" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" + [[package]] name = "lru-slab" version = "0.1.2" @@ -1606,15 +1624,18 @@ dependencies = [ [[package]] name = "masterror" -version = "0.13.1" +version = "0.14.0" dependencies = [ "actix-web", "axum", "config", "http 1.3.1", "js-sys", + "log", + "log-mdc", "masterror-derive", "masterror-template", + "metrics", "redis", "reqwest", "serde", @@ -1628,6 +1649,7 @@ dependencies = [ "tokio", "toml", "tracing", + "tracing-subscriber", "trybuild", "utoipa", "uuid", @@ -1671,6 +1693,16 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "metrics" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25dea7ac8057892855ec285c440160265225438c3c45072613c25a4b26e98ef5" +dependencies = [ + "ahash", + "portable-atomic", +] + [[package]] name = "mime" version = "0.3.17" @@ -1725,6 +1757,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +dependencies = [ + "windows-sys 0.52.0", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -1972,6 +2013,12 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "portable-atomic" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" + [[package]] name = "potential_utf" version = "0.1.3" @@ -2674,6 +2721,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3125,6 +3181,15 @@ dependencies = [ "syn", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -3358,6 +3423,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -3532,6 +3623,12 @@ dependencies = [ "syn", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/Cargo.toml b/Cargo.toml index dfc4e1d..0bfdc16 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.13.1" +version = "0.14.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -50,6 +50,9 @@ readme = "README.md" [features] default = [] +tracing = ["dep:tracing", "dep:log", "dep:log-mdc"] +metrics = ["dep:metrics"] +backtrace = [] axum = ["dep:axum", "dep:serde_json"] actix = ["dep:actix-web", "dep:serde_json"] @@ -77,7 +80,10 @@ masterror-template = { version = "0.3.6" } [dependencies] masterror-derive = { version = "0.7" } masterror-template = { workspace = true } -tracing = "0.1" +tracing = { version = "0.1", optional = true } +log = { version = "0.4", optional = true } +log-mdc = { version = "0.1", optional = true } +metrics = { version = "0.24", optional = true } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", optional = true } @@ -125,6 +131,7 @@ tokio = { version = "1", features = [ trybuild = "1" toml = "0.9" tempfile = "3" +tracing-subscriber = { version = "0.3", features = ["registry"] } [build-dependencies] serde = { version = "1", features = ["derive"] } @@ -136,6 +143,9 @@ feature_order = [ "actix", "openapi", "serde_json", + "tracing", + "metrics", + "backtrace", "sqlx", "sqlx-migrate", "reqwest", @@ -176,6 +186,15 @@ description = "Generate utoipa OpenAPI schema for ErrorResponse" [package.metadata.masterror.readme.features.serde_json] description = "Attach structured JSON details to AppError" +[package.metadata.masterror.readme.features.tracing] +description = "Emit structured tracing events when errors are constructed" + +[package.metadata.masterror.readme.features.metrics] +description = "Increment `error_total{code,category}` counter for each AppError" + +[package.metadata.masterror.readme.features.backtrace] +description = "Capture lazy `Backtrace` snapshots when telemetry is flushed" + [package.metadata.masterror.readme.features.sqlx] description = "Classify sqlx_core::Error variants into AppError kinds" diff --git a/README.md b/README.md index 8459c40..3b8adc6 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,14 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.13.1", default-features = false } +masterror = { version = "0.14.0", default-features = false } # or with features: -# masterror = { version = "0.13.1", features = [ +# masterror = { version = "0.14.0", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "sqlx-migrate", "reqwest", "redis", -# "validator", "config", "tokio", "multipart", -# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" +# "tracing", "metrics", "backtrace", "sqlx", +# "sqlx-migrate", "reqwest", "redis", "validator", +# "config", "tokio", "multipart", "teloxide", +# "telegram-webapp-sdk", "frontend", "turnkey" # ] } ~~~ @@ -76,14 +77,15 @@ masterror = { version = "0.13.1", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.13.1", default-features = false } +masterror = { version = "0.14.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.13.1", features = [ +# masterror = { version = "0.14.0", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "sqlx-migrate", "reqwest", "redis", -# "validator", "config", "tokio", "multipart", -# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" +# "tracing", "metrics", "backtrace", "sqlx", +# "sqlx-migrate", "reqwest", "redis", "validator", +# "config", "tokio", "multipart", "teloxide", +# "telegram-webapp-sdk", "frontend", "turnkey" # ] } ~~~ @@ -671,6 +673,9 @@ assert_eq!(resp.status, 401); - `actix` — Actix Web ResponseError and Responder implementations - `openapi` — Generate utoipa OpenAPI schema for ErrorResponse - `serde_json` — Attach structured JSON details to AppError +- `tracing` — Emit structured tracing events when errors are constructed +- `metrics` — Increment `error_total{code,category}` counter for each AppError +- `backtrace` — Capture lazy `Backtrace` snapshots when telemetry is flushed - `sqlx` — Classify sqlx_core::Error variants into AppError kinds - `sqlx-migrate` — Map sqlx::migrate::MigrateError into AppError (Database) - `reqwest` — Classify reqwest::Error as timeout/network/external API @@ -709,13 +714,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.13.1", default-features = false } +masterror = { version = "0.14.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.13.1", features = [ +masterror = { version = "0.14.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -724,7 +729,7 @@ masterror = { version = "0.13.1", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.13.1", features = [ +masterror = { version = "0.14.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/app_error.rs b/src/app_error.rs index c34406c..c206df3 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -53,11 +53,14 @@ //! } //! ``` //! -//! ## Logging +//! ## Telemetry //! -//! [`AppError::log`] emits a single structured `tracing::error!` event with -//! `kind`, `code` and optional `message` fields. Prefer calling it at the -//! transport boundary (e.g. in `IntoResponse`) to avoid duplicate logs. +//! [`AppError::log`] flushes telemetry once: it emits a structured `tracing` +//! event (when the `tracing` feature is enabled), increments the +//! `error_total{code,category}` counter (with the `metrics` feature) and +//! captures a lazy [`Backtrace`] snapshot (with the `backtrace` feature). +//! Constructors and framework integrations call it automatically, so manual +//! usage is rarely required. mod constructors; mod context; diff --git a/src/app_error/constructors.rs b/src/app_error/constructors.rs index 20f7521..30abece 100644 --- a/src/app_error/constructors.rs +++ b/src/app_error/constructors.rs @@ -62,8 +62,8 @@ impl AppError { /// assert!(err.message.is_none()); /// ``` pub fn database(msg: Option>) -> Self { - let mut err = Self::bare(AppErrorKind::Database); - err.message = msg; + let err = Self::new_raw(AppErrorKind::Database, msg); + err.emit_telemetry(); err } diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 61b1901..57cb5cc 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -129,13 +129,16 @@ impl Context { )); } - let mut error = AppError::bare(self.category).with_code(self.code); + let mut error = AppError::new_raw(self.category, None); + error.code = self.code; if !self.fields.is_empty() { - error = error.with_fields(self.fields); + error.metadata.extend(self.fields); } if matches!(self.edit_policy, MessageEditPolicy::Redact) { - error = error.redactable(); + error.edit_policy = MessageEditPolicy::Redact; } - error.with_source(source) + let error = error.with_source(source); + error.emit_telemetry(); + error } } diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 3787d9e..3625383 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,11 +1,15 @@ +#[cfg(feature = "backtrace")] +use std::sync::OnceLock; use std::{ backtrace::Backtrace, borrow::Cow, error::Error as StdError, - fmt::{Display, Formatter, Result as FmtResult} + fmt::{Display, Formatter, Result as FmtResult}, + sync::atomic::{AtomicBool, Ordering} }; -use tracing::error; +#[cfg(feature = "tracing")] +use tracing::{Level, event}; use super::metadata::{Field, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; @@ -20,6 +24,51 @@ pub enum MessageEditPolicy { Redact } +#[cfg(feature = "backtrace")] +#[derive(Debug)] +struct BacktraceSlot { + cell: OnceLock> +} + +#[cfg(feature = "backtrace")] +impl BacktraceSlot { + const fn new() -> Self { + Self { + cell: OnceLock::new() + } + } + + fn with(backtrace: Backtrace) -> Self { + let slot = Self::new(); + let _ = slot.cell.set(Some(backtrace)); + slot + } + + fn set(&mut self, backtrace: Backtrace) { + *self = Self::with(backtrace); + } + + fn get(&self) -> Option<&Backtrace> { + self.cell.get().and_then(|value| value.as_ref()) + } + + fn capture_if_absent(&self) -> Option<&Backtrace> { + self.cell + .get_or_init(|| Some(Backtrace::capture())) + .as_ref() + } +} + +#[cfg(feature = "backtrace")] +impl Default for BacktraceSlot { + fn default() -> Self { + Self::new() + } +} + +#[cfg(not(feature = "backtrace"))] +type BacktraceSlot = Option; + /// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] pub struct Error { @@ -38,7 +87,8 @@ pub struct Error { /// Optional authentication challenge for `WWW-Authenticate`. pub www_authenticate: Option, source: Option>, - backtrace: Option + backtrace: BacktraceSlot, + telemetry_dirty: AtomicBool } impl Display for Error { @@ -81,6 +131,86 @@ impl StdError for Error { pub type AppResult = Result; impl Error { + pub(crate) fn new_raw(kind: AppErrorKind, message: Option>) -> Self { + Self { + code: AppCode::from(kind), + kind, + message, + metadata: Metadata::new(), + edit_policy: MessageEditPolicy::Preserve, + retry: None, + www_authenticate: None, + source: None, + backtrace: BacktraceSlot::default(), + telemetry_dirty: AtomicBool::new(true) + } + } + + fn mark_dirty(&self) { + self.telemetry_dirty.store(true, Ordering::Release); + } + + fn take_dirty(&self) -> bool { + self.telemetry_dirty.swap(false, Ordering::AcqRel) + } + + #[cfg(feature = "backtrace")] + fn capture_backtrace(&self) -> Option<&Backtrace> { + self.backtrace.capture_if_absent() + } + + #[cfg(not(feature = "backtrace"))] + fn capture_backtrace(&self) -> Option<&Backtrace> { + self.backtrace.as_ref() + } + + #[cfg(feature = "backtrace")] + fn set_backtrace_slot(&mut self, backtrace: Backtrace) { + self.backtrace.set(backtrace); + } + + #[cfg(not(feature = "backtrace"))] + fn set_backtrace_slot(&mut self, backtrace: Backtrace) { + self.backtrace = Some(backtrace); + } + + pub(crate) fn emit_telemetry(&self) { + if self.take_dirty() { + #[cfg(feature = "backtrace")] + let _ = self.capture_backtrace(); + + #[cfg(feature = "metrics")] + { + metrics::counter!( + "error_total", + "code" => self.code.as_str(), + "category" => kind_label(self.kind) + ) + .increment(1); + } + + #[cfg(feature = "tracing")] + { + let message = self.message.as_deref(); + let retry_seconds = self.retry.map(|value| value.after_seconds); + let trace_id = log_mdc::get("trace_id", |value| value.map(str::to_owned)); + event!( + target: "masterror::error", + Level::ERROR, + code = self.code.as_str(), + category = kind_label(self.kind), + message = message, + retry_seconds, + redactable = matches!(self.edit_policy, MessageEditPolicy::Redact), + metadata_len = self.metadata.len() as u64, + www_authenticate = self.www_authenticate.as_deref(), + trace_id = trace_id.as_deref(), + "app error constructed" + ); + } + } + } + /// Create a new [`Error`] with a kind and message. /// /// This is equivalent to [`Error::with`], provided for API symmetry and to @@ -104,17 +234,9 @@ impl Error { /// intent. #[must_use] pub fn with(kind: AppErrorKind, msg: impl Into>) -> Self { - Self { - code: AppCode::from(kind), - kind, - message: Some(msg.into()), - metadata: Metadata::new(), - edit_policy: MessageEditPolicy::Preserve, - retry: None, - www_authenticate: None, - source: None, - backtrace: None - } + let err = Self::new_raw(kind, Some(msg.into())); + err.emit_telemetry(); + err } /// Create a message-less error with the given kind. @@ -122,23 +244,16 @@ impl Error { /// Useful when the kind alone conveys sufficient information to the client. #[must_use] pub fn bare(kind: AppErrorKind) -> Self { - Self { - code: AppCode::from(kind), - kind, - message: None, - metadata: Metadata::new(), - edit_policy: MessageEditPolicy::Preserve, - retry: None, - www_authenticate: None, - source: None, - backtrace: None - } + let err = Self::new_raw(kind, None); + err.emit_telemetry(); + err } /// Override the machine-readable [`AppCode`]. #[must_use] pub fn with_code(mut self, code: AppCode) -> Self { self.code = code; + self.mark_dirty(); self } @@ -150,6 +265,7 @@ impl Error { self.retry = Some(RetryAdvice { after_seconds: secs }); + self.mark_dirty(); self } @@ -157,6 +273,7 @@ impl Error { #[must_use] pub fn with_www_authenticate(mut self, value: impl Into) -> Self { self.www_authenticate = Some(value.into()); + self.mark_dirty(); self } @@ -164,6 +281,7 @@ impl Error { #[must_use] pub fn with_field(mut self, field: Field) -> Self { self.metadata.insert(field); + self.mark_dirty(); self } @@ -171,6 +289,7 @@ impl Error { #[must_use] pub fn with_fields(mut self, fields: impl IntoIterator) -> Self { self.metadata.extend(fields); + self.mark_dirty(); self } @@ -178,6 +297,7 @@ impl Error { #[must_use] pub fn with_metadata(mut self, metadata: Metadata) -> Self { self.metadata = metadata; + self.mark_dirty(); self } @@ -185,6 +305,7 @@ impl Error { #[must_use] pub fn redactable(mut self) -> Self { self.edit_policy = MessageEditPolicy::Redact; + self.mark_dirty(); self } @@ -192,13 +313,15 @@ impl Error { #[must_use] pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { self.source = Some(Box::new(source)); + self.mark_dirty(); self } /// Attach a captured backtrace. #[must_use] pub fn with_backtrace(mut self, backtrace: Backtrace) -> Self { - self.backtrace = Some(backtrace); + self.set_backtrace_slot(backtrace); + self.mark_dirty(); self } @@ -208,10 +331,11 @@ impl Error { &self.metadata } - /// Borrow the backtrace if present. + /// Borrow the backtrace, capturing it lazily when the `backtrace` feature + /// is enabled. #[must_use] pub fn backtrace(&self) -> Option<&Backtrace> { - self.backtrace.as_ref() + self.capture_backtrace() } /// Borrow the source if present. @@ -229,26 +353,43 @@ impl Error { } } - /// Log the error once at the boundary with stable fields. + /// Emit telemetry (`tracing` event, metrics counter, backtrace capture). /// - /// Emits a `tracing::error!` with `kind`, `code`, optional `message` and - /// metadata length. No internals or sources are leaked. + /// Downstream code can call this to guarantee telemetry after mutating the + /// error. It is automatically invoked by constructors and conversions. pub fn log(&self) { - match &self.message { - Some(m) => error!( - kind = ?self.kind, - code = %self.code, - message = %m, - metadata_len = self.metadata.len() - ), - None => error!( - kind = ?self.kind, - code = %self.code, - metadata_len = self.metadata.len() - ) - } + self.emit_telemetry(); } } /// Backwards-compatible export using the historical name. pub use Error as AppError; + +#[cfg(any(feature = "metrics", feature = "tracing"))] +fn kind_label(kind: AppErrorKind) -> &'static str { + match kind { + AppErrorKind::NotFound => "NotFound", + AppErrorKind::Validation => "Validation", + AppErrorKind::Conflict => "Conflict", + AppErrorKind::Unauthorized => "Unauthorized", + AppErrorKind::Forbidden => "Forbidden", + AppErrorKind::NotImplemented => "NotImplemented", + AppErrorKind::Internal => "Internal", + AppErrorKind::BadRequest => "BadRequest", + AppErrorKind::TelegramAuth => "TelegramAuth", + AppErrorKind::InvalidJwt => "InvalidJwt", + AppErrorKind::Database => "Database", + AppErrorKind::Service => "Service", + AppErrorKind::Config => "Config", + AppErrorKind::Turnkey => "Turnkey", + AppErrorKind::Timeout => "Timeout", + AppErrorKind::Network => "Network", + AppErrorKind::RateLimited => "RateLimited", + AppErrorKind::DependencyUnavailable => "DependencyUnavailable", + AppErrorKind::Serialization => "Serialization", + AppErrorKind::Deserialization => "Deserialization", + AppErrorKind::ExternalApi => "ExternalApi", + AppErrorKind::Queue => "Queue", + AppErrorKind::Cache => "Cache" + } +} diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index b0cfa9a..df607ca 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -201,6 +201,215 @@ fn log_uses_kind_and_code() { err.log(); } +#[cfg(feature = "tracing")] +#[test] +fn telemetry_emits_single_tracing_event_with_trace_id() { + use std::{ + fmt, + sync::{Arc, Mutex} + }; + + use tracing::{ + Dispatch, Event, Subscriber, dispatcher, + field::{Field, Visit} + }; + use tracing_subscriber::{ + Registry, + layer::{Context, Layer, SubscriberExt} + }; + + #[derive(Default, Clone)] + struct RecordedEvent { + trace_id: Option, + code: Option, + category: Option + } + + struct RecordingLayer { + events: Arc>> + } + + impl Layer for RecordingLayer + where + S: Subscriber + { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + if event.metadata().target() != "masterror::error" { + return; + } + + let mut record = RecordedEvent::default(); + event.record(&mut EventVisitor { + record: &mut record + }); + self.events.lock().expect("events lock").push(record); + } + } + + struct EventVisitor<'a> { + record: &'a mut RecordedEvent + } + + impl<'a> Visit for EventVisitor<'a> { + fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) { + let normalized = normalize_debug(value); + match field.name() { + "trace_id" => self.record.trace_id = Some(normalized), + "code" => self.record.code = Some(normalized), + "category" => self.record.category = Some(normalized), + _ => {} + } + } + } + + fn normalize_debug(value: &dyn fmt::Debug) -> String { + let mut rendered = format!("{value:?}"); + while let Some(stripped) = rendered + .strip_prefix("Some(") + .and_then(|s| s.strip_suffix(')')) + { + rendered = stripped.to_owned(); + } + rendered.trim_matches('"').to_owned() + } + + let events = Arc::new(Mutex::new(Vec::new())); + let layer = RecordingLayer { + events: events.clone() + }; + let subscriber = Registry::default().with(layer); + let dispatch = Dispatch::new(subscriber); + + dispatcher::with_default(&dispatch, || { + log_mdc::insert("trace_id", "trace-123"); + let err = AppError::internal("boom"); + err.log(); + log_mdc::remove("trace_id"); + + let events = events.lock().expect("events lock"); + assert_eq!(events.len(), 1, "expected exactly one tracing event"); + + let event = &events[0]; + assert_eq!(event.code.as_deref(), Some(AppCode::Internal.as_str())); + assert_eq!(event.category.as_deref(), Some("Internal")); + assert!( + event + .trace_id + .as_deref() + .is_some_and(|value| value.contains("trace-123")) + ); + }); +} + +#[cfg(feature = "metrics")] +#[test] +fn metrics_counter_is_incremented_once() { + use std::{ + collections::HashMap, + sync::{Arc, Mutex} + }; + + use metrics::{ + Counter, CounterFn, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit + }; + + #[derive(Clone)] + struct MetricsCounterHandle { + name: String, + labels: Vec<(String, String)>, + counts: Arc), u64>>> + } + + impl CounterFn for MetricsCounterHandle { + fn increment(&self, value: u64) { + let mut map = self.counts.lock().expect("counter map"); + *map.entry((self.name.clone(), self.labels.clone())) + .or_default() += value; + } + + fn absolute(&self, value: u64) { + let mut map = self.counts.lock().expect("counter map"); + map.insert((self.name.clone(), self.labels.clone()), value); + } + } + + struct CountingRecorder { + counts: Arc), u64>>> + } + + impl Recorder for CountingRecorder { + fn describe_counter( + &self, + _key: KeyName, + _unit: Option, + _description: SharedString + ) { + } + + fn describe_gauge(&self, _key: KeyName, _unit: Option, _description: SharedString) {} + + fn describe_histogram( + &self, + _key: KeyName, + _unit: Option, + _description: SharedString + ) { + } + + fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter { + let labels = key + .labels() + .map(|label| (label.key().to_owned(), label.value().to_owned())) + .collect::>(); + Counter::from_arc(Arc::new(MetricsCounterHandle { + name: key.name().to_owned(), + labels, + counts: self.counts.clone() + })) + } + + fn register_gauge(&self, _key: &Key, _metadata: &Metadata<'_>) -> Gauge { + Gauge::noop() + } + + fn register_histogram(&self, _key: &Key, _metadata: &Metadata<'_>) -> Histogram { + Histogram::noop() + } + } + + use std::sync::OnceLock; + + static RECORDER_COUNTS: OnceLock), u64>>>> = + OnceLock::new(); + + let counts = RECORDER_COUNTS + .get_or_init(|| { + let counts = Arc::new(Mutex::new(HashMap::new())); + metrics::set_global_recorder(CountingRecorder { + counts: counts.clone() + }) + .expect("install recorder"); + counts + }) + .clone(); + + counts.lock().expect("counter map").clear(); + + let err = AppError::forbidden("denied"); + err.log(); + + let key = ( + "error_total".to_owned(), + vec![ + ("code".to_owned(), AppCode::Forbidden.as_str().to_owned()), + ("category".to_owned(), "Forbidden".to_owned()), + ] + ); + + let counts = counts.lock().expect("counter map"); + assert_eq!(counts.get(&key).copied(), Some(1)); +} + #[test] fn result_alias_is_generic() { // The alias intentionally preserves the full AppError payload size. diff --git a/src/convert/actix.rs b/src/convert/actix.rs index 268e60a..e91edc9 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -85,6 +85,7 @@ impl ResponseError for AppError { /// Produce JSON body with `ErrorResponse`. Does not leak sources. fn error_response(&self) -> HttpResponse { + self.emit_telemetry(); let body = ErrorResponse::from(self); let mut builder = HttpResponse::build(self.status_code()); if let Some(retry) = body.retry { diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 5c65a07..6abcf1b 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -9,7 +9,8 @@ //! Err(...)` or directly `return AppError::...(...)` and get a JSON error //! body (when the `serde_json` feature is enabled) or an empty body //! otherwise. -//! - Logs each error once at the HTTP boundary using `tracing::error`. +//! - Flushes [`AppError`] telemetry at the HTTP boundary (tracing event, +//! metrics counter, lazy backtrace). //! //! ## Wire payload //! @@ -44,7 +45,6 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response} }; -use tracing::error; use crate::AppError; #[cfg(feature = "serde_json")] @@ -64,20 +64,14 @@ impl AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { - let status = self.http_status(); - - // Log once at the boundary with stable fields. - error!( - status = status.as_u16(), - kind = ?self.kind, - msg = self.message.as_deref().unwrap_or(""), - "AppError -> HTTP response" - ); + let err = self; + err.emit_telemetry(); + let status = err.http_status(); #[cfg(feature = "serde_json")] { // Build the stable wire contract (includes `code`). - let body: ErrorResponse = self.into(); + let body: ErrorResponse = err.into(); return body.into_response(); } diff --git a/src/response/axum_impl.rs b/src/response/axum_impl.rs index 2dacae7..70b2bf8 100644 --- a/src/response/axum_impl.rs +++ b/src/response/axum_impl.rs @@ -42,8 +42,9 @@ impl IntoResponse for ErrorResponse { /// Convert `AppError` into the stable wire model and reuse its `IntoResponse`. impl IntoResponse for AppError { fn into_response(self) -> Response { - // Use the canonical mapping defined in `From<&AppError> for ErrorResponse` - let wire: ErrorResponse = (&self).into(); + let err = self; + err.emit_telemetry(); + let wire: ErrorResponse = err.into(); wire.into_response() } } From c18ebd5d6a3d9de0e72c2cacbbef51112952a2a1 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 12:57:40 +0700 Subject: [PATCH 07/25] Fix clippy regressions and bump version --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +++++++------- src/app_error/core.rs | 4 ---- src/app_error/tests.rs | 39 +++++++++++++++++++++++++++------------ 6 files changed, 43 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2526e2..db0b209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.14.1] - 2025-09-24 + +### Fixed +- Removed the unused `BacktraceSlot::get` helper to restore builds with `-D warnings`. +- Simplified the metrics recorder test harness with dedicated types to satisfy + `clippy::type_complexity` without sacrificing coverage. + ## [0.14.0] - 2025-09-24 ### Added diff --git a/Cargo.lock b/Cargo.lock index 0400bbe..41638a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.14.0" +version = "0.14.1" dependencies = [ "actix-web", "axum", diff --git a/Cargo.toml b/Cargo.toml index 0bfdc16..68dbcc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.14.0" +version = "0.14.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 3b8adc6..45823d1 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.14.1", default-features = false } # or with features: -# masterror = { version = "0.14.0", features = [ +# masterror = { version = "0.14.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -77,10 +77,10 @@ masterror = { version = "0.14.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.14.1", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.14.0", features = [ +# masterror = { version = "0.14.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -714,13 +714,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.14.1", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.14.0", features = [ +masterror = { version = "0.14.1", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -729,7 +729,7 @@ masterror = { version = "0.14.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.14.0", features = [ +masterror = { version = "0.14.1", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 3625383..bcf43f2 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -48,10 +48,6 @@ impl BacktraceSlot { *self = Self::with(backtrace); } - fn get(&self) -> Option<&Backtrace> { - self.cell.get().and_then(|value| value.as_ref()) - } - fn capture_if_absent(&self) -> Option<&Backtrace> { self.cell .get_or_init(|| Some(Backtrace::capture())) diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index df607ca..d86af65 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -313,28 +313,44 @@ fn metrics_counter_is_incremented_once() { Counter, CounterFn, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit }; + #[derive(Clone, Debug, Eq, PartialEq, Hash)] + struct CounterKey { + name: String, + labels: Vec<(String, String)> + } + + impl CounterKey { + fn new(name: String, labels: Vec<(String, String)>) -> Self { + Self { + name, + labels + } + } + } + + type CounterMap = HashMap; + type SharedCounterMap = Arc>; + #[derive(Clone)] struct MetricsCounterHandle { - name: String, - labels: Vec<(String, String)>, - counts: Arc), u64>>> + key: CounterKey, + counts: SharedCounterMap } impl CounterFn for MetricsCounterHandle { fn increment(&self, value: u64) { let mut map = self.counts.lock().expect("counter map"); - *map.entry((self.name.clone(), self.labels.clone())) - .or_default() += value; + *map.entry(self.key.clone()).or_default() += value; } fn absolute(&self, value: u64) { let mut map = self.counts.lock().expect("counter map"); - map.insert((self.name.clone(), self.labels.clone()), value); + map.insert(self.key.clone(), value); } } struct CountingRecorder { - counts: Arc), u64>>> + counts: SharedCounterMap } impl Recorder for CountingRecorder { @@ -361,9 +377,9 @@ fn metrics_counter_is_incremented_once() { .labels() .map(|label| (label.key().to_owned(), label.value().to_owned())) .collect::>(); + let counter_key = CounterKey::new(key.name().to_owned(), labels); Counter::from_arc(Arc::new(MetricsCounterHandle { - name: key.name().to_owned(), - labels, + key: counter_key, counts: self.counts.clone() })) } @@ -379,8 +395,7 @@ fn metrics_counter_is_incremented_once() { use std::sync::OnceLock; - static RECORDER_COUNTS: OnceLock), u64>>>> = - OnceLock::new(); + static RECORDER_COUNTS: OnceLock = OnceLock::new(); let counts = RECORDER_COUNTS .get_or_init(|| { @@ -398,7 +413,7 @@ fn metrics_counter_is_incremented_once() { let err = AppError::forbidden("denied"); err.log(); - let key = ( + let key = CounterKey::new( "error_total".to_owned(), vec![ ("code".to_owned(), AppCode::Forbidden.as_str().to_owned()), From 023697c48d5e5a4d312d613fced67d27c244d49a Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 13:05:56 +0700 Subject: [PATCH 08/25] Refactor AppError storage and update lint configuration --- CHANGELOG.md | 11 ++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++++++------ src/app_error/core.rs | 47 ++++++++++++++++++++++++++++++----------- src/app_error/tests.rs | 2 -- src/response/details.rs | 2 -- src/response/mapping.rs | 16 ++++++-------- 8 files changed, 61 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a2526e2..18754c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.14.1] - 2025-09-25 + +### Changed +- Boxed the internal `AppError` payload inside a new `ErrorInner` allocation, + keeping public field access via `Deref` while shrinking the error to a + pointer-sized handle that shares metadata, retry hints, and backtrace state. + +### Removed +- Dropped `clippy::result_large_err` allowances in response helpers and tests + now that `AppError` is pointer-sized and lint-clean without suppressions. + ## [0.14.0] - 2025-09-24 ### Added diff --git a/Cargo.lock b/Cargo.lock index 0400bbe..41638a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1624,7 +1624,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.14.0" +version = "0.14.1" dependencies = [ "actix-web", "axum", diff --git a/Cargo.toml b/Cargo.toml index 0bfdc16..68dbcc2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.14.0" +version = "0.14.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 3b8adc6..45823d1 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.14.1", default-features = false } # or with features: -# masterror = { version = "0.14.0", features = [ +# masterror = { version = "0.14.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -77,10 +77,10 @@ masterror = { version = "0.14.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.14.1", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.14.0", features = [ +# masterror = { version = "0.14.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -714,13 +714,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.14.1", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.14.0", features = [ +masterror = { version = "0.14.1", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -729,7 +729,7 @@ masterror = { version = "0.14.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.14.0", features = [ +masterror = { version = "0.14.1", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 3625383..6d9711f 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -5,6 +5,7 @@ use std::{ borrow::Cow, error::Error as StdError, fmt::{Display, Formatter, Result as FmtResult}, + ops::{Deref, DerefMut}, sync::atomic::{AtomicBool, Ordering} }; @@ -69,9 +70,9 @@ impl Default for BacktraceSlot { #[cfg(not(feature = "backtrace"))] type BacktraceSlot = Option; -/// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] -pub struct Error { +#[doc(hidden)] +pub struct ErrorInner { /// Stable machine-readable error code. pub code: AppCode, /// Semantic error category. @@ -91,6 +92,26 @@ pub struct Error { telemetry_dirty: AtomicBool } +/// Rich application error preserving domain code, taxonomy and metadata. +#[derive(Debug)] +pub struct Error { + inner: Box +} + +impl Deref for Error { + type Target = ErrorInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for Error { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} + impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { Display::fmt(&self.kind, f) @@ -133,16 +154,18 @@ pub type AppResult = Result; impl Error { pub(crate) fn new_raw(kind: AppErrorKind, message: Option>) -> Self { Self { - code: AppCode::from(kind), - kind, - message, - metadata: Metadata::new(), - edit_policy: MessageEditPolicy::Preserve, - retry: None, - www_authenticate: None, - source: None, - backtrace: BacktraceSlot::default(), - telemetry_dirty: AtomicBool::new(true) + inner: Box::new(ErrorInner { + code: AppCode::from(kind), + kind, + message, + metadata: Metadata::new(), + edit_policy: MessageEditPolicy::Preserve, + retry: None, + www_authenticate: None, + source: None, + backtrace: BacktraceSlot::default(), + telemetry_dirty: AtomicBool::new(true) + }) } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index df607ca..749d4fe 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -412,8 +412,6 @@ fn metrics_counter_is_incremented_once() { #[test] fn result_alias_is_generic() { - // The alias intentionally preserves the full AppError payload size. - #[allow(clippy::result_large_err)] fn app() -> super::AppResult { Ok(1) } diff --git a/src/response/details.rs b/src/response/details.rs index 92472ca..ceca799 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -54,8 +54,6 @@ impl ErrorResponse { /// assert!(resp.details.is_some()); /// # } /// ``` - // AppError carries telemetry metadata; keep the rich payload despite the lint. - #[allow(clippy::result_large_err)] pub fn with_details(self, payload: T) -> AppResult where T: Serialize diff --git a/src/response/mapping.rs b/src/response/mapping.rs index e907581..cc522c5 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -11,18 +11,14 @@ impl Display for ErrorResponse { } impl From for ErrorResponse { - fn from(err: AppError) -> Self { - let AppError { - code, - kind, - message, - retry, - www_authenticate, - .. - } = err; + fn from(mut err: AppError) -> Self { + let kind = err.kind; + let code = err.code; + let retry = err.retry.take(); + let www_authenticate = err.www_authenticate.take(); let status = kind.http_status(); - let message = match message { + let message = match err.message.take() { Some(msg) => msg.into_owned(), None => kind.to_string() }; From 5996c97d95ef1959cb6260c804feb77ae0581b44 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 15:21:48 +0700 Subject: [PATCH 09/25] Add RFC7807 responses and tonic conversion --- CHANGELOG.md | 22 + Cargo.lock | 194 ++++- Cargo.toml | 8 +- README.md | 39 +- README.ru.md | 7 +- README.template.md | 20 +- src/convert.rs | 4 + src/convert/actix.rs | 50 +- src/convert/axum.rs | 99 +-- src/convert/tonic.rs | 204 +++++ src/lib.rs | 25 +- src/response.rs | 42 +- src/response/actix_impl.rs | 55 +- src/response/axum_impl.rs | 46 +- src/response/problem_json.rs | 756 ++++++++++++++++++ .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 31 files changed, 1427 insertions(+), 199 deletions(-) create mode 100644 src/convert/tonic.rs create mode 100644 src/response/problem_json.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a2526e2..6a9ac61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,28 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.15.0] - 2025-09-25 + +### Added +- Introduced a `response::problem_json` module with an RFC7807 `ProblemJson` + payload that serializes metadata, gRPC mappings and retry/authentication + hints while respecting the message redaction policy. +- Added an optional `tonic` feature exposing `TryFrom for tonic::Status` + with sanitized metadata and canonical gRPC code mapping. +- Published a compile-time `CODE_MAPPINGS` table mapping each `AppCode` to + HTTP, gRPC and problem type information for reuse across transports. + +### Changed +- Updated Axum and Actix integrations to emit `application/problem+json` + bodies, attach `Retry-After`/`WWW-Authenticate` headers automatically and + avoid leaking redactable messages or metadata. +- Re-exported `ProblemJson` from the crate root alongside `ErrorResponse` for + direct construction in custom handlers. + +### Tests +- Added unit coverage for the problem+json metadata sanitizer, header + propagation in Axum, and gRPC code mapping under the new `tonic` feature. + ## [0.14.0] - 2025-09-24 ### Added diff --git a/Cargo.lock b/Cargo.lock index 0400bbe..fe11b51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,28 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -267,20 +289,47 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core 0.4.5", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "itoa", + "matchit 0.7.3", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "sync_wrapper", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + [[package]] name = "axum" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" dependencies = [ - "axum-core", + "axum-core 0.5.2", "bytes", "futures-util", "http 1.3.1", "http-body", "http-body-util", "itoa", - "matchit", + "matchit 0.8.4", "memchr", "mime", "multer", @@ -291,7 +340,27 @@ dependencies = [ "serde_json", "serde_path_to_error", "sync_wrapper", - "tower", + "tower 0.5.2", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", "tower-layer", "tower-service", ] @@ -1082,6 +1151,25 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "h2" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.3.1", + "indexmap 2.11.4", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1232,9 +1320,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http 1.3.1", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1260,6 +1350,19 @@ dependencies = [ "tower-service", ] +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.17" @@ -1624,10 +1727,10 @@ dependencies = [ [[package]] name = "masterror" -version = "0.14.0" +version = "0.15.0" dependencies = [ "actix-web", - "axum", + "axum 0.8.4", "config", "http 1.3.1", "js-sys", @@ -1648,6 +1751,7 @@ dependencies = [ "tempfile", "tokio", "toml", + "tonic", "tracing", "tracing-subscriber", "trybuild", @@ -1671,6 +1775,12 @@ dependencies = [ name = "masterror-template" version = "0.3.6" +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "matchit" version = "0.8.4" @@ -2074,6 +2184,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", +] + [[package]] name = "psm" version = "0.1.26" @@ -2333,7 +2452,7 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-util", - "tower", + "tower 0.5.2", "tower-http", "tower-service", "url", @@ -3296,6 +3415,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.16" @@ -3348,6 +3478,56 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" +[[package]] +name = "tonic" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877c5b330756d856ffcc4553ab34a5684481ade925ecc54bcd1bf02b1d0d4d52" +dependencies = [ + "async-stream", + "async-trait", + "axum 0.7.9", + "base64 0.22.1", + "bytes", + "h2", + "http 1.3.1", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "prost", + "socket2 0.5.10", + "tokio", + "tokio-stream", + "tower 0.4.13", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 1.9.3", + "pin-project", + "pin-project-lite", + "rand 0.8.5", + "slab", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "tower" version = "0.5.2" @@ -3376,7 +3556,7 @@ dependencies = [ "http-body", "iri-string", "pin-project-lite", - "tower", + "tower 0.5.2", "tower-layer", "tower-service", ] diff --git a/Cargo.toml b/Cargo.toml index 0bfdc16..2d41449 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.14.0" +version = "0.15.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -71,6 +71,7 @@ teloxide = ["dep:teloxide-core"] telegram-webapp-sdk = ["dep:telegram-webapp-sdk"] frontend = ["dep:wasm-bindgen", "dep:js-sys", "dep:serde-wasm-bindgen"] turnkey = [] +tonic = ["dep:tonic"] openapi = ["dep:utoipa"] [workspace.dependencies] @@ -119,6 +120,7 @@ serde-wasm-bindgen = { version = "0.6", optional = true } uuid = { version = "1", default-features = false, features = [ "std" ] } +tonic = { version = "0.12", optional = true } [dev-dependencies] serde_json = "1" @@ -156,6 +158,7 @@ feature_order = [ "multipart", "teloxide", "telegram-webapp-sdk", + "tonic", "frontend", "turnkey", ] @@ -225,6 +228,9 @@ description = "Convert teloxide_core::RequestError into domain errors" [package.metadata.masterror.readme.features."telegram-webapp-sdk"] description = "Surface Telegram WebApp validation failures" +[package.metadata.masterror.readme.features.tonic] +description = "Convert AppError into tonic::Status with redaction" + [package.metadata.masterror.readme.features.frontend] description = "Log to the browser console and convert to JsValue on WASM" diff --git a/README.md b/README.md index 3b8adc6..766c012 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ and typed telemetry. 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`, `Metadata` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]`, `#[provide]` for domain mappings and structured telemetry @@ -38,20 +38,21 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.15.0", default-features = false } # or with features: -# masterror = { version = "0.14.0", features = [ +# masterror = { version = "0.15.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", # "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "frontend", "turnkey" +# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" # ] } ~~~ *Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* *Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* *Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* +*Since v0.15.0: RFC7807 `ProblemJson` responses for HTTP integrations and `tonic::Status` conversion.* --- @@ -61,7 +62,7 @@ masterror = { version = "0.14.0", default-features = false } - **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? }`. +- **Clean wire contract.** `ProblemJson { type?, title, status, detail?, code, grpc?, metadata? }` with `Retry-After` / `WWW-Authenticate` headers when present. - **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the @@ -77,15 +78,15 @@ masterror = { version = "0.14.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.15.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.14.0", features = [ +# masterror = { version = "0.15.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", # "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "frontend", "turnkey" +# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" # ] } ~~~ @@ -619,15 +620,18 @@ assert_eq!(display.to_string(), "404: Not Found"); Error response payload ~~~rust -use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse}; +use masterror::{AppError, AppErrorKind, ProblemJson}; 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""#); +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) +); -assert_eq!(resp.status, 401); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~ @@ -686,6 +690,7 @@ assert_eq!(resp.status, 401); - `multipart` — Handle axum multipart extraction errors - `teloxide` — Convert teloxide_core::RequestError into domain errors - `telegram-webapp-sdk` — Surface Telegram WebApp validation failures +- `tonic` — Convert AppError into tonic::Status with redaction - `frontend` — Log to the browser console and convert to JsValue on WASM - `turnkey` — Ship Turnkey-specific error taxonomy and conversions @@ -714,13 +719,13 @@ assert_eq!(resp.status, 401); Minimal core: ~~~toml -masterror = { version = "0.14.0", default-features = false } +masterror = { version = "0.15.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.14.0", features = [ +masterror = { version = "0.15.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -729,7 +734,7 @@ masterror = { version = "0.14.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.14.0", features = [ +masterror = { version = "0.15.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index e16cf76..1354483 100644 --- a/README.ru.md +++ b/README.ru.md @@ -19,7 +19,7 @@ ## Основные возможности -- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ErrorResponse`, `Metadata`. +- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata`. - Деривы `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]`, `#[provide]` для типизированного телеметрического контекста и прямых конверсий доменных ошибок. @@ -39,9 +39,9 @@ ~~~toml [dependencies] # минимальное ядро -masterror = { version = "0.13.1", default-features = false } +masterror = { version = "0.15.0", default-features = false } # или с нужными интеграциями -# masterror = { version = "0.13.1", features = [ +# masterror = { version = "0.15.0", features = [ # "axum", "actix", "openapi", "serde_json", # "sqlx", "sqlx-migrate", "reqwest", "redis", # "validator", "config", "tokio", "multipart", @@ -91,6 +91,7 @@ fn do_work(flag: bool) -> AppResult<()> { - `multipart` — обработка ошибок извлечения multipart в Axum. - `teloxide` — маппинг `teloxide_core::RequestError` в доменные категории. - `telegram-webapp-sdk` — обработка ошибок валидации данных Telegram WebApp. +- `tonic` — преобразование `AppError` в `tonic::Status` с учётом редактирования. - `frontend` — логирование в браузере и преобразование в `JsValue` для WASM. - `turnkey` — расширение таксономии для Turnkey SDK. diff --git a/README.template.md b/README.template.md index 1b399e7..886ef7b 100644 --- a/README.template.md +++ b/README.template.md @@ -19,7 +19,7 @@ and typed telemetry. 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`, `Metadata` +- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata` - Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[masterror(...)]`, `#[provide]` for domain mappings and structured telemetry @@ -48,6 +48,7 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } *Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* *Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* *Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* +*Since v0.15.0: RFC7807 `ProblemJson` responses for HTTP integrations and `tonic::Status` conversion.* --- @@ -57,7 +58,7 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } - **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? }`. +- **Clean wire contract.** `ProblemJson { type?, title, status, detail?, code, grpc?, metadata? }` with `Retry-After` / `WWW-Authenticate` headers when present. - **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. - **One log at boundary.** Log once with `tracing`. - **Less boilerplate.** Built-in conversions, compact prelude, and the @@ -611,15 +612,18 @@ assert_eq!(display.to_string(), "404: Not Found"); Error response payload ~~~rust -use masterror::{AppError, AppErrorKind, AppCode, ErrorResponse}; +use masterror::{AppError, AppErrorKind, ProblemJson}; 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""#); +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) +); -assert_eq!(resp.status, 401); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~ diff --git a/src/convert.rs b/src/convert.rs index cd9258b..7b74603 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -122,6 +122,10 @@ mod teloxide; #[cfg_attr(docsrs, doc(cfg(feature = "telegram-webapp-sdk")))] mod telegram_webapp_sdk; +#[cfg(feature = "tonic")] +#[cfg_attr(docsrs, doc(cfg(feature = "tonic")))] +mod tonic; + /// Map `std::io::Error` to an internal application error. /// /// Rationale: I/O failures are infrastructure-level and should not leak diff --git a/src/convert/actix.rs b/src/convert/actix.rs index e91edc9..b58108a 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -1,5 +1,4 @@ -//! Actix Web integration: `ResponseError` for [`AppError`] and helper JSON -//! payload. +//! Actix Web integration: `ResponseError` for [`AppError`] and RFC7807 payload. //! //! Enabled with the `actix` feature flag. //! @@ -7,19 +6,23 @@ //! - Implements `actix_web::ResponseError` for [`AppError`]. //! - This lets you `return AppResult<_>` from Actix handlers. //! - On error, Actix automatically builds an `HttpResponse` with the right -//! status code and JSON body (when the `serde_json` feature is enabled). +//! status code and RFC7807 JSON body (when the `serde_json` feature is +//! enabled). //! - Provides stable mapping from [`AppErrorKind`] to //! `actix_web::http::StatusCode`. //! - Ensures that only safe, public-facing fields are returned to the client -//! (`status`, `message`, `details?`). +//! (`type`, `title`, `status`, `detail?`, `metadata?`). //! //! ## Wire payload //! -//! When the `serde_json` feature is enabled, the body is [`ErrorResponse`] -//! with: +//! When the `serde_json` feature is enabled, the body is [`ProblemJson`] with: +//! - `type`: canonical URI describing the problem class +//! - `title`: short summary derived from [`AppErrorKind`] //! - `status`: numeric HTTP status (e.g. 404, 422, 500) -//! - `message`: explicit application message or a fallback from `AppErrorKind` -//! - `details`: currently `None`, but reserved for optional JSON/text payloads +//! - `detail?`: public message (redacted when the error is private) +//! - `metadata?`: sanitized structured fields carried from +//! [`Metadata`](crate::Metadata) +//! - `grpc?`: optional gRPC mapping for multi-protocol clients //! //! Without `serde_json`, Actix still returns a response with the correct status //! but with an empty body. @@ -49,7 +52,13 @@ //! The client will get a `403 Forbidden` response with a JSON body like: //! //! ```json -//! {"status":403,"message":"no access"} +//! { +//! "type":"https://errors.masterror.rs/forbidden", +//! "title":"Forbidden", +//! "status":403, +//! "detail":"no access", +//! "code":"FORBIDDEN" +//! } //! ``` //! //! ## Notes @@ -72,7 +81,9 @@ use actix_web::{ }; #[cfg(feature = "actix")] -use crate::{AppError, ErrorResponse}; +use crate::response::actix_impl::respond_with_problem_json; +#[cfg(feature = "actix")] +use crate::{AppError, ProblemJson}; #[cfg(feature = "actix")] impl ResponseError for AppError { @@ -83,18 +94,11 @@ impl ResponseError for AppError { .unwrap_or(ActixStatus::INTERNAL_SERVER_ERROR) } - /// Produce JSON body with `ErrorResponse`. Does not leak sources. + /// Produce JSON body with [`ProblemJson`]. Does not leak sources. fn error_response(&self) -> HttpResponse { self.emit_telemetry(); - let body = ErrorResponse::from(self); - let mut builder = HttpResponse::build(self.status_code()); - if let Some(retry) = body.retry { - builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); - } - if let Some(ref ch) = body.www_authenticate { - builder.insert_header((WWW_AUTHENTICATE, ch.as_str())); - } - builder.json(body) + let problem = ProblemJson::from_ref(self); + respond_with_problem_json(problem) } } @@ -106,7 +110,7 @@ mod actix_tests { http::header::{RETRY_AFTER, WWW_AUTHENTICATE} }; - use crate::{AppCode, AppError, AppErrorKind, AppResult, ErrorResponse}; + use crate::{AppCode, AppError, AppErrorKind, AppResult, ProblemJson}; #[test] fn maps_status_consistently() { @@ -134,10 +138,10 @@ mod actix_tests { ); let bytes = to_bytes(resp.into_body()).await?; - let body: ErrorResponse = serde_json::from_slice(&bytes)?; + let body: ProblemJson = serde_json::from_slice(&bytes)?; assert_eq!(body.status, 401); assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.message, "no token"); + assert_eq!(body.detail.as_deref(), Some("no token")); Ok(()) } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 6abcf1b..14abf21 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -6,17 +6,16 @@ //! - Adds an inherent `http_status()` on [`AppError`] that returns //! `axum::http::StatusCode` based on [`AppErrorKind`]. //! - Implements `IntoResponse` for [`AppError`] so handlers can `return -//! Err(...)` or directly `return AppError::...(...)` and get a JSON error -//! body (when the `serde_json` feature is enabled) or an empty body -//! otherwise. +//! Err(...)` or directly `return AppError::...(...)` and get an RFC7807 +//! problem+json body. //! - Flushes [`AppError`] telemetry at the HTTP boundary (tracing event, //! metrics counter, lazy backtrace). //! //! ## Wire payload //! -//! When the `serde_json` feature is enabled, the response body is -//! [`ErrorResponse`] with fields `{ status, message }`. `message` prefers the -//! explicit application message and falls back to the `AppErrorKind`’s display. +//! The response body is [`ProblemJson`] with fields `{ type, title, status, +//! detail, code, grpc, metadata }`. `detail` is redacted automatically when +//! the error is marked private. //! //! ## Example //! @@ -46,9 +45,7 @@ use axum::{ response::{IntoResponse, Response} }; -use crate::AppError; -#[cfg(feature = "serde_json")] -use crate::response::ErrorResponse; +use crate::{AppError, response::ProblemJson}; impl AppError { /// Map this error to an HTTP status derived from its [`AppErrorKind`]. @@ -65,18 +62,8 @@ impl AppError { impl IntoResponse for AppError { fn into_response(self) -> Response { let err = self; - err.emit_telemetry(); - let status = err.http_status(); - - #[cfg(feature = "serde_json")] - { - // Build the stable wire contract (includes `code`). - let body: ErrorResponse = err.into(); - return body.into_response(); - } - - #[allow(unreachable_code)] - (status, ()).into_response() + let problem = ProblemJson::from_app_error(err); + problem.into_response() } } @@ -101,51 +88,71 @@ mod tests { // --- IntoResponse with JSON body (serde_json enabled) -------------------- - #[cfg(feature = "serde_json")] #[tokio::test] - async fn into_response_builds_json_error_with_code_and_message() { - use axum::{body::to_bytes, response::IntoResponse}; - - let app_err = AppError::unauthorized("missing token"); - let resp = app_err.into_response(); + async fn into_response_builds_problem_json_with_headers() { + use axum::{ + body::to_bytes, + http::header::{CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE}, + response::IntoResponse + }; + + let app_err = AppError::unauthorized("missing token") + .with_retry_after_secs(7) + .with_www_authenticate("Bearer realm=\"api\""); + let mut resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let content_type = resp + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .expect("content-type header"); + assert_eq!(content_type, "application/problem+json"); + + let retry_after = resp + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .expect("retry-after header"); + assert_eq!(retry_after, "7"); + + let www_authenticate = resp + .headers() + .get(WWW_AUTHENTICATE) + .and_then(|value| value.to_str().ok()) + .expect("www-authenticate header"); + assert_eq!(www_authenticate, "Bearer realm=\"api\""); + let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - // Deserialize via our own type to ensure wire contract matches - let body: crate::response::ErrorResponse = + let body: crate::response::ProblemJson = serde_json::from_slice(&bytes).expect("json body"); assert_eq!(body.status, 401); assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.message, "missing token"); - - // Optional fields are absent by default - #[cfg(feature = "serde_json")] - { - assert!(body.details.is_none()); - } - assert!(body.retry.is_none()); - assert!(body.www_authenticate.is_none()); + assert_eq!(body.detail.as_deref(), Some("missing token")); + assert!(body.metadata.is_none()); + assert!(body.grpc.is_some()); } - // --- IntoResponse without JSON body (serde_json disabled) ---------------- - - #[cfg(not(feature = "serde_json"))] #[tokio::test] - async fn into_response_without_json_has_empty_body() { + async fn redacted_errors_hide_detail() { use axum::{body::to_bytes, response::IntoResponse}; - let app_err = AppError::not_found("nope"); - let resp = app_err.into_response(); + let app_err = AppError::internal("secret").redactable(); + let mut resp = app_err.into_response(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - assert_eq!(bytes.len(), 0, "body should be empty without serde_json"); + let body: crate::response::ProblemJson = + serde_json::from_slice(&bytes).expect("json body"); + + assert!(body.detail.is_none()); + assert!(body.metadata.is_none()); } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs new file mode 100644 index 0000000..4126503 --- /dev/null +++ b/src/convert/tonic.rs @@ -0,0 +1,204 @@ +//! Tonic integration: convert [`crate::Error`] into [`tonic::Status`]. +//! +//! Enabled with the `tonic` feature flag. +//! +//! ## Behavior +//! - Maps [`AppCode`] to the corresponding gRPC [`tonic::Code`]. +//! - Emits retry/authentication hints via metadata when available. +//! - Propagates public metadata only when the error is not marked as +//! redactable. +//! - Redacts the message automatically when the error is private. +//! +//! ## Example +//! +//! ```rust,ignore +//! use masterror::{AppError, AppErrorKind}; +//! +//! let status = tonic::Status::try_from(AppError::not_found("missing"))?; +//! assert_eq!(status.code(), tonic::Code::NotFound); +//! ``` + +#![cfg(feature = "tonic")] +#![cfg_attr(docsrs, doc(cfg(feature = "tonic")))] + +use std::{borrow::Cow, convert::Infallible}; + +use tonic::{ + Code, Status, + metadata::{MetadataMap, MetadataValue} +}; + +#[cfg(test)] +use crate::CODE_MAPPINGS; +use crate::{ + AppErrorKind, Error, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, mapping_for_code +}; + +impl TryFrom for Status { + type Error = Infallible; + + fn try_from(error: Error) -> Result { + Ok(status_from_error(error)) + } +} + +fn status_from_error(error: Error) -> Status { + error.emit_telemetry(); + let Error { + code, + kind, + message, + metadata, + edit_policy, + retry, + www_authenticate, + .. + } = error; + + let mapping = mapping_for_code(code); + let grpc_code = Code::from_i32(mapping.grpc().value); + let detail = sanitize_detail(message, kind, edit_policy); + let mut meta = MetadataMap::new(); + + insert_ascii(&mut meta, "app-code", code.as_str()); + insert_ascii( + &mut meta, + "app-http-status", + mapping.http_status().to_string() + ); + insert_ascii(&mut meta, "app-problem-type", mapping.problem_type()); + + if let Some(advice) = retry { + insert_retry(&mut meta, advice); + } + if let Some(challenge) = www_authenticate { + if is_ascii_metadata_value(&challenge) { + insert_ascii(&mut meta, "www-authenticate", challenge); + } + } + + if !matches!(edit_policy, MessageEditPolicy::Redact) { + attach_metadata(&mut meta, metadata); + } + + Status::with_metadata(grpc_code, detail, meta) +} + +fn sanitize_detail( + message: Option>, + kind: AppErrorKind, + policy: MessageEditPolicy +) -> String { + if matches!(policy, MessageEditPolicy::Redact) { + return kind.to_string(); + } + + message.map_or_else(|| kind.to_string(), Cow::into_owned) +} + +fn insert_retry(meta: &mut MetadataMap, retry: RetryAdvice) { + insert_ascii(meta, "retry-after", retry.after_seconds.to_string()); +} + +fn attach_metadata(meta: &mut MetadataMap, metadata: Metadata) { + for field in metadata { + let (name, value) = field.into_parts(); + if !is_safe_metadata_key(name) { + continue; + } + if let Some(serialized) = metadata_value_to_ascii(value) { + insert_ascii(meta, name, serialized); + } + } +} + +fn insert_ascii(meta: &mut MetadataMap, key: &'static str, value: impl AsRef) { + if !is_safe_metadata_key(key) { + return; + } + let value = value.as_ref(); + if !is_ascii_metadata_value(value) { + return; + } + if let Ok(metadata_value) = MetadataValue::try_from(value) { + let _ = meta.insert(key, metadata_value); + } +} + +fn metadata_value_to_ascii(value: FieldValue) -> Option { + match value { + FieldValue::Str(value) => { + let owned = value.into_owned(); + is_ascii_metadata_value(&owned).then_some(owned) + } + FieldValue::I64(value) => Some(value.to_string()), + FieldValue::U64(value) => Some(value.to_string()), + FieldValue::Bool(value) => Some(if value { "true" } else { "false" }.to_string()), + FieldValue::Uuid(value) => Some(value.to_string()) + } +} + +fn is_safe_metadata_key(key: &str) -> bool { + !key.is_empty() + && key + .bytes() + .all(|ch| matches!(ch, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.')) +} + +fn is_ascii_metadata_value(value: &str) -> bool { + value.bytes().all(|ch| matches!(ch, 0x20..=0x7E)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{AppError, AppErrorKind, field}; + + #[test] + fn status_maps_codes_correctly() { + for (code, mapping) in CODE_MAPPINGS.iter() { + let err = AppError::with(mapping.kind(), format!("{:?}", code)); + let status = Status::try_from(err).expect("status"); + assert_eq!(status.code(), Code::from_i32(mapping.grpc().value)); + let expected_detail = format!("{:?}", code); + assert_eq!( + status.message(), + expected_detail, + "unexpected message for {:?}", + code + ); + } + } + + #[test] + fn redacted_errors_hide_metadata() { + let err = AppError::internal("secret") + .redactable() + .with_field(field::str("request_id", "abc")); + let status = Status::try_from(err).expect("status"); + assert_eq!(status.message(), AppErrorKind::Internal.to_string()); + assert!(status.metadata().get("request_id").is_none()); + } + + #[test] + fn public_metadata_is_propagated() { + let err = AppError::service("downstream") + .with_field(field::str("request_id", "abc")) + .with_field(field::u64("attempt", 2)); + let status = Status::try_from(err).expect("status"); + assert_eq!( + status + .metadata() + .get("request_id") + .and_then(|value| value.to_str().ok()), + Some("abc") + ); + assert_eq!( + status + .metadata() + .get("attempt") + .and_then(|value| value.to_str().ok()), + Some("2") + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index b5653b7..f10b00f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,7 +11,8 @@ //! transport hints //! - [`AppErrorKind`] — stable internal taxonomy of application errors //! - [`AppResult`] — convenience alias for returning [`AppError`] -//! - [`ErrorResponse`] — stable wire-level JSON payload for HTTP APIs +//! - [`ProblemJson`] — RFC7807 payload emitted by HTTP/gRPC adapters +//! - [`ErrorResponse`] — legacy wire-level JSON payload for HTTP APIs //! - [`AppCode`] — public, machine-readable error code for clients //! - [`Metadata`] — structured telemetry attached to [`AppError`] //! - [`field`] — helper functions to build [`Metadata`] without manual enums @@ -33,10 +34,12 @@ //! //! Enable only what you need: //! -//! - `axum` — implements `IntoResponse` for [`AppError`] and [`ErrorResponse`] -//! with JSON body -//! - `actix` — implements `Responder` for [`ErrorResponse`] (and Actix -//! integration for [`AppError`]) +//! - `axum` — implements `IntoResponse` for [`AppError`] and [`ProblemJson`] +//! with RFC7807 body +//! - `actix` — implements `Responder` for [`ProblemJson`] and Actix +//! `ResponseError` for [`AppError`] +//! - `tonic` — converts [`struct@Error`] into `tonic::Status` with +//! sanitized metadata //! - `openapi` — derives an OpenAPI schema for [`ErrorResponse`] (via `utoipa`) //! - `sqlx` — `From` mapping //! - `redis` — `From` mapping @@ -50,8 +53,8 @@ //! mapping //! - `frontend` — convert errors into `wasm_bindgen::JsValue` and emit //! `console.error` logs in WASM/browser contexts -//! - `serde_json` — support for structured JSON details in [`ErrorResponse`]; -//! also pulled transitively by `axum` +//! - `serde_json` — support for structured JSON details in [`ErrorResponse`] +//! and [`ProblemJson`]; also pulled transitively by `axum` //! - `multipart` — compatibility flag for Axum multipart //! - `turnkey` — domain taxonomy and conversions for Turnkey errors, exposed in //! the `turnkey` module @@ -350,5 +353,11 @@ pub use kind::AppErrorKind; /// assert!(matches!(code, AppCode::BadRequest)); /// ``` pub use masterror_derive::{Error, Masterror}; -pub use response::{ErrorResponse, RetryAdvice}; +pub use response::{ + ErrorResponse, ProblemJson, RetryAdvice, + problem_json::{ + CODE_MAPPINGS, CodeMapping, GrpcCode, ProblemMetadata, ProblemMetadataValue, + mapping_for_code + } +}; pub use result_ext::ResultExt; diff --git a/src/response.rs b/src/response.rs index 9f5c1f3..760ea53 100644 --- a/src/response.rs +++ b/src/response.rs @@ -2,29 +2,26 @@ //! //! # Purpose //! -//! [`ErrorResponse`] is a stable JSON structure intended to be returned -//! directly from HTTP handlers. It represents the **public-facing contract** -//! for error reporting in web APIs. +//! [`ProblemJson`] serializes an RFC7807 payload designed for HTTP responses. +//! It augments the legacy [`ErrorResponse`] (still available for manual usage) +//! with: //! -//! It deliberately contains only *safe-to-expose* fields: +//! - canonical problem `type` URIs derived from [`AppCode`] +//! - a `title` computed from [`AppErrorKind`] +//! - the stable machine code plus optional gRPC mapping (`grpc.code`, +//! `grpc.value`) +//! - retry/authentication hints surfaced via the `Retry-After` and +//! `WWW-Authenticate` headers +//! - sanitized [`Metadata`] values when the error is not marked redactable //! -//! - [`status`](ErrorResponse::status): HTTP status code chosen by the service -//! - [`code`](ErrorResponse::code): stable, machine-readable error code -//! ([`AppCode`]) -//! - [`message`](ErrorResponse::message): human-oriented, non-sensitive text -//! - [`details`](ErrorResponse::details): optional structured payload -//! (`serde_json::Value` if the `serde_json` feature is enabled, otherwise -//! plain text) -//! - [`retry`](ErrorResponse::retry): optional retry advice, rendered as the -//! `Retry-After` header in HTTP adapters; set via -//! [`with_retry_after_secs`](ErrorResponse::with_retry_after_secs) or -//! [`with_retry_after_duration`](ErrorResponse::with_retry_after_duration) -//! - [`www_authenticate`](ErrorResponse::www_authenticate): optional -//! authentication challenge string, rendered as the `WWW-Authenticate` header +//! When the message is tagged redactable (`AppError::redactable` or +//! `Context::redact(true)`), both `detail` and metadata are omitted to avoid +//! leaking sensitive information. The HTTP adapters (`axum`, `actix`) emit +//! `application/problem+json` bodies automatically via [`ProblemJson`]. //! -//! Internal error sources (the [`std::error::Error`] chain inside [`AppError`]) -//! are **never leaked** into this type. They should be logged at the boundary, -//! but not serialized into responses. +//! [`ErrorResponse`] remains available for backwards compatibility with +//! existing wire contracts and can be converted into [`ProblemJson`] via +//! [`ProblemJson::from_error_response`]. //! //! # Example //! @@ -64,14 +61,17 @@ mod details; mod legacy; mod mapping; mod metadata; +pub mod problem_json; #[cfg(feature = "axum")] mod axum_impl; #[cfg(feature = "actix")] -mod actix_impl; +pub(crate) mod actix_impl; pub use core::{ErrorResponse, RetryAdvice}; +pub use problem_json::ProblemJson; + #[cfg(test)] mod tests; diff --git a/src/response/actix_impl.rs b/src/response/actix_impl.rs index 3359f63..a3a338a 100644 --- a/src/response/actix_impl.rs +++ b/src/response/actix_impl.rs @@ -1,33 +1,52 @@ -//! Actix integration: implements [`Responder`] for [`ErrorResponse`]. +//! Actix integration: implements [`Responder`] for [`ProblemJson`] and +//! [`ErrorResponse`]. //! //! Behavior: -//! - Serializes the response as JSON with the given status. -//! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. -//! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is present. +//! - Serializes the response as RFC7807 `application/problem+json`. +//! - Adds `Retry-After` when retry advice is present. +//! - Adds `WWW-Authenticate` when an authentication challenge is provided. +//! - Redacts message and metadata when the error is marked private. use actix_web::{ HttpRequest, HttpResponse, Responder, body::BoxBody, - http::header::{RETRY_AFTER, WWW_AUTHENTICATE} + http::header::{CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE} }; -use super::ErrorResponse; +use super::{ErrorResponse, ProblemJson}; + +pub(crate) fn respond_with_problem_json(mut problem: ProblemJson) -> HttpResponse { + let http_status = problem.status_code(); + let status = actix_web::http::StatusCode::from_u16(http_status.as_u16()) + .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR); + let retry_after = problem.retry_after; + let www_authenticate = problem.www_authenticate.take(); + + let mut builder = HttpResponse::build(status); + builder.insert_header((CONTENT_TYPE, "application/problem+json")); + + if let Some(retry) = retry_after { + builder.insert_header((RETRY_AFTER, retry.to_string())); + } + if let Some(challenge) = www_authenticate { + builder.insert_header((WWW_AUTHENTICATE, challenge)); + } + + builder.json(problem) +} + +impl Responder for ProblemJson { + type Body = BoxBody; + + fn respond_to(self, _req: &HttpRequest) -> HttpResponse { + respond_with_problem_json(self) + } +} impl Responder for ErrorResponse { type Body = BoxBody; fn respond_to(self, _req: &HttpRequest) -> HttpResponse { - let mut builder = HttpResponse::build( - actix_web::http::StatusCode::from_u16(self.status) - .unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR) - ); - if let Some(retry) = self.retry { - builder.insert_header((RETRY_AFTER, retry.after_seconds.to_string())); - } - if let Some(ref ch) = self.www_authenticate { - // Pass &str, not &String, to satisfy TryIntoHeaderPair - builder.insert_header((WWW_AUTHENTICATE, ch.as_str())); - } - builder.json(self) + respond_with_problem_json(ProblemJson::from_error_response(self)) } } diff --git a/src/response/axum_impl.rs b/src/response/axum_impl.rs index 70b2bf8..69fe5f0 100644 --- a/src/response/axum_impl.rs +++ b/src/response/axum_impl.rs @@ -1,36 +1,44 @@ -//! Axum integration: implements [`IntoResponse`] for [`ErrorResponse`]. +//! Axum integration: implements [`IntoResponse`] for [`ProblemJson`] and +//! [`ErrorResponse`]. //! //! Behavior: -//! - Serializes the response as JSON with the given status. -//! - Adds `Retry-After` if [`ErrorResponse::retry`] is present. -//! - Adds `WWW-Authenticate` if [`ErrorResponse::www_authenticate`] is present. +//! - Serializes the response as `application/problem+json` with the given +//! status. +//! - Adds `Retry-After` if retry advice is present. +//! - Adds `WWW-Authenticate` if an authentication challenge is present. +//! - Redacts the message and metadata when the error is marked as private. use axum::{ Json, http::{ HeaderValue, - header::{RETRY_AFTER, WWW_AUTHENTICATE} + header::{CONTENT_TYPE, RETRY_AFTER, WWW_AUTHENTICATE} }, response::{IntoResponse, Response} }; -use super::ErrorResponse; -use crate::AppError; +use super::{ErrorResponse, ProblemJson}; -impl IntoResponse for ErrorResponse { +impl IntoResponse for ProblemJson { fn into_response(self) -> Response { - let status = self.status_code(); + let mut body = self; + let status = body.status_code(); + let retry_after = body.retry_after; + let www_authenticate = body.www_authenticate.take(); + let mut response = (status, Json(body)).into_response(); - // Serialize JSON body first (borrow self for payload). - let mut response = (status, Json(&self)).into_response(); + response.headers_mut().insert( + CONTENT_TYPE, + HeaderValue::from_static("application/problem+json") + ); - if let Some(retry) = self.retry - && let Ok(hv) = HeaderValue::from_str(&retry.after_seconds.to_string()) + if let Some(retry) = retry_after + && let Ok(hv) = HeaderValue::from_str(&retry.to_string()) { response.headers_mut().insert(RETRY_AFTER, hv); } - if let Some(ch) = &self.www_authenticate - && let Ok(hv) = HeaderValue::from_str(ch) + if let Some(challenge) = www_authenticate + && let Ok(hv) = HeaderValue::from_str(&challenge) { response.headers_mut().insert(WWW_AUTHENTICATE, hv); } @@ -39,12 +47,8 @@ impl IntoResponse for ErrorResponse { } } -/// Convert `AppError` into the stable wire model and reuse its `IntoResponse`. -impl IntoResponse for AppError { +impl IntoResponse for ErrorResponse { fn into_response(self) -> Response { - let err = self; - err.emit_telemetry(); - let wire: ErrorResponse = err.into(); - wire.into_response() + ProblemJson::from_error_response(self).into_response() } } diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs new file mode 100644 index 0000000..d7adee6 --- /dev/null +++ b/src/response/problem_json.rs @@ -0,0 +1,756 @@ +use std::{borrow::Cow, collections::BTreeMap}; + +use http::StatusCode; +use serde::Serialize; + +use super::core::ErrorResponse; +use crate::{AppCode, AppError, AppErrorKind, FieldValue, MessageEditPolicy, Metadata}; + +/// Canonical mapping for a public [`AppCode`]. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppCode, mapping_for_code}; +/// +/// let mapping = mapping_for_code(AppCode::NotFound); +/// assert_eq!(mapping.http_status(), 404); +/// assert_eq!( +/// mapping.problem_type(), +/// "https://errors.masterror.rs/not-found" +/// ); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CodeMapping { + http_status: u16, + grpc: GrpcCode, + problem_type: &'static str, + kind: AppErrorKind +} + +impl CodeMapping { + /// HTTP status code associated with the [`AppCode`]. + #[cfg_attr(not(any(test, feature = "tonic")), allow(dead_code))] + #[must_use] + pub const fn http_status(&self) -> u16 { + self.http_status + } + + /// gRPC code mapping (`tonic::Code` discriminant). + #[must_use] + pub const fn grpc(&self) -> GrpcCode { + self.grpc + } + + /// Canonical RFC 7807 problem type URI. + #[must_use] + pub const fn problem_type(&self) -> &'static str { + self.problem_type + } + + /// Canonical error kind for presentation. + #[must_use] + pub const fn kind(&self) -> AppErrorKind { + self.kind + } +} + +/// gRPC status metadata used in RFC7807 payloads and tonic mapping. +/// +/// The `value` matches the discriminant of `tonic::Code`, allowing direct +/// conversion when the `tonic` feature is enabled. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppCode, mapping_for_code}; +/// +/// let grpc = mapping_for_code(AppCode::Internal).grpc(); +/// assert_eq!(grpc.name, "INTERNAL"); +/// assert_eq!(grpc.value, 13); +/// ``` +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct GrpcCode { + /// Canonical name (e.g. `"NOT_FOUND"`). + pub name: &'static str, + /// Numeric discriminant matching `tonic::Code`. + pub value: i32 +} + +/// RFC7807 `application/problem+json` payload enriched with machine-readable +/// metadata. +/// +/// Instances are produced by [`ProblemJson::from_app_error`] or +/// [`ProblemJson::from_ref`]. They power the HTTP adapters and expose +/// transport-neutral data for tests. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppError, ProblemJson}; +/// +/// let problem = ProblemJson::from_ref(&AppError::not_found("missing")); +/// assert_eq!(problem.status, 404); +/// assert_eq!(problem.code.as_str(), "NOT_FOUND"); +/// ``` +#[derive(Clone, Debug, Serialize)] +pub struct ProblemJson { + /// Canonical type URI describing the problem class. + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub type_uri: Option<&'static str>, + /// Short, human-friendly title describing the error category. + pub title: Cow<'static, str>, + /// HTTP status code returned to the client. + pub status: u16, + /// Optional human-readable detail (redacted when marked private). + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option>, + /// Stable machine-readable code. + pub code: AppCode, + /// Optional gRPC mapping for multi-protocol clients. + #[serde(skip_serializing_if = "Option::is_none")] + pub grpc: Option, + /// Structured metadata derived from [`Metadata`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + /// Retry advice propagated as the `Retry-After` header. + #[serde(skip)] + pub retry_after: Option, + /// Authentication challenge propagated as `WWW-Authenticate`. + #[serde(skip)] + pub www_authenticate: Option +} + +impl ProblemJson { + /// Build a problem payload from an owned [`AppError`]. + /// + /// # Preconditions + /// - `error.code` must be a public [`AppCode`] (guaranteed by + /// construction). + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppCode, AppError, ProblemJson}; + /// + /// let problem = ProblemJson::from_app_error(AppError::conflict("exists")); + /// assert_eq!(problem.code, AppCode::Conflict); + /// assert_eq!(problem.status, 409); + /// ``` + #[must_use] + pub fn from_app_error(error: AppError) -> Self { + let err = error; + err.emit_telemetry(); + let AppError { + code, + kind, + message, + metadata, + edit_policy, + retry, + www_authenticate, + .. + } = err; + + let mapping = mapping_for_code(code); + let status = kind.http_status(); + let title = Cow::Owned(kind.to_string()); + let detail = sanitize_detail(message, kind, edit_policy); + let metadata = sanitize_metadata_owned(metadata, edit_policy); + + Self { + type_uri: Some(mapping.problem_type()), + title, + status, + detail, + code, + grpc: Some(mapping.grpc()), + metadata, + retry_after: retry.map(|value| value.after_seconds), + www_authenticate + } + } + + /// Build a problem payload from a borrowed [`AppError`]. + /// + /// This is useful inside middleware that logs while forwarding the error + /// downstream without consuming it. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppError, ProblemJson}; + /// + /// let err = AppError::bad_request("invalid"); + /// let problem = ProblemJson::from_ref(&err); + /// assert_eq!(problem.status, 400); + /// assert!(problem.detail.is_some()); + /// ``` + #[must_use] + pub fn from_ref(error: &AppError) -> Self { + let mapping = mapping_for_code(error.code); + let status = error.kind.http_status(); + let title = Cow::Owned(error.kind.to_string()); + let detail = sanitize_detail_ref(error); + let metadata = sanitize_metadata_ref(error.metadata(), error.edit_policy); + + Self { + type_uri: Some(mapping.problem_type()), + title, + status, + detail, + code: error.code, + grpc: Some(mapping.grpc()), + metadata, + retry_after: error.retry.map(|value| value.after_seconds), + www_authenticate: error.www_authenticate.clone() + } + } + + /// Build a problem payload from a plain [`ErrorResponse`]. + /// + /// Metadata and redaction hints are not available in this conversion. + /// + /// # Examples + /// + /// ```rust + /// use masterror::{AppCode, ErrorResponse, ProblemJson}; + /// + /// let legacy = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); + /// let problem = ProblemJson::from_error_response(legacy); + /// assert_eq!(problem.status, 404); + /// assert_eq!(problem.code.as_str(), "NOT_FOUND"); + /// ``` + #[must_use] + pub fn from_error_response(response: ErrorResponse) -> Self { + let mapping = mapping_for_code(response.code); + let detail = if response.message.is_empty() { + None + } else { + Some(Cow::Owned(response.message)) + }; + + Self { + type_uri: Some(mapping.problem_type()), + title: Cow::Owned(mapping.kind().to_string()), + status: response.status, + detail, + code: response.code, + grpc: Some(mapping.grpc()), + metadata: None, + retry_after: response.retry.map(|value| value.after_seconds), + www_authenticate: response.www_authenticate + } + } + + /// Convert numeric status into [`StatusCode`]. + /// + /// Falls back to `500 Internal Server Error` if the value is invalid. + /// + /// # Examples + /// + /// ```rust + /// use http::StatusCode; + /// use masterror::{AppError, ProblemJson}; + /// + /// let problem = ProblemJson::from_app_error(AppError::service("oops")); + /// assert_eq!(problem.status_code(), StatusCode::INTERNAL_SERVER_ERROR); + /// ``` + #[must_use] + pub fn status_code(&self) -> StatusCode { + match StatusCode::from_u16(self.status) { + Ok(status) => status, + Err(_) => StatusCode::INTERNAL_SERVER_ERROR + } + } +} + +/// Metadata section of a [`ProblemJson`] payload. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppError, ProblemJson}; +/// +/// let err = AppError::service("retry").with_field(masterror::field::u64("attempt", 1)); +/// let problem = ProblemJson::from_ref(&err); +/// assert!(problem.metadata.is_some()); +/// ``` +#[derive(Clone, Debug, Serialize)] +#[serde(transparent)] +pub struct ProblemMetadata(BTreeMap<&'static str, ProblemMetadataValue>); + +impl ProblemMetadata { + #[cfg(test)] + fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// Individual metadata value serialized in problem payloads. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{ProblemMetadataValue, field}; +/// +/// let (_name, field_value) = field::u64("attempt", 2).into_parts(); +/// let value = ProblemMetadataValue::from(field_value); +/// assert!(matches!(value, ProblemMetadataValue::U64(2))); +/// ``` +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +pub enum ProblemMetadataValue { + /// String value preserved as-is. + String(Cow<'static, str>), + /// Signed 64-bit integer. + I64(i64), + /// Unsigned 64-bit integer. + U64(u64), + /// Boolean flag serialized as `true`/`false`. + Bool(bool) +} + +impl From for ProblemMetadataValue { + fn from(value: FieldValue) -> Self { + match value { + FieldValue::Str(value) => Self::String(value), + FieldValue::I64(value) => Self::I64(value), + FieldValue::U64(value) => Self::U64(value), + FieldValue::Bool(value) => Self::Bool(value), + FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())) + } + } +} + +impl From<&FieldValue> for ProblemMetadataValue { + fn from(value: &FieldValue) -> Self { + match value { + FieldValue::Str(value) => Self::String(value.clone()), + FieldValue::I64(value) => Self::I64(*value), + FieldValue::U64(value) => Self::U64(*value), + FieldValue::Bool(value) => Self::Bool(*value), + FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())) + } + } +} + +fn sanitize_detail( + message: Option>, + kind: AppErrorKind, + policy: MessageEditPolicy +) -> Option> { + if matches!(policy, MessageEditPolicy::Redact) { + return None; + } + + Some(message.unwrap_or_else(|| Cow::Owned(kind.to_string()))) +} + +fn sanitize_detail_ref(error: &AppError) -> Option> { + if matches!(error.edit_policy, MessageEditPolicy::Redact) { + return None; + } + + Some(Cow::Owned(error.render_message().into_owned())) +} + +fn sanitize_metadata_owned( + metadata: Metadata, + policy: MessageEditPolicy +) -> Option { + if matches!(policy, MessageEditPolicy::Redact) || metadata.is_empty() { + return None; + } + + let mut public = BTreeMap::new(); + for field in metadata { + let (name, value) = field.into_parts(); + public.insert(name, ProblemMetadataValue::from(value)); + } + + if public.is_empty() { + None + } else { + Some(ProblemMetadata(public)) + } +} + +fn sanitize_metadata_ref( + metadata: &Metadata, + policy: MessageEditPolicy +) -> Option { + if matches!(policy, MessageEditPolicy::Redact) || metadata.is_empty() { + return None; + } + + let mut public = BTreeMap::new(); + for (name, value) in metadata.iter() { + public.insert(name, ProblemMetadataValue::from(value)); + } + + if public.is_empty() { + None + } else { + Some(ProblemMetadata(public)) + } +} + +/// Canonical mapping table covering every built-in [`AppCode`]. +/// +/// # Examples +/// +/// ```rust +/// use masterror::CODE_MAPPINGS; +/// +/// assert!( +/// CODE_MAPPINGS +/// .iter() +/// .any(|(code, _)| code.as_str() == "NOT_FOUND") +/// ); +/// ``` +pub const CODE_MAPPINGS: &[(AppCode, CodeMapping)] = &[ + ( + AppCode::NotFound, + CodeMapping { + http_status: 404, + grpc: GrpcCode { + name: "NOT_FOUND", + value: 5 + }, + problem_type: "https://errors.masterror.rs/not-found", + kind: AppErrorKind::NotFound + } + ), + ( + AppCode::Validation, + CodeMapping { + http_status: 422, + grpc: GrpcCode { + name: "INVALID_ARGUMENT", + value: 3 + }, + problem_type: "https://errors.masterror.rs/validation", + kind: AppErrorKind::Validation + } + ), + ( + AppCode::Conflict, + CodeMapping { + http_status: 409, + grpc: GrpcCode { + name: "ALREADY_EXISTS", + value: 6 + }, + problem_type: "https://errors.masterror.rs/conflict", + kind: AppErrorKind::Conflict + } + ), + ( + AppCode::Unauthorized, + CodeMapping { + http_status: 401, + grpc: GrpcCode { + name: "UNAUTHENTICATED", + value: 16 + }, + problem_type: "https://errors.masterror.rs/unauthorized", + kind: AppErrorKind::Unauthorized + } + ), + ( + AppCode::Forbidden, + CodeMapping { + http_status: 403, + grpc: GrpcCode { + name: "PERMISSION_DENIED", + value: 7 + }, + problem_type: "https://errors.masterror.rs/forbidden", + kind: AppErrorKind::Forbidden + } + ), + ( + AppCode::NotImplemented, + CodeMapping { + http_status: 501, + grpc: GrpcCode { + name: "UNIMPLEMENTED", + value: 12 + }, + problem_type: "https://errors.masterror.rs/not-implemented", + kind: AppErrorKind::NotImplemented + } + ), + ( + AppCode::BadRequest, + CodeMapping { + http_status: 400, + grpc: GrpcCode { + name: "INVALID_ARGUMENT", + value: 3 + }, + problem_type: "https://errors.masterror.rs/bad-request", + kind: AppErrorKind::BadRequest + } + ), + ( + AppCode::RateLimited, + CodeMapping { + http_status: 429, + grpc: GrpcCode { + name: "RESOURCE_EXHAUSTED", + value: 8 + }, + problem_type: "https://errors.masterror.rs/rate-limited", + kind: AppErrorKind::RateLimited + } + ), + ( + AppCode::TelegramAuth, + CodeMapping { + http_status: 401, + grpc: GrpcCode { + name: "UNAUTHENTICATED", + value: 16 + }, + problem_type: "https://errors.masterror.rs/telegram-auth", + kind: AppErrorKind::TelegramAuth + } + ), + ( + AppCode::InvalidJwt, + CodeMapping { + http_status: 401, + grpc: GrpcCode { + name: "UNAUTHENTICATED", + value: 16 + }, + problem_type: "https://errors.masterror.rs/invalid-jwt", + kind: AppErrorKind::InvalidJwt + } + ), + ( + AppCode::Internal, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/internal", + kind: AppErrorKind::Internal + } + ), + ( + AppCode::Database, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/database", + kind: AppErrorKind::Database + } + ), + ( + AppCode::Service, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/service", + kind: AppErrorKind::Service + } + ), + ( + AppCode::Config, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/config", + kind: AppErrorKind::Config + } + ), + ( + AppCode::Turnkey, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/turnkey", + kind: AppErrorKind::Turnkey + } + ), + ( + AppCode::Timeout, + CodeMapping { + http_status: 504, + grpc: GrpcCode { + name: "DEADLINE_EXCEEDED", + value: 4 + }, + problem_type: "https://errors.masterror.rs/timeout", + kind: AppErrorKind::Timeout + } + ), + ( + AppCode::Network, + CodeMapping { + http_status: 503, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/network", + kind: AppErrorKind::Network + } + ), + ( + AppCode::DependencyUnavailable, + CodeMapping { + http_status: 503, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/dependency-unavailable", + kind: AppErrorKind::DependencyUnavailable + } + ), + ( + AppCode::Serialization, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/serialization", + kind: AppErrorKind::Serialization + } + ), + ( + AppCode::Deserialization, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/deserialization", + kind: AppErrorKind::Deserialization + } + ), + ( + AppCode::ExternalApi, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/external-api", + kind: AppErrorKind::ExternalApi + } + ), + ( + AppCode::Queue, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/queue", + kind: AppErrorKind::Queue + } + ), + ( + AppCode::Cache, + CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "UNAVAILABLE", + value: 14 + }, + problem_type: "https://errors.masterror.rs/cache", + kind: AppErrorKind::Cache + } + ) +]; + +const DEFAULT_MAPPING: CodeMapping = CodeMapping { + http_status: 500, + grpc: GrpcCode { + name: "INTERNAL", + value: 13 + }, + problem_type: "https://errors.masterror.rs/internal", + kind: AppErrorKind::Internal +}; + +/// Lookup helper returning canonical mapping for a given [`AppCode`]. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppCode, mapping_for_code}; +/// +/// let mapping = mapping_for_code(AppCode::Timeout); +/// assert_eq!(mapping.grpc().name, "DEADLINE_EXCEEDED"); +/// ``` +#[must_use] +pub fn mapping_for_code(code: AppCode) -> CodeMapping { + CODE_MAPPINGS + .iter() + .find_map(|(candidate, mapping)| { + if *candidate == code { + Some(*mapping) + } else { + None + } + }) + .unwrap_or(DEFAULT_MAPPING) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::AppError; + + #[test] + fn metadata_is_skipped_when_redacted() { + let err = AppError::internal("secret") + .redactable() + .with_field(crate::field::str("token", "super-secret")); + let problem = ProblemJson::from_ref(&err); + assert!(problem.detail.is_none()); + assert!(problem.metadata.is_none()); + } + + #[test] + fn metadata_is_serialized_when_allowed() { + let err = AppError::internal("oops").with_field(crate::field::u64("attempt", 2)); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + assert!(!metadata.is_empty()); + } + + #[test] + fn mapping_for_every_code_matches_http_status() { + for (code, mapping) in CODE_MAPPINGS { + let status = mapping.http_status(); + let expected = mapping.kind().http_status(); + assert_eq!(status, expected, "status mismatch for {:?}", code); + } + } +} diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index c3fb86b..113a10d 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index b331baa..9ada290 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index b2658a1..fd151cc 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 83d517f..5a25e12 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | #[error("missing")] - | ^ +8 | / #[error("missing")] +9 | | Missing + | |___________^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index f929951..bdadf45 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 34abc91..037fac8 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index d579838..1822edf 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default From 9c94793aa65f5902551561f38e622192bbd247bb Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:07:21 +0700 Subject: [PATCH 10/25] Add Arc-based error sources and lazy backtrace capture --- CHANGELOG.md | 17 ++ Cargo.lock | 4 +- Cargo.toml | 4 +- README.md | 15 +- masterror-derive/Cargo.toml | 2 +- masterror-derive/src/input.rs | 13 ++ masterror-derive/src/masterror_impl.rs | 19 ++- src/app_error/core.rs | 145 ++++++++++++++---- src/app_error/tests.rs | 77 +++++++++- src/lib.rs | 4 +- src/response/problem_json.rs | 23 ++- tests/masterror_macro.rs | 41 +++++ .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 - .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 28 files changed, 329 insertions(+), 90 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b78da9..b8d5d48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,23 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.16.0] - 2025-09-26 + +### Changed +- Switched the internal `AppError` source storage to `Arc` and added a + shared `with_source_arc` helper so conversions can reuse existing `Arc` + handles without extra allocations. +- Replaced the backtrace slot with an `Option` managed through an + environment-aware lazy capture that respects `RUST_BACKTRACE` and avoids + snapshot allocation when disabled. +- Updated the `masterror::Error` derive and `ResultExt` conversions to forward + sources using the new shared storage while preserving error chains. + +### Tests +- Added regression coverage for the `std::error::Error` chain, `Arc` source + preservation in the derives, and conditional backtrace capture driven by the + `RUST_BACKTRACE` environment variable. + ## [0.15.0] - 2025-09-25 ### Added diff --git a/Cargo.lock b/Cargo.lock index fe11b51..6460182 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.15.0" +version = "0.16.0" dependencies = [ "actix-web", "axum 0.8.4", @@ -1763,7 +1763,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.7.0" +version = "0.7.1" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 2d41449..5de95af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.15.0" +version = "0.16.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -75,7 +75,7 @@ tonic = ["dep:tonic"] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.7.0" } +masterror-derive = { version = "0.7.1" } masterror-template = { version = "0.3.6" } [dependencies] diff --git a/README.md b/README.md index 74a5e9e..0a8970a 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.15.0", default-features = false } +masterror = { version = "0.16.0", default-features = false } # or with features: -# masterror = { version = "0.15.0", features = [ - +# masterror = { version = "0.16.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -79,10 +78,10 @@ masterror = { version = "0.15.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.15.0", default-features = false } +masterror = { version = "0.16.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.15.0", features = [ +# masterror = { version = "0.16.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +719,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.15.0", default-features = false } +masterror = { version = "0.16.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.15.0", features = [ +masterror = { version = "0.16.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +734,7 @@ masterror = { version = "0.15.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.15.0", features = [ +masterror = { version = "0.16.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index dadb1cd..797d219 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.7.0" +version = "0.7.1" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index 64a00e0..adedc94 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -1509,6 +1509,19 @@ pub(crate) fn option_inner_type(ty: &syn::Type) -> Option<&syn::Type> { }) } +pub(crate) fn is_arc_type(ty: &syn::Type) -> bool { + let syn::Type::Path(path) = ty else { + return false; + }; + if path.qself.is_some() { + return false; + } + path.path + .segments + .last() + .is_some_and(|segment| segment.ident == "Arc") +} + pub(crate) fn is_backtrace_type(ty: &syn::Type) -> bool { let syn::Type::Path(path) = ty else { return false; diff --git a/masterror-derive/src/masterror_impl.rs b/masterror-derive/src/masterror_impl.rs index e51b7d0..e16145e 100644 --- a/masterror-derive/src/masterror_impl.rs +++ b/masterror-derive/src/masterror_impl.rs @@ -3,7 +3,8 @@ use quote::{format_ident, quote}; use syn::{Error, Expr, ExprPath, Index}; use crate::input::{ - ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_option_type + ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_arc_type, + is_option_type, option_inner_type }; pub fn expand(input: &ErrorInput) -> Result { @@ -433,13 +434,27 @@ fn source_attachment_tokens(bound_fields: &[BoundField<'_>]) -> TokenStream { for bound in bound_fields { if bound.field.attrs.has_source() { let binding = &bound.binding; - if is_option_type(&bound.field.ty) { + let ty = &bound.field.ty; + if is_option_type(ty) { + let arc_inner = option_inner_type(ty).is_some_and(is_arc_type); + if arc_inner { + return quote! { + if let Some(source) = #binding { + __masterror_error = __masterror_error.with_source_arc(source); + } + }; + } return quote! { if let Some(source) = #binding { __masterror_error = __masterror_error.with_source(source); } }; } else { + if is_arc_type(ty) { + return quote! { + __masterror_error = __masterror_error.with_source_arc(#binding); + }; + } return quote! { __masterror_error = __masterror_error.with_source(#binding); }; diff --git a/src/app_error/core.rs b/src/app_error/core.rs index c6b6055..7284192 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -1,12 +1,21 @@ #[cfg(feature = "backtrace")] -use std::sync::OnceLock; use std::{ backtrace::Backtrace, + env, + sync::{ + OnceLock, + atomic::{AtomicU8, Ordering as AtomicOrdering} + } +}; +use std::{ borrow::Cow, error::Error as StdError, fmt::{Display, Formatter, Result as FmtResult}, ops::{Deref, DerefMut}, - sync::atomic::{AtomicBool, Ordering} + sync::{ + Arc, + atomic::{AtomicBool, Ordering} + } }; #[cfg(feature = "tracing")] @@ -50,9 +59,7 @@ impl BacktraceSlot { } fn capture_if_absent(&self) -> Option<&Backtrace> { - self.cell - .get_or_init(|| Some(Backtrace::capture())) - .as_ref() + self.cell.get_or_init(capture_backtrace_snapshot).as_ref() } } @@ -64,7 +71,19 @@ impl Default for BacktraceSlot { } #[cfg(not(feature = "backtrace"))] -type BacktraceSlot = Option; +#[derive(Debug, Default)] +struct BacktraceSlot { + _marker: () +} + +#[cfg(not(feature = "backtrace"))] +impl BacktraceSlot { + fn set(&mut self, _backtrace: std::backtrace::Backtrace) {} + + fn capture_if_absent(&self) -> Option<&std::backtrace::Backtrace> { + None + } +} #[derive(Debug)] #[doc(hidden)] @@ -83,11 +102,71 @@ pub struct ErrorInner { pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. pub www_authenticate: Option, - source: Option>, + source: Option>, backtrace: BacktraceSlot, telemetry_dirty: AtomicBool } +#[cfg(feature = "backtrace")] +const BACKTRACE_STATE_UNSET: u8 = 0; +#[cfg(feature = "backtrace")] +const BACKTRACE_STATE_ENABLED: u8 = 1; +#[cfg(feature = "backtrace")] +const BACKTRACE_STATE_DISABLED: u8 = 2; + +#[cfg(feature = "backtrace")] +static BACKTRACE_STATE: AtomicU8 = AtomicU8::new(BACKTRACE_STATE_UNSET); + +#[cfg(feature = "backtrace")] +fn capture_backtrace_snapshot() -> Option { + if should_capture_backtrace() { + Some(Backtrace::capture()) + } else { + None + } +} + +#[cfg(feature = "backtrace")] +fn should_capture_backtrace() -> bool { + match BACKTRACE_STATE.load(AtomicOrdering::Acquire) { + BACKTRACE_STATE_ENABLED => true, + BACKTRACE_STATE_DISABLED => false, + _ => { + let enabled = detect_backtrace_preference(); + BACKTRACE_STATE.store( + if enabled { + BACKTRACE_STATE_ENABLED + } else { + BACKTRACE_STATE_DISABLED + }, + AtomicOrdering::Release + ); + enabled + } + } +} + +#[cfg(feature = "backtrace")] +fn detect_backtrace_preference() -> bool { + match env::var_os("RUST_BACKTRACE") { + None => false, + Some(value) => { + let value = value.to_string_lossy(); + let trimmed = value.trim(); + if trimmed.is_empty() { + return false; + } + let lowered = trimmed.to_ascii_lowercase(); + !(matches!(lowered.as_str(), "0" | "off" | "false")) + } + } +} + +#[cfg(all(test, feature = "backtrace"))] +pub(crate) fn reset_backtrace_preference() { + BACKTRACE_STATE.store(BACKTRACE_STATE_UNSET, AtomicOrdering::Release); +} + /// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] pub struct Error { @@ -117,8 +196,13 @@ impl Display for Error { impl StdError for Error { fn source(&self) -> Option<&(dyn StdError + 'static)> { self.source - .as_ref() - .map(|source| &**source as &(dyn StdError + 'static)) + .as_deref() + .map(|source| source as &(dyn StdError + 'static)) + } + + #[cfg(feature = "backtrace")] + fn backtrace(&self) -> Option<&Backtrace> { + self.capture_backtrace() } } @@ -173,26 +257,14 @@ impl Error { self.telemetry_dirty.swap(false, Ordering::AcqRel) } - #[cfg(feature = "backtrace")] - fn capture_backtrace(&self) -> Option<&Backtrace> { + fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { self.backtrace.capture_if_absent() } - #[cfg(not(feature = "backtrace"))] - fn capture_backtrace(&self) -> Option<&Backtrace> { - self.backtrace.as_ref() - } - - #[cfg(feature = "backtrace")] - fn set_backtrace_slot(&mut self, backtrace: Backtrace) { + fn set_backtrace_slot(&mut self, backtrace: std::backtrace::Backtrace) { self.backtrace.set(backtrace); } - #[cfg(not(feature = "backtrace"))] - fn set_backtrace_slot(&mut self, backtrace: Backtrace) { - self.backtrace = Some(backtrace); - } - pub(crate) fn emit_telemetry(&self) { if self.take_dirty() { #[cfg(feature = "backtrace")] @@ -331,14 +403,35 @@ impl Error { /// Attach a source error for diagnostics. #[must_use] pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self { - self.source = Some(Box::new(source)); + self.source = Some(Arc::new(source)); + self.mark_dirty(); + self + } + + /// Attach a shared source error without cloning the underlying `Arc`. + /// + /// # Examples + /// + /// ```rust + /// use std::sync::Arc; + /// + /// use masterror::{AppError, AppErrorKind}; + /// + /// let source = Arc::new(std::io::Error::new(std::io::ErrorKind::Other, "boom")); + /// let err = AppError::internal("boom").with_source_arc(source.clone()); + /// assert!(err.source_ref().is_some()); + /// assert_eq!(Arc::strong_count(&source), 2); + /// ``` + #[must_use] + pub fn with_source_arc(mut self, source: Arc) -> Self { + self.source = Some(source); self.mark_dirty(); self } /// Attach a captured backtrace. #[must_use] - pub fn with_backtrace(mut self, backtrace: Backtrace) -> Self { + pub fn with_backtrace(mut self, backtrace: std::backtrace::Backtrace) -> Self { self.set_backtrace_slot(backtrace); self.mark_dirty(); self @@ -353,7 +446,7 @@ impl Error { /// Borrow the backtrace, capturing it lazily when the `backtrace` feature /// is enabled. #[must_use] - pub fn backtrace(&self) -> Option<&Backtrace> { + pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> { self.capture_backtrace() } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 03f2c66..0ca0ec6 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,4 +1,12 @@ use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; +#[cfg(feature = "backtrace")] +use std::{env, sync::Mutex}; + +#[cfg(feature = "backtrace")] +use super::core::reset_backtrace_preference; + +#[cfg(feature = "backtrace")] +static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); use super::{AppError, FieldValue, MessageEditPolicy, field}; use crate::{AppCode, AppErrorKind}; @@ -176,15 +184,74 @@ impl StdError for DummyError {} #[test] fn source_is_preserved_without_extra_allocation() { let source = Arc::new(DummyError); - let err = AppError::internal("boom").with_source(source.clone()); + let err = AppError::internal("boom").with_source_arc(source.clone()); assert_eq!(Arc::strong_count(&source), 2); let stored = err.source_ref().expect("source"); - let stored_arc = stored - .downcast_ref::>() - .expect("arc should be preserved"); - assert!(Arc::ptr_eq(stored_arc, &source)); + let stored_dummy = stored + .downcast_ref::() + .expect("dummy should be preserved"); + assert!(std::ptr::eq(stored_dummy, &*source)); +} + +#[test] +fn error_chain_is_preserved() { + #[derive(Debug)] + struct NestedError { + inner: DummyError + } + + impl Display for NestedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.inner.fmt(f) + } + } + + impl StdError for NestedError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.inner) + } + } + + let err = AppError::internal("boom").with_source(NestedError { + inner: DummyError + }); + let top_source = StdError::source(&err).expect("top source"); + assert!(top_source.is::()); + let nested = top_source.source().expect("nested source"); + assert!(nested.is::()); +} + +#[cfg(feature = "backtrace")] +fn with_backtrace_env(value: Option<&str>, test: F) { + let _guard = BACKTRACE_ENV_GUARD.lock().expect("env guard"); + reset_backtrace_preference(); + match value { + Some(val) => env::set_var("RUST_BACKTRACE", val), + None => env::remove_var("RUST_BACKTRACE") + } + test(); + env::remove_var("RUST_BACKTRACE"); + reset_backtrace_preference(); +} + +#[cfg(feature = "backtrace")] +#[test] +fn backtrace_respects_disabled_env() { + with_backtrace_env(Some("0"), || { + let err = AppError::internal("boom"); + assert!(err.backtrace().is_none()); + }); +} + +#[cfg(feature = "backtrace")] +#[test] +fn backtrace_enabled_when_env_requests() { + with_backtrace_env(Some("1"), || { + let err = AppError::internal("boom"); + assert!(err.backtrace().is_some()); + }); } #[test] diff --git a/src/lib.rs b/src/lib.rs index f10b00f..825b779 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,8 +38,8 @@ //! with RFC7807 body //! - `actix` — implements `Responder` for [`ProblemJson`] and Actix //! `ResponseError` for [`AppError`] -//! - `tonic` — converts [`struct@Error`] into `tonic::Status` with -//! sanitized metadata +//! - `tonic` — converts [`struct@Error`] into `tonic::Status` with sanitized +//! metadata //! - `openapi` — derives an OpenAPI schema for [`ErrorResponse`] (via `utoipa`) //! - `sqlx` — `From` mapping //! - `redis` — `From` mapping diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index d7adee6..28af7e7 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -138,19 +138,16 @@ impl ProblemJson { /// assert_eq!(problem.status, 409); /// ``` #[must_use] - pub fn from_app_error(error: AppError) -> Self { - let err = error; - err.emit_telemetry(); - let AppError { - code, - kind, - message, - metadata, - edit_policy, - retry, - www_authenticate, - .. - } = err; + pub fn from_app_error(mut error: AppError) -> Self { + error.emit_telemetry(); + + let code = error.code; + let kind = error.kind; + let message = error.message.take(); + let metadata = core::mem::take(&mut error.metadata); + let edit_policy = error.edit_policy; + let retry = error.retry.take(); + let www_authenticate = error.www_authenticate.take(); let mapping = mapping_for_code(code); let status = kind.http_status(); diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs index e9815e0..668b340 100644 --- a/tests/masterror_macro.rs +++ b/tests/masterror_macro.rs @@ -1,5 +1,7 @@ #![allow(non_shorthand_field_patterns)] +use std::sync::Arc; + use masterror::{ AppCode, AppErrorKind, Error as MasterrorError, Masterror, MessageEditPolicy, mapping::{GrpcMapping, HttpMapping, ProblemMapping} @@ -155,3 +157,42 @@ fn enum_masterror_conversion_handles_variants() { )] ); } + +#[test] +fn masterror_preserves_arc_source_without_extra_clone() { + let source = Arc::new(ArcLeafError); + let converted: MasterrorError = ArcSourceError { + source: source.clone() + } + .into(); + + assert_eq!(Arc::strong_count(&source), 2); + + let stored = converted + .source_ref() + .and_then(|src| src.downcast_ref::()) + .expect("arc source"); + assert!(std::ptr::eq(stored, &*source)); +} +#[derive(Debug)] +struct ArcLeafError; + +impl std::fmt::Display for ArcLeafError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("arc leaf") + } +} + +impl std::error::Error for ArcLeafError {} + +#[derive(Debug, Masterror)] +#[error("arc leaf source")] +#[masterror( + code = AppCode::Internal, + category = AppErrorKind::Internal, + message +)] +struct ArcSourceError { + #[source] + source: Arc +} diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index bbc297c..d000de1 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("without")] -9 | | Without, - | |___________^ +8 | #[error("without")] + | ^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 4f02301..70ccade 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index 021c135..c615e98 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^^^^^^^^^^^^^^^^^^^^^ + | ^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b8f363..5b08225 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index be76742..d416399 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,5 +8,4 @@ error: multiple unused formatting arguments | argument never used | argument never used | - = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index b8bf229..d7acdb1 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index a6a40c2..5869420 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 | 4 | #[error("{value:y}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index 3d332c7..bbe04b4 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 | 4 | #[error("{value:B}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 0bd10fa..2c30e71 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 | 4 | #[error("{value:P}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index 113a10d..c3fb86b 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index 9ada290..b331baa 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index fd151cc..b2658a1 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 5a25e12..83d517f 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("missing")] -9 | | Missing - | |___________^ +8 | #[error("missing")] + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index bdadf45..f929951 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 037fac8..34abc91 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index 1822edf..d579838 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default From 1f313f4a86a0ce7470b8824b056824b7026a9a9c Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:04:52 +0700 Subject: [PATCH 11/25] Add metadata redaction policies and internal formatters --- CHANGELOG.md | 18 ++ Cargo.lock | 5 +- Cargo.toml | 7 +- README.md | 19 +- README.ru.md | 5 +- README.template.md | 5 +- masterror-derive/Cargo.toml | 2 +- masterror-derive/src/input.rs | 108 ++++++++-- masterror-derive/src/masterror_impl.rs | 34 +++- src/app_error.rs | 2 +- src/app_error/context.rs | 41 +++- src/app_error/core.rs | 10 +- src/app_error/metadata.rs | 185 +++++++++++++++--- src/app_error/tests.rs | 33 +++- src/convert/tonic.rs | 8 +- src/lib.rs | 8 +- src/response.rs | 1 + src/response/core.rs | 6 + src/response/internal.rs | 74 +++++++ src/response/mapping.rs | 11 +- src/response/problem_json.rs | 169 +++++++++++++++- src/response/tests.rs | 25 ++- tests/masterror_macro.rs | 10 +- .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 39 files changed, 732 insertions(+), 109 deletions(-) create mode 100644 src/response/internal.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d5d48..c341449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.17.0] - 2025-09-27 + +### Added +- Per-field redaction metadata via a new [`FieldRedaction`] enum, default + heuristics for common secret keys (passwords, tokens, card numbers) and the + `Context::redact_field` / `AppError::redact_field` helpers. +- `#[masterror(redact(fields(...)))]` support in the derive macro to configure + metadata policies alongside message redaction. +- Opt-in internal formatters for [`ErrorResponse`] and [`ProblemJson`] that are + safe to use in diagnostic logs without additional serialization boilerplate. + +### Changed +- Problem JSON and legacy `ErrorResponse` serialization now hash, mask or drop + metadata according to per-field policies while honoring the global redaction + flag. +- Redaction-aware conversions ensure redactable messages fall back to the error + kind across HTTP and gRPC mappings. + ## [0.16.0] - 2025-09-26 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 6460182..dc61df7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.16.0" +version = "0.17.0" dependencies = [ "actix-web", "axum 0.8.4", @@ -1744,6 +1744,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "sha2", "sqlx", "sqlx-core", "telegram-webapp-sdk", @@ -1763,7 +1764,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.7.1" +version = "0.8.0" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 5de95af..3a8f8a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.16.0" +version = "0.17.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -75,11 +75,11 @@ tonic = ["dep:tonic"] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.7.1" } +masterror-derive = { version = "0.8.0" } masterror-template = { version = "0.3.6" } [dependencies] -masterror-derive = { version = "0.7" } +masterror-derive = { version = "0.8" } masterror-template = { workspace = true } tracing = { version = "0.1", optional = true } log = { version = "0.4", optional = true } @@ -89,6 +89,7 @@ metrics = { version = "0.24", optional = true } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", optional = true } http = "1" +sha2 = "0.10" # optional integrations axum = { version = "0.8", optional = true, default-features = false, features = [ diff --git a/README.md b/README.md index 0a8970a..278be85 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.16.0", default-features = false } +masterror = { version = "0.17.0", default-features = false } # or with features: -# masterror = { version = "0.16.0", features = [ +# masterror = { version = "0.17.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.16.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.16.0", default-features = false } +masterror = { version = "0.17.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.16.0", features = [ +# masterror = { version = "0.17.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -201,7 +201,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(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)) @@ -240,7 +240,8 @@ assert_eq!( - `message` forwards the formatted [`Display`] output as the safe public message. Omit it to keep the message private. - `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport - boundary. + boundary, `fields("name" = hash, "card" = last4)` overrides metadata + policies (`hash`, `last4`, `redact`, `none`). - `telemetry(...)` accepts expressions that evaluate to `Option`. Each populated field is inserted into the resulting [`Metadata`]; use `telemetry()` when no fields are attached. @@ -719,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.16.0", default-features = false } +masterror = { version = "0.17.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.16.0", features = [ +masterror = { version = "0.17.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -734,7 +735,7 @@ masterror = { version = "0.16.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.16.0", features = [ +masterror = { version = "0.17.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 1354483..d87a385 100644 --- a/README.ru.md +++ b/README.ru.md @@ -212,7 +212,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(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)) @@ -250,7 +250,8 @@ assert_eq!( [`AppErrorKind`]. - `message` включает текст, возвращаемый [`Display`], в публичное сообщение. - `redact(message)` выставляет [`MessageEditPolicy`] в режим редактирования на - транспортной границе. + транспортной границе, `fields("name" = hash, "card" = last4)` переопределяет + обработку метаданных (`hash`, `last4`, `redact`, `none`). - `telemetry(...)` принимает выражения, возвращающие `Option`. Каждое присутствующее поле добавляется в [`Metadata`]; пустые выражения пропускаются. diff --git a/README.template.md b/README.template.md index 886ef7b..1095ee8 100644 --- a/README.template.md +++ b/README.template.md @@ -193,7 +193,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(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)) @@ -232,7 +232,8 @@ assert_eq!( - `message` forwards the formatted [`Display`] output as the safe public message. Omit it to keep the message private. - `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport - boundary. + boundary, `fields("name" = hash, "card" = last4)` overrides metadata + policies (`hash`, `last4`, `redact`, `none`). - `telemetry(...)` accepts expressions that evaluate to `Option`. Each populated field is inserted into the resulting [`Metadata`]; use `telemetry()` when no fields are attached. diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index 797d219..76edd2a 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index adedc94..382c0e4 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -6,7 +6,7 @@ use syn::{ Expr, ExprPath, Field as SynField, Fields as SynFields, GenericArgument, Ident, LitBool, LitInt, LitStr, Token, TypePath, ext::IdentExt, - parse::{Parse, ParseStream}, + parse::{Parse, ParseBuffer, ParseStream}, punctuated::Punctuated, spanned::Spanned, token::Paren @@ -62,7 +62,7 @@ pub struct MasterrorSpec { pub code: Expr, pub category: ExprPath, pub expose_message: bool, - pub redact_message: bool, + pub redact: RedactSpec, pub telemetry: Vec, pub map_grpc: Option, pub map_problem: Option, @@ -70,6 +70,26 @@ pub struct MasterrorSpec { pub attribute_span: Span } +#[derive(Clone, Debug, Default)] +pub struct RedactSpec { + pub message: bool, + pub fields: Vec +} + +#[derive(Clone, Debug)] +pub struct FieldRedactionSpec { + pub name: LitStr, + pub policy: FieldRedactionKind +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldRedactionKind { + None, + Redact, + Hash, + Last4 +} + #[derive(Debug)] pub enum Fields { Unit, @@ -791,7 +811,8 @@ fn parse_masterror_attribute(attr: &Attribute) -> Result { let mut code = None; let mut category = None; let mut expose_message = false; - let mut redact_message = false; + let mut redact = RedactSpec::default(); + let mut seen_redact = false; let mut telemetry = None; let mut map_grpc = None; let mut map_problem = None; @@ -822,10 +843,11 @@ fn parse_masterror_attribute(attr: &Attribute) -> Result { expose_message = parse_flag_value(input)?; } "redact" => { - if redact_message { + if seen_redact { return Err(Error::new(ident.span(), "duplicate redact(...) block")); } - redact_message = parse_redact_block(input, ident.span())?; + redact = parse_redact_block(input, ident.span())?; + seen_redact = true; } "telemetry" => { if telemetry.is_some() { @@ -909,7 +931,7 @@ fn parse_masterror_attribute(attr: &Attribute) -> Result { code, category, expose_message, - redact_message, + redact, telemetry: telemetry.unwrap_or_default(), map_grpc, map_problem, @@ -928,7 +950,7 @@ fn parse_flag_value(input: ParseStream) -> Result { } } -fn parse_redact_block(input: ParseStream, span: Span) -> Result { +fn parse_redact_block(input: ParseStream, span: Span) -> Result { let content; syn::parenthesized!(content in input); @@ -936,22 +958,31 @@ fn parse_redact_block(input: ParseStream, span: Span) -> Result { return Err(Error::new(span, "redact(...) requires at least one option")); } - let mut redact_message = false; + let mut spec = RedactSpec::default(); while !content.is_empty() { let ident: Ident = content.call(Ident::parse_any)?; match ident.to_string().as_str() { "message" => { - if redact_message { + if spec.message { return Err(Error::new(ident.span(), "duplicate redact(message) option")); } if content.peek(Token![=]) { content.parse::()?; let value: LitBool = content.parse()?; - redact_message = value.value; + spec.message = value.value; } else { - redact_message = true; + spec.message = true; + } + } + "fields" => { + if !spec.fields.is_empty() { + return Err(Error::new( + ident.span(), + "duplicate redact(fields(...)) option" + )); } + spec.fields = parse_redact_fields(&content, ident.span())?; } other => { return Err(Error::new( @@ -971,7 +1002,60 @@ fn parse_redact_block(input: ParseStream, span: Span) -> Result { } } - Ok(redact_message) + Ok(spec) +} + +fn parse_redact_fields( + content: &ParseBuffer<'_>, + span: Span +) -> Result, Error> { + let inner; + syn::parenthesized!(inner in *content); + + if inner.is_empty() { + return Err(Error::new( + span, + "redact(fields(...)) requires at least one field" + )); + } + + let mut fields = Vec::new(); + while !inner.is_empty() { + let name: LitStr = inner.parse()?; + let policy = if inner.peek(Token![=]) { + inner.parse::()?; + let ident: Ident = inner.call(Ident::parse_any)?; + match ident.to_string().to_ascii_lowercase().as_str() { + "none" => FieldRedactionKind::None, + "redact" => FieldRedactionKind::Redact, + "hash" => FieldRedactionKind::Hash, + "last4" | "last_four" => FieldRedactionKind::Last4, + other => { + return Err(Error::new( + ident.span(), + format!("unknown redact policy `{other}` in fields(...)") + )); + } + } + } else { + FieldRedactionKind::Redact + }; + fields.push(FieldRedactionSpec { + name, + policy + }); + + if inner.peek(Token![,]) { + inner.parse::()?; + } else if !inner.is_empty() { + return Err(Error::new( + inner.span(), + "expected `,` or end of input in redact(fields(...))" + )); + } + } + + Ok(fields) } fn parse_telemetry_block(input: ParseStream, span: Span) -> Result, Error> { diff --git a/masterror-derive/src/masterror_impl.rs b/masterror-derive/src/masterror_impl.rs index e16145e..1a91dc0 100644 --- a/masterror-derive/src/masterror_impl.rs +++ b/masterror-derive/src/masterror_impl.rs @@ -3,8 +3,8 @@ use quote::{format_ident, quote}; use syn::{Error, Expr, ExprPath, Index}; use crate::input::{ - ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_arc_type, - is_option_type, option_inner_type + ErrorData, ErrorInput, Field, FieldRedactionKind, Fields, MasterrorSpec, RedactSpec, + StructData, VariantData, is_arc_type, is_option_type, option_inner_type }; pub fn expand(input: &ErrorInput) -> Result { @@ -75,7 +75,7 @@ fn struct_conversion_impl( let field_usage = field_usage_tokens(&bound_fields); let telemetry_init = telemetry_initialization(&spec.telemetry); let metadata_attach = metadata_attach_tokens(); - let redact_tokens = redact_tokens(spec.redact_message); + let redact_tokens = redact_tokens(&spec.redact); let source_tokens = source_attachment_tokens(&bound_fields); let backtrace_tokens = backtrace_attachment_tokens(&data.fields, &bound_fields); @@ -117,7 +117,7 @@ fn enum_conversion_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenSt let field_usage = field_usage_tokens(&bound_fields); let telemetry_init = telemetry_initialization(&spec.telemetry); let metadata_attach = metadata_attach_tokens(); - let redact_tokens = redact_tokens(spec.redact_message); + let redact_tokens = redact_tokens(&spec.redact); let source_tokens = source_attachment_tokens(&bound_fields); let backtrace_tokens = backtrace_attachment_tokens(&variant.fields, &bound_fields); message_arms.push(enum_message_arm(ident, variant, spec.expose_message)); @@ -420,13 +420,35 @@ fn metadata_attach_tokens() -> TokenStream { } } -fn redact_tokens(enabled: bool) -> TokenStream { - if enabled { +fn redact_tokens(spec: &RedactSpec) -> TokenStream { + let message = if spec.message { quote!( __masterror_error = __masterror_error.redactable(); ) } else { TokenStream::new() + }; + + let field_updates = spec.fields.iter().map(|field| { + let name = &field.name; + let policy = field_redaction_tokens(field.policy); + quote!( + __masterror_error = __masterror_error.redact_field(#name, #policy); + ) + }); + + quote! { + #message + #( #field_updates )* + } +} + +fn field_redaction_tokens(kind: FieldRedactionKind) -> TokenStream { + match kind { + FieldRedactionKind::None => quote!(masterror::FieldRedaction::None), + FieldRedactionKind::Redact => quote!(masterror::FieldRedaction::Redact), + FieldRedactionKind::Hash => quote!(masterror::FieldRedaction::Hash), + FieldRedactionKind::Last4 => quote!(masterror::FieldRedaction::Last4) } } diff --git a/src/app_error.rs b/src/app_error.rs index c206df3..aaf3fe6 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -70,7 +70,7 @@ mod metadata; pub use core::{AppError, AppResult, Error, MessageEditPolicy}; pub use context::Context; -pub use metadata::{Field, FieldValue, Metadata, field}; +pub use metadata::{Field, FieldRedaction, FieldValue, Metadata, field}; #[cfg(test)] mod tests; diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 57cb5cc..3539194 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -2,7 +2,7 @@ use std::{error::Error as StdError, panic::Location}; use super::{ core::{AppError, Error, MessageEditPolicy}, - metadata::{Field, FieldValue} + metadata::{Field, FieldRedaction, FieldValue} }; use crate::{AppCode, AppErrorKind}; @@ -41,6 +41,7 @@ pub struct Context { code: AppCode, category: AppErrorKind, fields: Vec, + field_policies: Vec<(&'static str, FieldRedaction)>, edit_policy: MessageEditPolicy, caller_location: Option<&'static Location<'static>>, code_overridden: bool @@ -57,6 +58,7 @@ impl Context { code: AppCode::from(category), category, fields: Vec::new(), + field_policies: Vec::new(), edit_policy: MessageEditPolicy::Preserve, caller_location: None, code_overridden: false @@ -88,6 +90,21 @@ impl Context { #[must_use] pub fn with(mut self, field: Field) -> Self { self.fields.push(field); + self.apply_field_redactions(); + self + } + + /// Override the redaction policy for a metadata field. + #[must_use] + pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { + self.field_policies + .retain(|(existing, _)| *existing != name); + self.field_policies.push((name, redaction)); + for field in &mut self.fields { + if field.name() == name { + field.set_redaction(redaction); + } + } self } @@ -132,8 +149,12 @@ impl Context { let mut error = AppError::new_raw(self.category, None); error.code = self.code; if !self.fields.is_empty() { + self.apply_field_redactions(); error.metadata.extend(self.fields); } + for &(name, redaction) in &self.field_policies { + error = error.redact_field(name, redaction); + } if matches!(self.edit_policy, MessageEditPolicy::Redact) { error.edit_policy = MessageEditPolicy::Redact; } @@ -142,3 +163,21 @@ impl Context { error } } + +impl Context { + fn apply_field_redactions(&mut self) { + if self.field_policies.is_empty() { + return; + } + for field in &mut self.fields { + if let Some((_, policy)) = self + .field_policies + .iter() + .rev() + .find(|(name, _)| *name == field.name()) + { + field.set_redaction(*policy); + } + } + } +} diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 7284192..e9384ce 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -21,7 +21,7 @@ use std::{ #[cfg(feature = "tracing")] use tracing::{Level, event}; -use super::metadata::{Field, Metadata}; +use super::metadata::{Field, FieldRedaction, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; /// Controls whether the public message may be redacted before exposure. @@ -384,6 +384,14 @@ impl Error { self } + /// Override the redaction policy for a stored metadata field. + #[must_use] + pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { + self.metadata.set_redaction(name, redaction); + self.mark_dirty(); + self + } + /// Replace metadata entirely. #[must_use] pub fn with_metadata(mut self, metadata: Metadata) -> Self { diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs index 754793b..0aa8267 100644 --- a/src/app_error/metadata.rs +++ b/src/app_error/metadata.rs @@ -4,6 +4,20 @@ use std::{ fmt::{Display, Formatter, Result as FmtResult} }; +/// Redaction policy associated with a metadata [`Field`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum FieldRedaction { + /// Preserve the value as-is. + #[default] + None, + /// Remove the value from public payloads. + Redact, + /// Hash the value with a cryptographic digest before exposure. + Hash, + /// Preserve only the last four characters (mask the rest). + Last4 +} + use uuid::Uuid; /// Value stored inside [`Metadata`]. @@ -41,17 +55,20 @@ impl Display for FieldValue { /// Single metadata field – name plus value. #[derive(Clone, Debug, PartialEq)] pub struct Field { - name: &'static str, - value: FieldValue + name: &'static str, + value: FieldValue, + redaction: FieldRedaction } impl Field { /// Create a new [`Field`]. #[must_use] - pub const fn new(name: &'static str, value: FieldValue) -> Self { + pub fn new(name: &'static str, value: FieldValue) -> Self { + let redaction = infer_default_redaction(name); Self { name, - value + value, + redaction } } @@ -67,10 +84,93 @@ impl Field { &self.value } + /// Field redaction policy. + #[must_use] + pub const fn redaction(&self) -> FieldRedaction { + self.redaction + } + + /// Override the redaction policy while consuming the field. + #[must_use] + pub fn with_redaction(mut self, redaction: FieldRedaction) -> Self { + self.redaction = redaction; + self + } + + /// Update the redaction policy in place. + pub fn set_redaction(&mut self, redaction: FieldRedaction) { + self.redaction = redaction; + } + /// Consume the field and return owned components. #[must_use] - pub fn into_parts(self) -> (&'static str, FieldValue) { - (self.name, self.value) + pub fn into_parts(self) -> (&'static str, FieldValue, FieldRedaction) { + (self.name, self.value, self.redaction) + } + + /// Consume the field and return only the value. + #[must_use] + pub fn into_value(self) -> FieldValue { + self.value + } +} + +fn infer_default_redaction(name: &str) -> FieldRedaction { + let lowered = name.to_ascii_lowercase(); + + if lowered.contains("password") + || lowered.contains("passphrase") + || lowered.contains("secret") + || lowered.contains("authorization") + || lowered.contains("cookie") + || lowered.contains("session") + || lowered.contains("jwt") + || lowered.contains("bearer") + || lowered.contains("otp") + || lowered.contains("pin") + { + return FieldRedaction::Redact; + } + + let mut card_like = false; + let mut number_like = false; + + for segment in lowered.split(['.', '_', '-', ':', '/']) { + if segment.is_empty() { + continue; + } + if segment.eq_ignore_ascii_case("token") + || segment.eq_ignore_ascii_case("apikey") + || segment.eq_ignore_ascii_case("api") && lowered.contains("key") + || segment.ends_with("token") + || segment.eq_ignore_ascii_case("key") + || segment.eq_ignore_ascii_case("access") && lowered.contains("token") + || segment.eq_ignore_ascii_case("refresh") && lowered.contains("token") + { + return FieldRedaction::Hash; + } + + if segment.eq_ignore_ascii_case("card") + || segment.eq_ignore_ascii_case("iban") + || segment.eq_ignore_ascii_case("pan") + || segment.eq_ignore_ascii_case("account") + || segment.eq_ignore_ascii_case("acct") + { + card_like = true; + } + + if segment.eq_ignore_ascii_case("number") + || segment.eq_ignore_ascii_case("no") + || segment.eq_ignore_ascii_case("id") + { + number_like = true; + } + } + + if card_like && number_like { + FieldRedaction::Last4 + } else { + FieldRedaction::None } } @@ -81,7 +181,7 @@ impl Field { /// enum construction. #[derive(Clone, Debug, Default, PartialEq)] pub struct Metadata { - fields: BTreeMap<&'static str, FieldValue> + fields: BTreeMap<&'static str, Field> } impl Metadata { @@ -95,12 +195,8 @@ impl Metadata { #[must_use] pub fn from_fields(fields: impl IntoIterator) -> Self { let mut map = BTreeMap::new(); - for Field { - name, - value - } in fields - { - map.insert(name, value); + for field in fields { + map.insert(field.name, field); } Self { fields: map @@ -121,8 +217,9 @@ impl Metadata { /// Insert or replace a field and return the previous value. pub fn insert(&mut self, field: Field) -> Option { - let (name, value) = field.into_parts(); - self.fields.insert(name, value) + self.fields + .insert(field.name, field) + .map(|previous| previous.into_value()) } /// Extend metadata with additional fields. @@ -135,29 +232,57 @@ impl Metadata { /// Borrow a field value by name. #[must_use] pub fn get(&self, name: &'static str) -> Option<&FieldValue> { + self.fields.get(name).map(|field| field.value()) + } + + /// Borrow the full field entry by name. + #[must_use] + pub fn get_field(&self, name: &'static str) -> Option<&Field> { self.fields.get(name) } + /// Override the redaction policy for a specific field. + pub fn set_redaction(&mut self, name: &'static str, redaction: FieldRedaction) { + if let Some(field) = self.fields.get_mut(name) { + field.set_redaction(redaction); + } + } + + /// Retrieve the redaction policy for a field if present. + #[must_use] + pub fn redaction(&self, name: &'static str) -> Option { + self.fields.get(name).map(|field| field.redaction()) + } + /// Iterator over metadata fields in sorted order. pub fn iter(&self) -> impl Iterator { - self.fields.iter().map(|(k, v)| (*k, v)) + self.fields.iter().map(|(k, v)| (*k, v.value())) + } + + /// Iterator over metadata entries including the redaction policy. + pub fn iter_with_redaction( + &self + ) -> impl Iterator { + self.fields + .iter() + .map(|(name, field)| (*name, field.value(), field.redaction())) } } impl IntoIterator for Metadata { type Item = Field; type IntoIter = std::iter::Map< - std::collections::btree_map::IntoIter<&'static str, FieldValue>, - fn((&'static str, FieldValue)) -> Field + std::collections::btree_map::IntoIter<&'static str, Field>, + fn((&'static str, Field)) -> Field >; fn into_iter(self) -> Self::IntoIter { - fn into_field(entry: (&'static str, FieldValue)) -> Field { - Field::new(entry.0, entry.1) + fn into_field(entry: (&'static str, Field)) -> Field { + entry.1 } self.fields .into_iter() - .map(into_field as fn((&'static str, FieldValue)) -> Field) + .map(into_field as fn((&'static str, Field)) -> Field) } } @@ -206,7 +331,7 @@ mod tests { use uuid::Uuid; - use super::{FieldValue, Metadata, field}; + use super::{FieldRedaction, FieldValue, Metadata, field}; #[test] fn metadata_roundtrip() { @@ -219,6 +344,7 @@ mod tests { Some(&FieldValue::Str(Cow::Borrowed("abc"))) ); assert_eq!(meta.get("count"), Some(&FieldValue::I64(42))); + assert_eq!(meta.redaction("request_id"), Some(FieldRedaction::None)); } #[test] @@ -240,14 +366,27 @@ mod tests { assert_eq!(meta.get("count"), Some(&FieldValue::I64(2))); } + #[test] + fn default_redaction_applies_to_common_keys() { + let password = field::str("password", Cow::Borrowed("secret")); + assert!(matches!(password.redaction(), FieldRedaction::Redact)); + + let token = field::str("api_token", Cow::Borrowed("abcdef")); + assert!(matches!(token.redaction(), FieldRedaction::Hash)); + + let card = field::str("card_number", Cow::Borrowed("4111111111111111")); + assert!(matches!(card.redaction(), FieldRedaction::Last4)); + } + #[test] fn field_into_parts_returns_components() { let field = field::u64("elapsed_ms", 30); let clone = field.clone(); assert_eq!(clone.name(), field.name()); assert_eq!(clone.value(), field.value()); - let (owned_name, owned_value) = clone.into_parts(); + let (owned_name, owned_value, redaction) = clone.into_parts(); assert_eq!(owned_name, field.name()); assert_eq!(owned_value, field.value().clone()); + assert_eq!(redaction, field.redaction()); } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 0ca0ec6..3d9812c 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -8,7 +8,7 @@ use super::core::reset_backtrace_preference; #[cfg(feature = "backtrace")] static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); -use super::{AppError, FieldValue, MessageEditPolicy, field}; +use super::{AppError, FieldRedaction, FieldValue, MessageEditPolicy, field}; use crate::{AppCode, AppErrorKind}; // --- Helpers ------------------------------------------------------------- @@ -170,6 +170,37 @@ fn metadata_and_code_are_preserved() { assert_eq!(metadata.get("attempt"), Some(&FieldValue::I64(2))); } +#[test] +fn context_redact_field_overrides_policy() { + let err = super::Context::new(AppErrorKind::Service) + .with(field::str("token", "super-secret")) + .redact_field("token", FieldRedaction::Redact) + .into_error(DummyError); + + let metadata = err.metadata(); + assert_eq!( + metadata.get("token"), + Some(&FieldValue::Str(Cow::Borrowed("super-secret"))) + ); + assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Redact)); +} + +#[test] +fn app_error_redact_field_updates_metadata() { + let err = AppError::internal("boom") + .with_field(field::str("api_key", "key")) + .redact_field("api_key", FieldRedaction::Hash); + + assert_eq!( + err.metadata().redaction("api_key"), + Some(FieldRedaction::Hash) + ); + assert_eq!( + err.metadata().get("api_key"), + Some(&FieldValue::Str(Cow::Borrowed("key"))) + ); +} + #[derive(Debug)] struct DummyError; diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 4126503..e56c68e 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -31,7 +31,8 @@ use tonic::{ #[cfg(test)] use crate::CODE_MAPPINGS; use crate::{ - AppErrorKind, Error, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, mapping_for_code + AppErrorKind, Error, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, + mapping_for_code }; impl TryFrom for Status { @@ -102,7 +103,10 @@ fn insert_retry(meta: &mut MetadataMap, retry: RetryAdvice) { fn attach_metadata(meta: &mut MetadataMap, metadata: Metadata) { for field in metadata { - let (name, value) = field.into_parts(); + let (name, value, redaction) = field.into_parts(); + if !matches!(redaction, FieldRedaction::None) { + continue; + } if !is_safe_metadata_key(name) { continue; } diff --git a/src/lib.rs b/src/lib.rs index 825b779..0f912bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,7 +106,7 @@ //! code = AppCode::NotFound, //! category = AppErrorKind::NotFound, //! message, -//! redact(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)) @@ -144,7 +144,8 @@ //! - `message` — expose the formatted [`core::fmt::Display`] output as the //! public message. //! - `redact(message)` — mark the message as redactable at the transport -//! boundary. +//! boundary, `fields("name" = hash, "card" = last4)` override metadata +//! policies (`hash`, `last4`, `redact`, `none`). //! - `telemetry(...)` — list of expressions producing //! `Option` to be inserted into [`Metadata`]. //! - `map.grpc` / `map.problem` — optional gRPC status (as `i32`) and @@ -322,7 +323,8 @@ pub mod prelude; pub mod mapping; pub use app_error::{ - AppError, AppResult, Context, Error, Field, FieldValue, MessageEditPolicy, Metadata, field + AppError, AppResult, Context, Error, Field, FieldRedaction, FieldValue, MessageEditPolicy, + Metadata, field }; pub use code::AppCode; pub use kind::AppErrorKind; diff --git a/src/response.rs b/src/response.rs index 760ea53..5f7d926 100644 --- a/src/response.rs +++ b/src/response.rs @@ -58,6 +58,7 @@ mod core; mod details; +pub mod internal; mod legacy; mod mapping; mod metadata; diff --git a/src/response/core.rs b/src/response/core.rs index ccd4768..6b429f4 100644 --- a/src/response/core.rs +++ b/src/response/core.rs @@ -92,4 +92,10 @@ impl ErrorResponse { pub fn status_code(&self) -> StatusCode { StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) } + + /// Formatter exposing internals for diagnostic logs. + #[must_use] + pub fn internal(&self) -> crate::response::internal::ErrorResponseFormatter<'_> { + crate::response::internal::ErrorResponseFormatter::new(self) + } } diff --git a/src/response/internal.rs b/src/response/internal.rs new file mode 100644 index 0000000..1cd90bd --- /dev/null +++ b/src/response/internal.rs @@ -0,0 +1,74 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +use super::{core::ErrorResponse, problem_json::ProblemJson}; + +/// Formatter exposing response internals for opt-in diagnostics. +#[derive(Clone, Copy)] +pub struct ErrorResponseFormatter<'a> { + inner: &'a ErrorResponse +} + +impl<'a> ErrorResponseFormatter<'a> { + pub(crate) fn new(inner: &'a ErrorResponse) -> Self { + Self { + inner + } + } +} + +impl Debug for ErrorResponseFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ErrorResponse") + .field("status", &self.inner.status) + .field("code", &self.inner.code) + .field("message", &self.inner.message) + .field("details", &self.inner.details) + .field("retry", &self.inner.retry) + .field("www_authenticate", &self.inner.www_authenticate) + .finish() + } +} + +impl Display for ErrorResponseFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self.inner, f) + } +} + +/// Formatter exposing problem-json internals for opt-in diagnostics. +#[derive(Clone, Copy)] +pub struct ProblemJsonFormatter<'a> { + inner: &'a ProblemJson +} + +impl<'a> ProblemJsonFormatter<'a> { + pub(crate) fn new(inner: &'a ProblemJson) -> Self { + Self { + inner + } + } +} + +impl Debug for ProblemJsonFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ProblemJson") + .field("type", &self.inner.type_uri) + .field("title", &self.inner.title) + .field("status", &self.inner.status) + .field("detail", &self.inner.detail) + .field("code", &self.inner.code) + .field("grpc", &self.inner.grpc) + .field("metadata", &self.inner.metadata) + .finish() + } +} + +impl Display for ProblemJsonFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {}: {:?}", + self.inner.status, self.inner.code, self.inner.detail + ) + } +} diff --git a/src/response/mapping.rs b/src/response/mapping.rs index cc522c5..c0d2302 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -16,11 +16,12 @@ impl From for ErrorResponse { let code = err.code; let retry = err.retry.take(); let www_authenticate = err.www_authenticate.take(); + let policy = err.edit_policy; let status = kind.http_status(); let message = match err.message.take() { - Some(msg) => msg.into_owned(), - None => kind.to_string() + Some(msg) if !matches!(policy, crate::MessageEditPolicy::Redact) => msg.into_owned(), + _ => kind.to_string() }; Self { @@ -37,7 +38,11 @@ impl From for ErrorResponse { impl From<&AppError> for ErrorResponse { fn from(err: &AppError) -> Self { let status = err.kind.http_status(); - let message = err.render_message().into_owned(); + let message = if matches!(err.edit_policy, crate::MessageEditPolicy::Redact) { + err.kind.to_string() + } else { + err.render_message().into_owned() + }; Self { status, diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 28af7e7..440dd2e 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -1,10 +1,13 @@ -use std::{borrow::Cow, collections::BTreeMap}; +use std::{borrow::Cow, collections::BTreeMap, fmt::Write}; use http::StatusCode; use serde::Serialize; +use sha2::{Digest, Sha256}; use super::core::ErrorResponse; -use crate::{AppCode, AppError, AppErrorKind, FieldValue, MessageEditPolicy, Metadata}; +use crate::{ + AppCode, AppError, AppErrorKind, FieldRedaction, FieldValue, MessageEditPolicy, Metadata +}; /// Canonical mapping for a public [`AppCode`]. /// @@ -260,6 +263,12 @@ impl ProblemJson { Err(_) => StatusCode::INTERNAL_SERVER_ERROR } } + + /// Formatter exposing internals for diagnostic logging. + #[must_use] + pub fn internal(&self) -> crate::response::internal::ProblemJsonFormatter<'_> { + crate::response::internal::ProblemJsonFormatter::new(self) + } } /// Metadata section of a [`ProblemJson`] payload. @@ -291,7 +300,7 @@ impl ProblemMetadata { /// ```rust /// use masterror::{ProblemMetadataValue, field}; /// -/// let (_name, field_value) = field::u64("attempt", 2).into_parts(); +/// let (_name, field_value, _redaction) = field::u64("attempt", 2).into_parts(); /// let value = ProblemMetadataValue::from(field_value); /// assert!(matches!(value, ProblemMetadataValue::U64(2))); /// ``` @@ -362,8 +371,10 @@ fn sanitize_metadata_owned( let mut public = BTreeMap::new(); for field in metadata { - let (name, value) = field.into_parts(); - public.insert(name, ProblemMetadataValue::from(value)); + let (name, value, redaction) = field.into_parts(); + if let Some(sanitized) = sanitize_problem_metadata_value_owned(value, redaction) { + public.insert(name, sanitized); + } } if public.is_empty() { @@ -382,8 +393,10 @@ fn sanitize_metadata_ref( } let mut public = BTreeMap::new(); - for (name, value) in metadata.iter() { - public.insert(name, ProblemMetadataValue::from(value)); + for (name, value, redaction) in metadata.iter_with_redaction() { + if let Some(sanitized) = sanitize_problem_metadata_value_ref(value, redaction) { + public.insert(name, sanitized); + } } if public.is_empty() { @@ -393,6 +406,103 @@ fn sanitize_metadata_ref( } } +const REDACTED_PLACEHOLDER: &str = "[REDACTED]"; + +fn sanitize_problem_metadata_value_owned( + value: FieldValue, + redaction: FieldRedaction +) -> Option { + match redaction { + FieldRedaction::None => Some(ProblemMetadataValue::from(value)), + FieldRedaction::Redact => Some(ProblemMetadataValue::String(Cow::Borrowed( + REDACTED_PLACEHOLDER + ))), + FieldRedaction::Hash => Some(ProblemMetadataValue::String(Cow::Owned(hash_field_value( + &value + )))), + FieldRedaction::Last4 => mask_last4_field_value(&value) + .map(|masked| ProblemMetadataValue::String(Cow::Owned(masked))) + } +} + +fn sanitize_problem_metadata_value_ref( + value: &FieldValue, + redaction: FieldRedaction +) -> Option { + match redaction { + FieldRedaction::None => Some(ProblemMetadataValue::from(value)), + FieldRedaction::Redact => Some(ProblemMetadataValue::String(Cow::Borrowed( + REDACTED_PLACEHOLDER + ))), + FieldRedaction::Hash => Some(ProblemMetadataValue::String(Cow::Owned(hash_field_value( + value + )))), + FieldRedaction::Last4 => mask_last4_field_value(value) + .map(|masked| ProblemMetadataValue::String(Cow::Owned(masked))) + } +} + +fn hash_field_value(value: &FieldValue) -> String { + let mut hasher = Sha256::new(); + match value { + FieldValue::Str(value) => hasher.update(value.as_ref().as_bytes()), + FieldValue::I64(value) => { + let string = value.to_string(); + hasher.update(string.as_bytes()); + } + FieldValue::U64(value) => { + let string = value.to_string(); + hasher.update(string.as_bytes()); + } + FieldValue::Bool(value) => { + if *value { + hasher.update(b"true"); + } else { + hasher.update(b"false"); + } + } + FieldValue::Uuid(value) => { + let string = value.to_string(); + hasher.update(string.as_bytes()); + } + } + let digest = hasher.finalize(); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(&mut hex, "{:02x}", byte); + } + hex +} + +fn mask_last4_field_value(value: &FieldValue) -> Option { + match value { + FieldValue::Str(value) => Some(mask_last4(value.as_ref())), + FieldValue::I64(value) => Some(mask_last4(&value.to_string())), + FieldValue::U64(value) => Some(mask_last4(&value.to_string())), + FieldValue::Uuid(value) => Some(mask_last4(&value.to_string())), + FieldValue::Bool(_) => None + } +} + +fn mask_last4(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let total = chars.len(); + if total == 0 { + return String::new(); + } + + let keep = if total <= 4 { 1 } else { 4 }; + let mask_len = total.saturating_sub(keep); + let mut masked = String::with_capacity(value.len()); + for _ in 0..mask_len { + masked.push('*'); + } + for ch in chars.iter().skip(mask_len) { + masked.push(*ch); + } + masked +} + /// Canonical mapping table covering every built-in [`AppCode`]. /// /// # Examples @@ -742,6 +852,51 @@ mod tests { assert!(!metadata.is_empty()); } + #[test] + fn redacted_metadata_uses_placeholder() { + let err = AppError::internal("oops").with_field(crate::field::str("password", "secret")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("password").expect("password field"); + match value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.as_ref(), super::REDACTED_PLACEHOLDER); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn hashed_metadata_masks_original_value() { + let err = AppError::internal("oops").with_field(crate::field::str("token", "super")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("token").expect("token field"); + match value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.len(), 64); + assert_ne!(text.as_ref(), "super"); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn last4_metadata_preserves_suffix() { + let err = AppError::internal("oops") + .with_field(crate::field::str("card_number", "4111111111111111")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("card_number").expect("card number"); + match value { + ProblemMetadataValue::String(text) => { + assert!(text.ends_with("1111")); + assert!(text.starts_with("************")); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + #[test] fn mapping_for_every_code_matches_http_status() { for (code, mapping) in CODE_MAPPINGS { diff --git a/src/response/tests.rs b/src/response/tests.rs index 43fa5f0..89777e5 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -1,5 +1,5 @@ use super::ErrorResponse; -use crate::{AppCode, AppError, AppErrorKind}; +use crate::{AppCode, AppError, AppErrorKind, ProblemJson}; // --- Basic constructors and fields -------------------------------------- @@ -181,6 +181,18 @@ fn from_app_error_bare_uses_kind_display_as_message() { assert_eq!(resp.message, AppErrorKind::Timeout.to_string()); } +#[test] +fn from_app_error_redacts_message_when_policy_allows() { + let app = AppError::internal("sensitive").redactable(); + let resp: ErrorResponse = app.into(); + + assert_eq!(resp.message, AppErrorKind::Internal.to_string()); + + let borrowed = AppError::internal("private").redactable(); + let resp_ref: ErrorResponse = (&borrowed).into(); + assert_eq!(resp_ref.message, AppErrorKind::Internal.to_string()); +} + // --- Display formatting -------------------------------------------------- #[test] @@ -301,6 +313,17 @@ fn serialized_json_contains_core_fields() { assert!(s.contains("\"after_seconds\":1")); } +#[test] +fn internal_formatters_are_opt_in() { + let resp = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); + let formatted = format!("{:?}", resp.internal()); + assert!(formatted.contains("ErrorResponse")); + + let problem = ProblemJson::from_ref(&AppError::not_found("missing")); + let formatted_problem = format!("{:?}", problem.internal()); + assert!(formatted_problem.contains("ProblemJson")); +} + #[cfg(feature = "axum")] #[test] fn app_error_into_response_maps_status() { diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs index 668b340..38b162f 100644 --- a/tests/masterror_macro.rs +++ b/tests/masterror_macro.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use masterror::{ - AppCode, AppErrorKind, Error as MasterrorError, Masterror, MessageEditPolicy, + AppCode, AppErrorKind, Error as MasterrorError, FieldRedaction, Masterror, MessageEditPolicy, mapping::{GrpcMapping, HttpMapping, ProblemMapping} }; @@ -13,7 +13,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(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)) @@ -57,7 +57,7 @@ enum ApiError { #[test] fn struct_masterror_conversion_populates_metadata_and_source() { - let source = std::io::Error::new(std::io::ErrorKind::Other, "backend down"); + let source = std::io::Error::other("backend down"); let err = MissingFlag { user_id: "alice".into(), flag: "beta", @@ -101,6 +101,10 @@ fn struct_masterror_conversion_populates_metadata_and_source() { MissingFlag::HTTP_MAPPING, HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) ); + assert_eq!( + converted.metadata().redaction("user_id"), + Some(FieldRedaction::Hash) + ); assert_eq!(MissingFlag::HTTP_MAPPING.status(), 404); let grpc = MissingFlag::GRPC_MAPPING.expect("grpc mapping"); diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index c3fb86b..113a10d 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index b331baa..9ada290 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index b2658a1..fd151cc 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 83d517f..5a25e12 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | #[error("missing")] - | ^ +8 | / #[error("missing")] +9 | | Missing + | |___________^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index f929951..bdadf45 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 34abc91..037fac8 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index d579838..1822edf 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default From 565e3953507b0305b116d2fa01ef0f2c21ae6574 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 07:10:12 +0700 Subject: [PATCH 12/25] Finalize context error migration --- CHANGELOG.md | 13 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +- src/code/app_code.rs | 6 + src/convert/config.rs | 89 ++++- src/convert/multipart.rs | 23 +- src/convert/redis.rs | 111 +++++- src/convert/reqwest.rs | 149 +++++-- src/convert/serde_json.rs | 56 ++- src/convert/sqlx.rs | 373 ++++++++++++++++-- src/convert/telegram_webapp_sdk.rs | 48 ++- src/convert/teloxide.rs | 97 +++-- src/convert/tokio.rs | 27 +- src/convert/validator.rs | 80 +++- src/response/problem_json.rs | 12 + .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 - .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 32 files changed, 969 insertions(+), 188 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c341449..78e6ad4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.18.0] - 2025-09-28 + +### Added +- Added the `AppCode::UserAlreadyExists` classification and mapped it to RFC7807 + responses with the appropriate retry hint. + +### Changed +- Switched all integration converters in `src/convert/*` to build structured + `Context` metadata before producing `Error` values, including HTTP status, + operation, endpoint, duration and retry/edit flags. +- Extended integration tests to validate the enriched metadata, retry behavior + and error code/category mappings across the updated converters. + ## [0.17.0] - 2025-09-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index dc61df7..2181e27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.17.0" +version = "0.18.0" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 3a8f8a7..55b022a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.17.0" +version = "0.18.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 278be85..472f547 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.17.0", default-features = false } +masterror = { version = "0.18.0", default-features = false } # or with features: -# masterror = { version = "0.17.0", features = [ +# masterror = { version = "0.18.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.17.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.17.0", default-features = false } +masterror = { version = "0.18.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.17.0", features = [ +# masterror = { version = "0.18.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.17.0", default-features = false } +masterror = { version = "0.18.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.17.0", features = [ +masterror = { version = "0.18.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.17.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.17.0", features = [ +masterror = { version = "0.18.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/code/app_code.rs b/src/code/app_code.rs index 11bf384..b46d699 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -36,6 +36,11 @@ pub enum AppCode { /// Typically mapped to HTTP **409 Conflict**. Conflict, + /// Attempted to create a user that already exists (unique constraint). + /// + /// Typically mapped to HTTP **409 Conflict**. + UserAlreadyExists, + /// Authentication required or failed (missing/invalid credentials). /// /// Typically mapped to HTTP **401 Unauthorized**. @@ -149,6 +154,7 @@ impl AppCode { AppCode::NotFound => "NOT_FOUND", AppCode::Validation => "VALIDATION", AppCode::Conflict => "CONFLICT", + AppCode::UserAlreadyExists => "USER_ALREADY_EXISTS", AppCode::Unauthorized => "UNAUTHORIZED", AppCode::Forbidden => "FORBIDDEN", AppCode::NotImplemented => "NOT_IMPLEMENTED", diff --git a/src/convert/config.rs b/src/convert/config.rs index e88ae5a..d3ef335 100644 --- a/src/convert/config.rs +++ b/src/convert/config.rs @@ -1,4 +1,4 @@ -//! Convert [`config::ConfigError`] into [`AppError`], +//! Convert [`config::ConfigError`] into [`Error`], //! producing [`AppErrorKind::Config`]. //! //! Enabled with the `config` feature. @@ -7,23 +7,90 @@ //! //! ```rust,ignore //! use config::ConfigError; -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! //! let err = ConfigError::Message("missing key".into()); -//! let app_err: AppError = err.into(); +//! let app_err: Error = err.into(); //! assert!(matches!(app_err.kind, AppErrorKind::Config)); //! ``` #[cfg(feature = "config")] use config::ConfigError; #[cfg(feature = "config")] -use crate::AppError; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; #[cfg(feature = "config")] #[cfg_attr(docsrs, doc(cfg(feature = "config")))] -impl From for AppError { +impl From for Error { fn from(err: ConfigError) -> Self { - AppError::config(err.to_string()) + build_context(&err).into_error(err) + } +} + +#[cfg(feature = "config")] +fn build_context(error: &ConfigError) -> Context { + match error { + ConfigError::Frozen => { + Context::new(AppErrorKind::Config).with(field::str("config.phase", "frozen")) + } + ConfigError::NotFound(key) => Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "not_found")) + .with(field::str("config.key", key.clone())), + ConfigError::PathParse { + .. + } => Context::new(AppErrorKind::Config).with(field::str("config.phase", "path_parse")), + ConfigError::FileParse { + uri, .. + } => { + let mut ctx = + Context::new(AppErrorKind::Config).with(field::str("config.phase", "file_parse")); + if let Some(path) = uri { + ctx = ctx.with(field::str("config.uri", path.clone())); + } + ctx + } + ConfigError::Type { + origin, + unexpected, + expected, + key + } => { + let mut ctx = Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "type")) + .with(field::str("config.expected", *expected)) + .with(field::str("config.unexpected", unexpected.to_string())); + if let Some(origin) = origin { + ctx = ctx.with(field::str("config.origin", origin.clone())); + } + if let Some(key) = key { + ctx = ctx.with(field::str("config.key", key.clone())); + } + ctx + } + ConfigError::At { + origin, + key, + .. + } => { + let mut ctx = + Context::new(AppErrorKind::Config).with(field::str("config.phase", "at")); + if let Some(origin) = origin { + ctx = ctx.with(field::str("config.origin", origin.clone())); + } + if let Some(key) = key { + ctx = ctx.with(field::str("config.key", key.clone())); + } + ctx + } + ConfigError::Message(message) => Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "message")) + .with(field::str("config.message", message.clone())), + ConfigError::Foreign(_) => { + Context::new(AppErrorKind::Config).with(field::str("config.phase", "foreign")) + } } } @@ -31,12 +98,18 @@ impl From for AppError { mod tests { use config::ConfigError; - use crate::{AppError, AppErrorKind}; + use super::*; + use crate::{AppErrorKind, FieldValue}; #[test] fn maps_to_config_kind() { let err = ConfigError::Message("dummy".into()); - let app_err = AppError::from(err); + let app_err = Error::from(err); assert!(matches!(app_err.kind, AppErrorKind::Config)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("config.phase"), + Some(&FieldValue::Str("message".into())) + ); } } diff --git a/src/convert/multipart.rs b/src/convert/multipart.rs index f23750e..212db57 100644 --- a/src/convert/multipart.rs +++ b/src/convert/multipart.rs @@ -1,4 +1,4 @@ -//! Maps [`MultipartError`] into [`AppError`] with +//! Maps [`MultipartError`] into [`Error`] with //! [`AppErrorKind::BadRequest`], preserving the original message. //! //! Intended for Axum multipart form parsing so that client mistakes are @@ -8,11 +8,16 @@ use axum::extract::multipart::MultipartError; -use crate::{AppError, AppErrorKind}; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; -impl From for AppError { +impl From for Error { fn from(err: MultipartError) -> Self { - AppError::with(AppErrorKind::BadRequest, format!("Multipart error: {err}")) + Context::new(AppErrorKind::BadRequest) + .with(field::str("multipart.reason", err.to_string())) + .into_error(err) } } @@ -24,7 +29,7 @@ mod tests { http::Request }; - use crate::{AppError, AppErrorKind}; + use crate::{AppErrorKind, FieldValue}; #[tokio::test] async fn multipart_error_maps_to_bad_request() { @@ -42,10 +47,12 @@ mod tests { .expect("extractor"); let err = multipart.next_field().await.expect_err("error"); - let expected = format!("Multipart error: {err}"); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert_eq!(app_err.kind, AppErrorKind::BadRequest); - assert_eq!(app_err.message.as_deref(), Some(expected.as_str())); + assert_eq!( + app_err.metadata().get("multipart.reason"), + Some(&FieldValue::Str(err.to_string().into())) + ); } } diff --git a/src/convert/redis.rs b/src/convert/redis.rs index 8494ae3..05cc4c3 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -1,12 +1,14 @@ -//! Conversion from [`redis::RedisError`] into [`AppError`]. +//! Conversion from [`redis::RedisError`] into [`Error`]. //! //! Enabled with the `redis` feature flag. //! //! ## Mapping //! -//! All Redis client errors are mapped to `AppErrorKind::Cache`. -//! The full error string from the driver is preserved in `message` for logs -//! and JSON payloads (if applicable). +//! All Redis client errors are mapped to `AppErrorKind::Cache` by default and +//! enriched with structured metadata (error kind, code, retry hints). Timeout +//! and infrastructure-level failures are promoted to `Timeout` or +//! `DependencyUnavailable` respectively. Metadata captures cluster redirects, +//! retry strategy and low-level flags without exposing sensitive payloads. //! //! This categorization treats Redis as a cache infrastructure dependency. //! If you need a different taxonomy (e.g. distinguishing caches from queues), @@ -16,10 +18,10 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use redis::RedisError; //! -//! fn handle_cache_error(e: RedisError) -> AppError { +//! fn handle_cache_error(e: RedisError) -> Error { //! e.into() //! } //! @@ -31,10 +33,13 @@ //! ``` #[cfg(feature = "redis")] -use redis::RedisError; +use redis::{RedisError, RetryMethod}; #[cfg(feature = "redis")] -use crate::AppError; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; /// Map any [`redis::RedisError`] into an [`AppError`] with kind `Cache`. /// @@ -42,10 +47,75 @@ use crate::AppError; /// Detailed driver errors are kept in the message for diagnostics. #[cfg(feature = "redis")] #[cfg_attr(docsrs, doc(cfg(feature = "redis")))] -impl From for AppError { +impl From for Error { fn from(err: RedisError) -> Self { - // Infrastructure cache issue -> cache-level error - AppError::cache(format!("Redis error: {err}")) + let (context, retry_after) = build_context(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); + } + error + } +} + +#[cfg(feature = "redis")] +fn build_context(err: &RedisError) -> (Context, Option) { + let mut context = Context::new(AppErrorKind::Cache) + .with(field::str("redis.kind", format!("{:?}", err.kind()))) + .with(field::str("redis.category", err.category())) + .with(field::bool("redis.is_timeout", err.is_timeout())) + .with(field::bool( + "redis.is_cluster_error", + err.is_cluster_error() + )) + .with(field::bool( + "redis.is_connection_refused", + err.is_connection_refusal() + )) + .with(field::bool( + "redis.is_connection_dropped", + err.is_connection_dropped() + )); + + if let Some(code) = err.code() { + context = context.with(field::str("redis.code", code.to_owned())); + } + + if err.is_timeout() { + context = context.category(AppErrorKind::Timeout); + } else if err.is_connection_refusal() + || err.is_connection_dropped() + || err.is_cluster_error() + || err.is_io_error() + { + context = context.category(AppErrorKind::DependencyUnavailable); + } + + if let Some((addr, slot)) = err.redirect_node() { + context = context + .with(field::str("redis.redirect_addr", addr.to_owned())) + .with(field::u64("redis.redirect_slot", u64::from(slot))); + } + + let retry_method = err.retry_method(); + let retry_after = retry_after_hint(retry_method); + context = context.with(field::str( + "redis.retry_method", + format!("{:?}", retry_method) + )); + + (context, retry_after) +} + +#[cfg(feature = "redis")] +const fn retry_after_hint(method: RetryMethod) -> Option { + match method { + RetryMethod::NoRetry => None, + RetryMethod::RetryImmediately | RetryMethod::AskRedirect | RetryMethod::MovedRedirect => { + Some(0) + } + RetryMethod::Reconnect | RetryMethod::ReconnectFromInitialConnections => Some(1), + RetryMethod::WaitAndRetry => Some(2) } } @@ -54,12 +124,25 @@ mod tests { use redis::ErrorKind; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[test] fn maps_to_cache_kind() { let redis_err = RedisError::from((ErrorKind::IoError, "boom")); - let app_err: AppError = redis_err.into(); - assert!(matches!(app_err.kind, AppErrorKind::Cache)); + let app_err: Error = redis_err.into(); + assert!(matches!(app_err.kind, AppErrorKind::DependencyUnavailable)); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("redis.kind"), + Some(&FieldValue::Str("IoError".into())) + ); + } + + #[test] + fn busy_loading_sets_retry_hint() { + let err = RedisError::from((ErrorKind::BusyLoadingError, "loading")); + let app_err: Error = err.into(); + assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(2)); + assert!(matches!(app_err.kind, AppErrorKind::DependencyUnavailable)); } } diff --git a/src/convert/reqwest.rs b/src/convert/reqwest.rs index 19256e2..9eeb29f 100644 --- a/src/convert/reqwest.rs +++ b/src/convert/reqwest.rs @@ -1,4 +1,4 @@ -//! Conversion from [`reqwest::Error`] into [`AppError`]. +//! Conversion from [`reqwest::Error`] into [`Error`]. //! //! Enabled with the `reqwest` feature flag. //! @@ -7,11 +7,16 @@ //! - [`reqwest::Error::is_timeout`] → `AppErrorKind::Timeout` //! - [`reqwest::Error::is_connect`] or [`reqwest::Error::is_request`] → //! `AppErrorKind::Network` -//! - [`reqwest::Error::is_status`] → `AppErrorKind::ExternalApi` (with upstream -//! status info) +//! - HTTP status errors are classified by status family: +//! - `429` → `AppErrorKind::RateLimited` +//! - `5xx` → `AppErrorKind::DependencyUnavailable` +//! - `408` → `AppErrorKind::Timeout` +//! - others → `AppErrorKind::ExternalApi` //! - All other cases → `AppErrorKind::ExternalApi` //! -//! The original error string is preserved in the `message` for observability. +//! Structured metadata captures the upstream endpoint, status code and +//! low-level flags (timeout/connect/request). Potentially sensitive data (URL) +//! is marked for hashing/redaction in public payloads. //! //! ## Rationale //! @@ -22,10 +27,10 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use reqwest::Error as ReqwestError; //! -//! fn handle_http_error(e: ReqwestError) -> AppError { +//! fn handle_http_error(e: ReqwestError) -> Error { //! e.into() //! } //! @@ -40,12 +45,13 @@ //! ``` #[cfg(feature = "reqwest")] -use reqwest::Error as ReqwestError; +use reqwest::{Error as ReqwestError, StatusCode}; +use crate::AppErrorKind; #[cfg(feature = "reqwest")] -use crate::AppError; +use crate::app_error::{Context, Error, FieldRedaction, field}; -/// Map a [`reqwest::Error`] into an [`AppError`] according to its category. +/// Map a [`reqwest::Error`] into an [`Error`] according to its category. /// /// - Timeout → `Timeout` /// - Connect or request build error → `Network` @@ -53,20 +59,77 @@ use crate::AppError; /// - Fallback for other cases → `ExternalApi` #[cfg(feature = "reqwest")] #[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))] -impl From for AppError { +impl From for Error { fn from(err: ReqwestError) -> Self { - if err.is_timeout() { - AppError::timeout(format!("Request timeout: {err}")) - } else if err.is_connect() || err.is_request() { - AppError::network(format!("Network error: {err}")) - } else if err.is_status() { - AppError::external_api(format!("Upstream status error: {err}")) - } else { - AppError::external_api(format!("Upstream error: {err}")) + let (context, retry_after) = classify_reqwest_error(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); } + error } } +#[cfg(feature = "reqwest")] +fn classify_reqwest_error(err: &ReqwestError) -> (Context, Option) { + let mut context = Context::new(AppErrorKind::ExternalApi) + .with(field::bool("reqwest.is_timeout", err.is_timeout())) + .with(field::bool("reqwest.is_connect", err.is_connect())) + .with(field::bool("reqwest.is_request", err.is_request())) + .with(field::bool("reqwest.is_status", err.is_status())) + .with(field::bool("reqwest.is_body", err.is_body())) + .with(field::bool("reqwest.is_decode", err.is_decode())) + .with(field::bool("reqwest.is_redirect", err.is_redirect())); + + let mut retry_after = None; + + if err.is_timeout() { + context = context.category(AppErrorKind::Timeout); + } else if err.is_connect() || err.is_request() { + context = context.category(AppErrorKind::Network); + } + + if let Some(status) = err.status() { + let status_code = u16::from(status); + context = context.with(field::u64("http.status", u64::from(status_code))); + if let Some(reason) = status.canonical_reason() { + context = context.with(field::str("http.status_reason", reason)); + } + + context = match status { + StatusCode::TOO_MANY_REQUESTS => { + retry_after = Some(1); + context.category(AppErrorKind::RateLimited) + } + StatusCode::REQUEST_TIMEOUT => context.category(AppErrorKind::Timeout), + s if s.is_server_error() => context.category(AppErrorKind::DependencyUnavailable), + _ => context + }; + } + + if let Some(url) = err.url() { + context = context + .with(field::str("http.url", url.as_str())) + .redact_field("http.url", FieldRedaction::Hash); + + if let Some(host) = url.host_str() { + context = context.with(field::str("http.host", host.to_owned())); + } + + let path = url.path(); + if !path.is_empty() { + context = context.with(field::str("http.path", path.to_owned())); + } + + let scheme = url.scheme(); + if !scheme.is_empty() { + context = context.with(field::str("http.scheme", scheme.to_owned())); + } + } + + (context, retry_after) +} + #[cfg(all(test, feature = "reqwest", feature = "tokio"))] mod tests { use std::time::Duration; @@ -75,9 +138,10 @@ mod tests { use tokio::{net::TcpListener, time::sleep}; use super::*; + use crate::{AppCode, AppErrorKind, FieldRedaction, FieldValue}; #[tokio::test] - async fn timeout_message_includes_original_error() { + async fn timeout_sets_category_and_metadata() { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind listener"); @@ -99,15 +163,48 @@ mod tests { .await .expect_err("expected timeout"); - assert!(err.is_timeout()); + let app_err: Error = err.into(); + assert_eq!(app_err.kind, AppErrorKind::Timeout); - let err_str = err.to_string(); - let app_err: AppError = err.into(); - let msg = app_err.message.expect("app error message"); - assert!( - msg.contains(err_str.as_str()), - "{msg} does not contain {err_str}" + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("reqwest.is_timeout"), + Some(&FieldValue::Bool(true)) ); + assert_eq!(metadata.redaction("http.url"), Some(FieldRedaction::Hash)); + + server.abort(); + } + + #[tokio::test] + async fn status_error_maps_retry_and_rate_limit() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); + let addr = listener.local_addr().expect("addr"); + + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept"); + let mut buf = [0_u8; 1024]; + let _ = socket.read(&mut buf).await; + let response = b"HTTP/1.1 429 Too Many Requests\r\ncontent-length: 0\r\n\r\n"; + let _ = socket.write_all(response).await; + }); + + let client = Client::new(); + let response = client + .get(format!("http://{addr}")) + .send() + .await + .expect("send"); + let err = response.error_for_status().expect_err("status error"); + + let app_err: Error = err.into(); + assert_eq!(app_err.kind, AppErrorKind::RateLimited); + assert_eq!(app_err.code, AppCode::RateLimited); + assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(1)); + let metadata = app_err.metadata(); + assert_eq!(metadata.get("http.status"), Some(&FieldValue::U64(429))); server.abort(); } diff --git a/src/convert/serde_json.rs b/src/convert/serde_json.rs index f0cb308..f307217 100644 --- a/src/convert/serde_json.rs +++ b/src/convert/serde_json.rs @@ -1,4 +1,4 @@ -//! Conversion from [`serde_json::Error`] into [`AppError`]. +//! Conversion from [`serde_json::Error`] into [`Error`]. //! //! Enabled with the `serde_json` feature flag. //! @@ -18,10 +18,10 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use serde_json::Error as SjError; //! -//! fn handle_json_error(e: SjError) -> AppError { +//! fn handle_json_error(e: SjError) -> Error { //! e.into() //! } //! @@ -34,7 +34,10 @@ use serde_json::{Error as SjError, error::Category}; #[cfg(feature = "serde_json")] -use crate::AppError; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; /// Map a [`serde_json::Error`] into an [`AppError`]. /// @@ -43,15 +46,33 @@ use crate::AppError; /// logs and optional JSON payloads. #[cfg(feature = "serde_json")] #[cfg_attr(docsrs, doc(cfg(feature = "serde_json")))] -impl From for AppError { +impl From for Error { fn from(err: SjError) -> Self { - match err.classify() { - Category::Io => AppError::serialization(err.to_string()), - Category::Syntax | Category::Data | Category::Eof => { - AppError::deserialization(err.to_string()) - } + build_context(&err).into_error(err) + } +} + +#[cfg(feature = "serde_json")] +fn build_context(err: &SjError) -> Context { + let category = err.classify(); + let mut context = match category { + Category::Io => Context::new(AppErrorKind::Serialization), + Category::Syntax | Category::Data | Category::Eof => { + Context::new(AppErrorKind::Deserialization) } } + .with(field::str("serde_json.category", format!("{:?}", category))); + + let line = err.line(); + if line != 0 { + context = context.with(field::u64("serde_json.line", u64::from(line))); + } + let column = err.column(); + if column != 0 { + context = context.with(field::u64("serde_json.column", u64::from(column))); + } + + context } #[cfg(test)] @@ -61,7 +82,7 @@ mod tests { use serde_json::json; use super::*; - use crate::kind::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[test] fn io_maps_to_serialization() { @@ -78,14 +99,23 @@ mod tests { } let err = serde_json::to_writer(FailWriter, &json!({"k": "v"})).unwrap_err(); - let app: AppError = err.into(); + let app: Error = err.into(); assert!(matches!(app.kind, AppErrorKind::Serialization)); + assert_eq!( + app.metadata().get("serde_json.category"), + Some(&FieldValue::Str("Io".into())) + ); } #[test] fn syntax_maps_to_deserialization() { let err = serde_json::from_str::("not-json").unwrap_err(); - let app: AppError = err.into(); + let app: Error = err.into(); assert!(matches!(app.kind, AppErrorKind::Deserialization)); + let metadata = app.metadata(); + assert_eq!( + metadata.get("serde_json.category"), + Some(&FieldValue::Str("Syntax".into())) + ); } } diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index 89fd254..1fbe506 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -1,4 +1,4 @@ -//! Conversions from `sqlx` errors into `AppError`. +//! Conversions from `sqlx` errors into [`Error`]. //! //! Feature flags: //! - `sqlx` → maps `sqlx_core::error::Error` @@ -7,21 +7,25 @@ //! ## Mappings //! //! - `sqlx_core::error::Error::RowNotFound` → `AppErrorKind::NotFound` -//! - any other `sqlx_core::error::Error` → `AppErrorKind::Database` -//! - `sqlx::migrate::MigrateError` → `AppErrorKind::Database` +//! - Database constraint errors capture SQLSTATE/constraint metadata and map to +//! `Conflict`/`Validation` +//! - Transient SQLSTATEs (e.g. `40001`, `55P03`) attach retry hints +//! - `sqlx::migrate::MigrateError` → `AppErrorKind::Database` with migration +//! phase metadata //! -//! The original error message is preserved in `AppError.message` for -//! observability. SQL driver–specific details are **not** mapped to separate -//! kinds to keep the taxonomy stable. +//! Structured metadata includes SQLSTATE codes, constraint names and migration +//! phases to aid observability while keeping secrets out of public payloads. +//! Known SQLSTATE codes override [`AppCode`] (`UNIQUE_VIOLATION` → +//! `USER_ALREADY_EXISTS`). //! //! ## Example //! //! ```rust,ignore //! // Requires: features = ["sqlx"] -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use sqlx_core::error::Error as SqlxError; //! -//! fn handle_db_error(e: SqlxError) -> AppError { +//! fn handle_db_error(e: SqlxError) -> Error { //! e.into() //! } //! @@ -33,58 +37,365 @@ #[cfg(feature = "sqlx-migrate")] use sqlx::migrate::MigrateError; #[cfg(feature = "sqlx")] -use sqlx_core::error::Error as SqlxError; +use sqlx_core::error::{DatabaseError, Error as SqlxError, ErrorKind as SqlxErrorKind}; #[cfg(any(feature = "sqlx", feature = "sqlx-migrate"))] -use crate::AppError; +use crate::{ + AppCode, AppErrorKind, + app_error::{Context, Error, field} +}; -/// Map a `sqlx_core::error::Error` into an `AppError`. +#[cfg(feature = "sqlx")] +const SQLSTATE_CODE_OVERRIDES: &[(&str, AppCode)] = &[ + ("23505", AppCode::UserAlreadyExists), + ("23503", AppCode::Conflict), + ("23502", AppCode::Validation), + ("23514", AppCode::Validation) +]; + +#[cfg(feature = "sqlx")] +const SQLSTATE_RETRY_HINTS: &[(&str, u64)] = &[("40001", 1), ("55P03", 1)]; + +/// Map a `sqlx_core::error::Error` into [`Error`]. /// /// - `RowNotFound` → `AppErrorKind::NotFound` -/// - all other cases → `AppErrorKind::Database` -/// -/// The database error message is preserved for debugging and log correlation. +/// - database constraint errors attach SQLSTATE and constraint metadata +/// - concurrency SQLSTATEs attach retry hints #[cfg(feature = "sqlx")] #[cfg_attr(docsrs, doc(cfg(feature = "sqlx")))] -impl From for AppError { +impl From for Error { fn from(err: SqlxError) -> Self { - match err { - SqlxError::RowNotFound => AppError::not_found("Record not found"), - other => AppError::database_with_message(other.to_string()) + let (context, retry_after) = build_sqlx_context(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); } + error } } -/// Map a `sqlx::migrate::MigrateError` into an `AppError`. +/// Map a `sqlx::migrate::MigrateError` into [`Error`]. /// -/// All migration errors are considered `AppErrorKind::Database`. -/// The error string is preserved in `message`. +/// Errors are categorised as `Database` with metadata describing the failing +/// migration phase. #[cfg(feature = "sqlx-migrate")] #[cfg_attr(docsrs, doc(cfg(feature = "sqlx-migrate")))] -impl From for AppError { +impl From for Error { fn from(err: MigrateError) -> Self { - AppError::database_with_message(err.to_string()) + build_migrate_context(&err).into_error(err) + } +} + +#[cfg(feature = "sqlx")] +fn build_sqlx_context(err: &SqlxError) -> (Context, Option) { + match err { + SqlxError::RowNotFound => ( + Context::new(AppErrorKind::NotFound).with(field::str("db.reason", "row_not_found")), + None + ), + SqlxError::Database(db_err) => classify_database_error(db_err.as_ref()), + SqlxError::Io(io_err) => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "io_error")) + .with(field::str("io.kind", format!("{:?}", io_err.kind()))), + None + ), + SqlxError::PoolTimedOut => ( + Context::new(AppErrorKind::Timeout).with(field::str("db.reason", "pool_timeout")), + Some(1) + ), + SqlxError::PoolClosed => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "pool_closed")), + None + ), + SqlxError::WorkerCrashed => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "worker_crashed")), + Some(1) + ), + SqlxError::Configuration(source) => ( + Context::new(AppErrorKind::Config) + .with(field::str("db.reason", "configuration")) + .with(field::str("db.detail", source.to_string())), + None + ), + SqlxError::InvalidArgument(message) => ( + Context::new(AppErrorKind::BadRequest) + .with(field::str("db.reason", "invalid_argument")) + .with(field::str("db.argument", message.clone())), + None + ), + SqlxError::ColumnDecode { + index, .. + } => ( + Context::new(AppErrorKind::Deserialization) + .with(field::str("db.reason", "column_decode")) + .with(field::str("db.column", index.clone())), + None + ), + SqlxError::ColumnNotFound(name) => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "column_not_found")) + .with(field::str("db.column", name.clone())), + None + ), + SqlxError::ColumnIndexOutOfBounds { + index, + len + } => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "column_index_out_of_bounds")) + .with(field::u64("db.index", *index as u64)) + .with(field::u64("db.len", *len as u64)), + None + ), + SqlxError::TypeNotFound { + type_name + } => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "type_not_found")) + .with(field::str("db.type", type_name.clone())), + None + ), + SqlxError::Encode(_) => ( + Context::new(AppErrorKind::Serialization).with(field::str("db.reason", "encode")), + None + ), + SqlxError::Decode(_) => ( + Context::new(AppErrorKind::Deserialization).with(field::str("db.reason", "decode")), + None + ), + SqlxError::Protocol(detail) => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "protocol")) + .with(field::str("db.detail", detail.clone())), + Some(1) + ), + SqlxError::Tls(_) => ( + Context::new(AppErrorKind::Network).with(field::str("db.reason", "tls")), + Some(1) + ), + SqlxError::AnyDriverError(_) => ( + Context::new(AppErrorKind::Database).with(field::str("db.reason", "driver_error")), + None + ), + SqlxError::InvalidSavePointStatement => ( + Context::new(AppErrorKind::Internal) + .with(field::str("db.reason", "invalid_savepoint")), + None + ), + SqlxError::BeginFailed => ( + Context::new(AppErrorKind::DependencyUnavailable) + .with(field::str("db.reason", "begin_failed")), + Some(1) + ), + other => ( + Context::new(AppErrorKind::Database) + .with(field::str("db.reason", "unclassified")) + .with(field::str("db.detail", format!("{:?}", other))), + None + ) + } +} + +#[cfg(feature = "sqlx")] +fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, Option) { + let mut context = Context::new(AppErrorKind::Database) + .with(field::str("db.reason", "database_error")) + .with(field::str("db.message", error.message().to_owned())); + + if let Some(constraint) = error.constraint() { + context = context.with(field::str("db.constraint", constraint.to_owned())); + } + if let Some(table) = error.table() { + context = context.with(field::str("db.table", table.to_owned())); + } + + let mut retry_after = None; + let mut category = AppErrorKind::Database; + let mut code_override = None; + + let code = error.code().map(|code| code.into_owned()); + if let Some(ref sqlstate) = code { + context = context.with(field::str("db.code", sqlstate.clone())); + if let Some((_, secs)) = SQLSTATE_RETRY_HINTS + .iter() + .find(|(state, _)| *state == sqlstate.as_str()) + { + retry_after = Some(*secs); + } + if let Some((_, app_code)) = SQLSTATE_CODE_OVERRIDES + .iter() + .find(|(state, _)| *state == sqlstate.as_str()) + { + code_override = Some(*app_code); + } + } + + category = match error.kind() { + SqlxErrorKind::UniqueViolation => AppErrorKind::Conflict, + SqlxErrorKind::ForeignKeyViolation => AppErrorKind::Conflict, + SqlxErrorKind::NotNullViolation | SqlxErrorKind::CheckViolation => { + AppErrorKind::Validation + } + SqlxErrorKind::Other => AppErrorKind::Database + }; + + context = context.category(category); + if let Some(code) = code_override { + context = context.code(code); + } + + (context, retry_after) +} + +#[cfg(feature = "sqlx-migrate")] +fn build_migrate_context(err: &MigrateError) -> Context { + match err { + MigrateError::Execute(inner) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "execute")) + .with(field::str("migration.source", inner.to_string())), + MigrateError::ExecuteMigration(inner, version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "execute_migration")) + .with(field::i64("migration.version", *version)) + .with(field::str("migration.source", inner.to_string())), + MigrateError::Source(source) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "source")) + .with(field::str("migration.source", source.to_string())), + MigrateError::VersionMissing(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_missing")) + .with(field::i64("migration.version", *version)), + MigrateError::VersionMismatch(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_mismatch")) + .with(field::i64("migration.version", *version)), + MigrateError::VersionNotPresent(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_not_present")) + .with(field::i64("migration.version", *version)), + MigrateError::VersionTooOld(version, latest) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_too_old")) + .with(field::i64("migration.version", *version)) + .with(field::i64("migration.latest", *latest)), + MigrateError::VersionTooNew(version, latest) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "version_too_new")) + .with(field::i64("migration.version", *version)) + .with(field::i64("migration.latest", *latest)), + MigrateError::ForceNotSupported => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "force_not_supported")), + MigrateError::InvalidMixReversibleAndSimple => { + Context::new(AppErrorKind::Database).with(field::str("migration.phase", "invalid_mix")) + } + MigrateError::Dirty(version) => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "dirty")) + .with(field::i64("migration.version", *version)) } } #[cfg(all(test, feature = "sqlx"))] mod tests_sqlx { - use std::io; + use std::fmt; use super::*; - use crate::AppErrorKind; + use crate::{AppCode, AppErrorKind, FieldValue}; #[test] fn row_not_found_maps_to_not_found() { - let err: AppError = SqlxError::RowNotFound.into(); + let err: Error = SqlxError::RowNotFound.into(); assert!(matches!(err.kind, AppErrorKind::NotFound)); } #[test] - fn other_error_maps_to_database() { - // Prefer modern constructor; avoids clippy::io-other-error - let io_err = io::Error::other("boom"); - let err: AppError = SqlxError::Io(io_err).into(); - assert!(matches!(err.kind, AppErrorKind::Database)); + fn io_error_maps_to_dependency_unavailable() { + let io_err = std::io::Error::other("boom"); + let err: Error = SqlxError::Io(io_err).into(); + assert!(matches!(err.kind, AppErrorKind::DependencyUnavailable)); + let metadata = err.metadata(); + assert_eq!( + metadata.get("db.reason"), + Some(&FieldValue::Str("io_error".into())) + ); + } + + #[test] + fn unique_violation_sets_code_override() { + let db_err = DummyDbError { + message: "duplicate key".into(), + code: Some("23505".into()), + constraint: Some("users_email_key".into()), + table: Some("users".into()), + kind: SqlxErrorKind::UniqueViolation + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.kind, AppErrorKind::Conflict); + assert_eq!(err.code, AppCode::UserAlreadyExists); + let metadata = err.metadata(); + assert_eq!( + metadata.get("db.constraint"), + Some(&FieldValue::Str("users_email_key".into())) + ); + } + + #[test] + fn serialization_failure_carries_retry_hint() { + let db_err = DummyDbError { + message: "serialization failure".into(), + code: Some("40001".into()), + constraint: None, + table: None, + kind: SqlxErrorKind::Other + }; + let err: Error = SqlxError::Database(Box::new(db_err)).into(); + assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + } + + #[derive(Debug)] + struct DummyDbError { + message: String, + code: Option, + constraint: Option, + table: Option, + kind: SqlxErrorKind + } + + impl fmt::Display for DummyDbError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } + } + + impl std::error::Error for DummyDbError {} + + impl DatabaseError for DummyDbError { + fn message(&self) -> &str { + &self.message + } + + fn code(&self) -> Option> { + self.code.as_deref().map(std::borrow::Cow::Borrowed) + } + + fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) { + self + } + + fn into_error(self: Box) -> Box { + self + } + + fn constraint(&self) -> Option<&str> { + self.constraint.as_deref() + } + + fn table(&self) -> Option<&str> { + self.table.as_deref() + } + + fn kind(&self) -> SqlxErrorKind { + self.kind + } } } diff --git a/src/convert/telegram_webapp_sdk.rs b/src/convert/telegram_webapp_sdk.rs index 252a089..063eeba 100644 --- a/src/convert/telegram_webapp_sdk.rs +++ b/src/convert/telegram_webapp_sdk.rs @@ -1,6 +1,6 @@ //! Conversion from //! [`telegram_webapp_sdk::utils::validate_init_data::ValidationError`] into -//! [`AppError`]. +//! [`Error`]. //! //! Enabled with the `telegram-webapp-sdk` feature flag. //! @@ -22,10 +22,10 @@ //! # #[cfg(feature = "telegram-webapp-sdk")] //! # { //! '''rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use telegram_webapp_sdk::utils::validate_init_data::ValidationError; //! -//! fn convert(err: ValidationError) -> AppError { +//! fn convert(err: ValidationError) -> Error { //! err.into() //! } //! @@ -39,14 +39,37 @@ use telegram_webapp_sdk::utils::validate_init_data::ValidationError; #[cfg(feature = "telegram-webapp-sdk")] -use crate::AppError; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; /// Map [`ValidationError`] into an [`AppError`] with kind `TelegramAuth`. #[cfg(feature = "telegram-webapp-sdk")] #[cfg_attr(docsrs, doc(cfg(feature = "telegram-webapp-sdk")))] -impl From for AppError { +impl From for Error { fn from(err: ValidationError) -> Self { - AppError::telegram_auth(err.to_string()) + build_context(&err).into_error(err) + } +} + +#[cfg(feature = "telegram-webapp-sdk")] +fn build_context(error: &ValidationError) -> Context { + match error { + ValidationError::MissingField(field) => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "missing_field")) + .with(field::str("telegram_webapp.field", (*field).to_owned())), + ValidationError::InvalidEncoding => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "invalid_encoding")), + ValidationError::InvalidSignatureEncoding => Context::new(AppErrorKind::TelegramAuth) + .with(field::str( + "telegram_webapp.reason", + "invalid_signature_encoding" + )), + ValidationError::SignatureMismatch => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "signature_mismatch")), + ValidationError::InvalidPublicKey => Context::new(AppErrorKind::TelegramAuth) + .with(field::str("telegram_webapp.reason", "invalid_public_key")) } } @@ -55,7 +78,7 @@ mod tests { use telegram_webapp_sdk::utils::validate_init_data::ValidationError; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[test] fn all_variants_map_to_telegram_auth_and_preserve_message() { @@ -68,16 +91,19 @@ mod tests { ]; for case in cases { - let msg = case.to_string(); - let app: AppError = case.into(); + let app: Error = case.into(); assert!(matches!(app.kind, AppErrorKind::TelegramAuth)); - assert_eq!(app.message.as_deref(), Some(msg.as_str())); + assert!(app.metadata().get("telegram_webapp.reason").is_some()); } } #[test] fn validation_error_maps_to_telegram_auth() { - let err: AppError = ValidationError::SignatureMismatch.into(); + let err: Error = ValidationError::SignatureMismatch.into(); assert!(matches!(err.kind, AppErrorKind::TelegramAuth)); + assert_eq!( + err.metadata().get("telegram_webapp.reason"), + Some(&FieldValue::Str("signature_mismatch".into())) + ); } } diff --git a/src/convert/teloxide.rs b/src/convert/teloxide.rs index 6e30032..9686f55 100644 --- a/src/convert/teloxide.rs +++ b/src/convert/teloxide.rs @@ -1,4 +1,4 @@ -//! Conversion from [`teloxide_core::RequestError`] into [`AppError`]. +//! Conversion from [`teloxide_core::RequestError`] into [`Error`]. //! //! Enabled with the `teloxide` feature flag. //! @@ -16,11 +16,11 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use teloxide_core::{errors::ApiError, RequestError, types::Seconds}; //! use std::{io, sync::Arc}; //! -//! fn map(err: RequestError) -> AppError { err.into() } +//! fn map(err: RequestError) -> Error { err.into() } //! //! let err = RequestError::RetryAfter(Seconds::from_seconds(1)); //! let app_err = map(err); @@ -30,26 +30,70 @@ use teloxide_core::RequestError; #[cfg(feature = "teloxide")] -use crate::AppError; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; #[cfg(feature = "teloxide")] #[cfg_attr(docsrs, doc(cfg(feature = "teloxide")))] -impl From for AppError { +impl From for Error { fn from(err: RequestError) -> Self { - match err { - RequestError::Api(api) => AppError::external_api(format!("Telegram API error: {api}")), - RequestError::MigrateToChatId(id) => { - AppError::external_api(format!("Group migrated to {id}")) - } - RequestError::RetryAfter(secs) => { - AppError::rate_limited(format!("Retry after {secs}")) - } - RequestError::Network(e) => AppError::network(format!("Network error: {e}")), - RequestError::InvalidJson { - source, .. - } => AppError::deserialization(format!("Invalid Telegram JSON: {source}")), - RequestError::Io(e) => AppError::internal(format!("I/O error: {e}")) + let (context, retry_after) = build_teloxide_context(&err); + let mut error = context.into_error(err); + if let Some(secs) = retry_after { + error = error.with_retry_after_secs(secs); } + error + } +} + +#[cfg(feature = "teloxide")] +fn build_teloxide_context(err: &RequestError) -> (Context, Option) { + match err { + RequestError::Api(api) => ( + Context::new(AppErrorKind::ExternalApi) + .with(field::str("telegram.reason", "api")) + .with(field::str("telegram.api_error", api.to_string())), + None + ), + RequestError::MigrateToChatId(id) => ( + Context::new(AppErrorKind::ExternalApi) + .with(field::str("telegram.reason", "migrate_to_chat")) + .with(field::i64("telegram.chat_id", id.0)), + None + ), + RequestError::RetryAfter(secs) => { + let seconds = u64::from(secs.seconds()); + ( + Context::new(AppErrorKind::RateLimited) + .with(field::str("telegram.reason", "retry_after")) + .with(field::u64("telegram.retry_after_secs", seconds)), + Some(seconds) + ) + } + RequestError::Network(e) => ( + Context::new(AppErrorKind::Network) + .with(field::str("telegram.reason", "network")) + .with(field::str("telegram.detail", e.to_string())), + None + ), + RequestError::InvalidJson { + source, + raw + } => ( + Context::new(AppErrorKind::Deserialization) + .with(field::str("telegram.reason", "invalid_json")) + .with(field::str("telegram.detail", source.to_string())) + .with(field::u64("telegram.payload_len", raw.len() as u64)), + None + ), + RequestError::Io(e) => ( + Context::new(AppErrorKind::Internal) + .with(field::str("telegram.reason", "io")) + .with(field::str("io.kind", format!("{:?}", e.kind()))), + None + ) } } @@ -60,27 +104,36 @@ mod tests { use teloxide_core::{errors::ApiError, types::Seconds}; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[test] fn api_maps_to_external_api() { let err = RequestError::Api(ApiError::BotBlocked); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::ExternalApi)); + assert_eq!( + app_err.metadata().get("telegram.api_error"), + Some(&FieldValue::Str(ApiError::BotBlocked.to_string().into())) + ); } #[test] fn retry_after_maps_to_rate_limited() { let err = RequestError::RetryAfter(Seconds::from_seconds(5)); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::RateLimited)); + assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(5)); } #[test] fn io_maps_to_internal() { let io_err = Arc::new(io::Error::other("disk")); let err = RequestError::Io(io_err); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::Internal)); + assert_eq!( + app_err.metadata().get("telegram.reason"), + Some(&FieldValue::Str("io".into())) + ); } } diff --git a/src/convert/tokio.rs b/src/convert/tokio.rs index 8592095..a2c79e1 100644 --- a/src/convert/tokio.rs +++ b/src/convert/tokio.rs @@ -1,4 +1,4 @@ -//! Conversion from [`tokio::time::error::Elapsed`] into [`AppError`]. +//! Conversion from [`tokio::time::error::Elapsed`] into [`Error`]. //! //! Enabled with the `tokio` feature flag. //! @@ -17,7 +17,7 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::{AppErrorKind, Error}; //! use tokio::time::{sleep, timeout, Duration}; //! //! #[tokio::main] @@ -26,7 +26,7 @@ //! let res = timeout(Duration::from_millis(10), fut).await; //! //! let err = res.unwrap_err(); // tokio::time::error::Elapsed -//! let app_err: AppError = err.into(); +//! let app_err: Error = err.into(); //! //! assert!(matches!(app_err.kind, AppErrorKind::Timeout)); //! } @@ -36,7 +36,10 @@ use tokio::time::error::Elapsed; #[cfg(feature = "tokio")] -use crate::AppError; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; /// Map a [`tokio::time::error::Elapsed`] into an [`AppError`] with kind /// `Timeout`. @@ -44,9 +47,11 @@ use crate::AppError; /// Message is fixed to avoid leaking timing specifics to the client. #[cfg(feature = "tokio")] #[cfg_attr(docsrs, doc(cfg(feature = "tokio")))] -impl From for AppError { - fn from(_: Elapsed) -> Self { - AppError::timeout("Operation timed out") +impl From for Error { + fn from(err: Elapsed) -> Self { + Context::new(AppErrorKind::Timeout) + .with(field::str("timeout.source", "tokio::time::timeout")) + .into_error(err) } } @@ -55,7 +60,7 @@ mod tests { use tokio::time::{Duration, sleep, timeout}; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[tokio::test] async fn elapsed_maps_to_timeout() { @@ -63,7 +68,11 @@ mod tests { let err = timeout(Duration::from_millis(1), fut) .await .expect_err("expect timeout"); - let app_err: AppError = err.into(); + let app_err: Error = err.into(); assert!(matches!(app_err.kind, AppErrorKind::Timeout)); + assert_eq!( + app_err.metadata().get("timeout.source"), + Some(&FieldValue::Str("tokio::time::timeout".into())) + ); } } diff --git a/src/convert/validator.rs b/src/convert/validator.rs index e5f7f47..e0fb4e2 100644 --- a/src/convert/validator.rs +++ b/src/convert/validator.rs @@ -1,4 +1,4 @@ -//! Conversion from [`validator::ValidationErrors`] into [`AppError`]. +//! Conversion from [`validator::ValidationErrors`] into [`Error`]. //! //! Enabled with the `validator` feature flag. //! @@ -19,7 +19,7 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind, AppResult}; +//! use masterror::{AppErrorKind, AppResult, Error}; //! use validator::{Validate, ValidationError}; //! //! #[derive(Validate)] @@ -39,10 +39,13 @@ //! ``` #[cfg(feature = "validator")] -use validator::ValidationErrors; +use validator::{ValidationErrors, ValidationErrorsKind}; #[cfg(feature = "validator")] -use crate::AppError; +use crate::{ + AppErrorKind, + app_error::{Context, Error, field} +}; /// Map [`validator::ValidationErrors`] into an [`AppError`] with kind /// `Validation`. @@ -51,18 +54,74 @@ use crate::AppError; /// Consider extending `AppError` if you want to expose structured details. #[cfg(feature = "validator")] #[cfg_attr(docsrs, doc(cfg(feature = "validator")))] -impl From for AppError { +impl From for Error { fn from(err: ValidationErrors) -> Self { - AppError::validation(err.to_string()) + build_context(&err).into_error(err) } } +#[cfg(feature = "validator")] +fn build_context(errors: &ValidationErrors) -> Context { + let mut context = Context::new(AppErrorKind::Validation); + + let field_errors = errors.field_errors(); + if !field_errors.is_empty() { + context = context.with(field::u64( + "validation.field_count", + field_errors.len() as u64 + )); + + let total: u64 = field_errors.values().map(|errs| errs.len() as u64).sum(); + if total > 0 { + context = context.with(field::u64("validation.error_count", total)); + } + + let mut names = String::new(); + for (idx, name) in field_errors.keys().take(3).enumerate() { + if idx > 0 { + names.push(','); + } + names.push_str(name.as_ref()); + } + if !names.is_empty() { + context = context.with(field::str("validation.fields", names)); + } + + let mut codes: Vec = Vec::new(); + for errors in field_errors.values() { + for error in *errors { + let code = error.code.as_ref(); + if codes.len() >= 3 { + break; + } + if codes.iter().any(|existing| existing == code) { + continue; + } + codes.push(code.to_string()); + } + } + if !codes.is_empty() { + context = context.with(field::str("validation.codes", codes.join(","))); + } + } + + let has_nested = errors + .errors() + .values() + .any(|kind| !matches!(kind, ValidationErrorsKind::Field(_))); + if has_nested { + context = context.with(field::bool("validation.has_nested", true)); + } + + context +} + #[cfg(all(test, feature = "validator"))] mod tests { use validator::Validate; use super::*; - use crate::AppErrorKind; + use crate::{AppErrorKind, FieldValue}; #[derive(Validate)] struct Payload { @@ -75,7 +134,12 @@ mod tests { let bad = Payload { val: 0 }; - let err: AppError = bad.validate().unwrap_err().into(); + let err: Error = bad.validate().unwrap_err().into(); assert!(matches!(err.kind, AppErrorKind::Validation)); + let metadata = err.metadata(); + assert_eq!( + metadata.get("validation.field_count"), + Some(&FieldValue::U64(1)) + ); } } diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 440dd2e..bdb2a0b 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -553,6 +553,18 @@ pub const CODE_MAPPINGS: &[(AppCode, CodeMapping)] = &[ kind: AppErrorKind::Conflict } ), + ( + AppCode::UserAlreadyExists, + CodeMapping { + http_status: 409, + grpc: GrpcCode { + name: "ALREADY_EXISTS", + value: 6 + }, + problem_type: "https://errors.masterror.rs/user-already-exists", + kind: AppErrorKind::Conflict + } + ), ( AppCode::Unauthorized, CodeMapping { diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index bbc297c..d000de1 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("without")] -9 | | Without, - | |___________^ +8 | #[error("without")] + | ^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 4f02301..70ccade 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index 021c135..c615e98 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^^^^^^^^^^^^^^^^^^^^^ + | ^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b8f363..5b08225 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index be76742..d416399 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,5 +8,4 @@ error: multiple unused formatting arguments | argument never used | argument never used | - = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index b8bf229..d7acdb1 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index a6a40c2..5869420 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 | 4 | #[error("{value:y}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index 3d332c7..bbe04b4 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 | 4 | #[error("{value:B}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 0bd10fa..2c30e71 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 | 4 | #[error("{value:P}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index 113a10d..c3fb86b 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index 9ada290..b331baa 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index fd151cc..b2658a1 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 5a25e12..83d517f 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("missing")] -9 | | Missing - | |___________^ +8 | #[error("missing")] + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index bdadf45..f929951 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 037fac8..34abc91 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index 1822edf..d579838 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default From 1f162ca2fd5b40366535758726652da4e819963f Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 07:57:12 +0700 Subject: [PATCH 13/25] feat: store shared sources and lazy backtraces --- CHANGELOG.md | 13 ++++++ Cargo.lock | 4 +- Cargo.toml | 6 +-- README.md | 14 +++--- masterror-derive/Cargo.toml | 2 +- src/app_error.rs | 2 + src/app_error/core.rs | 90 +++++++++++++------------------------ src/result_ext.rs | 69 ++++++++++++++++++++++++++++ tests/masterror_macro.rs | 4 +- 9 files changed, 131 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78e6ad4..d329bab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.19.0] - 2025-09-29 + +### Changed +- Reworked `AppError` storage to keep sources behind shared `Arc` handles and + lazily capture optional `Backtrace` snapshots without allocating when + `RUST_BACKTRACE` disables them. +- Updated the `masterror::Error` derive and `ResultExt` conversions to forward + sources/backtraces automatically under the new storage layout. + +### Tests +- Added regression coverage for chained error sources and conditional + backtrace capture driven by the `RUST_BACKTRACE` environment variable. + ## [0.18.0] - 2025-09-28 ### Added diff --git a/Cargo.lock b/Cargo.lock index 2181e27..cd5621a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.18.0" +version = "0.19.0" dependencies = [ "actix-web", "axum 0.8.4", @@ -1764,7 +1764,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.8.0" +version = "0.9.0" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 55b022a..4bef584 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.18.0" +version = "0.19.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -75,11 +75,11 @@ tonic = ["dep:tonic"] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.8.0" } +masterror-derive = { version = "0.9.0" } masterror-template = { version = "0.3.6" } [dependencies] -masterror-derive = { version = "0.8" } +masterror-derive = { version = "0.9" } masterror-template = { workspace = true } tracing = { version = "0.1", optional = true } log = { version = "0.4", optional = true } diff --git a/README.md b/README.md index 472f547..7d8e8f7 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.18.0", default-features = false } +masterror = { version = "0.19.0", default-features = false } # or with features: -# masterror = { version = "0.18.0", features = [ +# masterror = { version = "0.19.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.18.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.18.0", default-features = false } +masterror = { version = "0.19.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.18.0", features = [ +# masterror = { version = "0.19.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.18.0", default-features = false } +masterror = { version = "0.19.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.18.0", features = [ +masterror = { version = "0.19.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.18.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.18.0", features = [ +masterror = { version = "0.19.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index 76edd2a..b757e0b 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.8.0" +version = "0.9.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/src/app_error.rs b/src/app_error.rs index aaf3fe6..a154446 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -67,6 +67,8 @@ mod context; mod core; mod metadata; +#[cfg(all(test, feature = "backtrace"))] +pub(crate) use core::reset_backtrace_preference; pub use core::{AppError, AppResult, Error, MessageEditPolicy}; pub use context::Context; diff --git a/src/app_error/core.rs b/src/app_error/core.rs index e9384ce..38cff1b 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -34,57 +34,6 @@ pub enum MessageEditPolicy { Redact } -#[cfg(feature = "backtrace")] -#[derive(Debug)] -struct BacktraceSlot { - cell: OnceLock> -} - -#[cfg(feature = "backtrace")] -impl BacktraceSlot { - const fn new() -> Self { - Self { - cell: OnceLock::new() - } - } - - fn with(backtrace: Backtrace) -> Self { - let slot = Self::new(); - let _ = slot.cell.set(Some(backtrace)); - slot - } - - fn set(&mut self, backtrace: Backtrace) { - *self = Self::with(backtrace); - } - - fn capture_if_absent(&self) -> Option<&Backtrace> { - self.cell.get_or_init(capture_backtrace_snapshot).as_ref() - } -} - -#[cfg(feature = "backtrace")] -impl Default for BacktraceSlot { - fn default() -> Self { - Self::new() - } -} - -#[cfg(not(feature = "backtrace"))] -#[derive(Debug, Default)] -struct BacktraceSlot { - _marker: () -} - -#[cfg(not(feature = "backtrace"))] -impl BacktraceSlot { - fn set(&mut self, _backtrace: std::backtrace::Backtrace) {} - - fn capture_if_absent(&self) -> Option<&std::backtrace::Backtrace> { - None - } -} - #[derive(Debug)] #[doc(hidden)] pub struct ErrorInner { @@ -102,8 +51,6 @@ pub struct ErrorInner { pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. pub www_authenticate: Option, - source: Option>, - backtrace: BacktraceSlot, telemetry_dirty: AtomicBool } @@ -170,7 +117,12 @@ pub(crate) fn reset_backtrace_preference() { /// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] pub struct Error { - inner: Box + inner: Box, + source: Option>, + #[cfg(feature = "backtrace")] + backtrace: Option, + #[cfg(feature = "backtrace")] + captured_backtrace: OnceLock> } impl Deref for Error { @@ -242,10 +194,13 @@ impl Error { edit_policy: MessageEditPolicy::Preserve, retry: None, www_authenticate: None, - source: None, - backtrace: BacktraceSlot::default(), telemetry_dirty: AtomicBool::new(true) - }) + }), + source: None, + #[cfg(feature = "backtrace")] + backtrace: None, + #[cfg(feature = "backtrace")] + captured_backtrace: OnceLock::new() } } @@ -257,14 +212,31 @@ impl Error { self.telemetry_dirty.swap(false, Ordering::AcqRel) } + #[cfg(feature = "backtrace")] + fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { + if let Some(backtrace) = self.backtrace.as_ref() { + return Some(backtrace); + } + + self.captured_backtrace + .get_or_init(capture_backtrace_snapshot) + .as_ref() + } + + #[cfg(not(feature = "backtrace"))] fn capture_backtrace(&self) -> Option<&std::backtrace::Backtrace> { - self.backtrace.capture_if_absent() + None } + #[cfg(feature = "backtrace")] fn set_backtrace_slot(&mut self, backtrace: std::backtrace::Backtrace) { - self.backtrace.set(backtrace); + self.backtrace = Some(backtrace); + self.captured_backtrace = OnceLock::new(); } + #[cfg(not(feature = "backtrace"))] + fn set_backtrace_slot(&mut self, _backtrace: std::backtrace::Backtrace) {} + pub(crate) fn emit_telemetry(&self) { if self.take_dirty() { #[cfg(feature = "backtrace")] diff --git a/src/result_ext.rs b/src/result_ext.rs index 87f3a9f..60ec0e9 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -51,14 +51,21 @@ mod tests { fmt::{Display, Formatter, Result as FmtResult}, sync::Arc }; + #[cfg(feature = "backtrace")] + use std::{env, sync::Mutex}; use super::ResultExt; + #[cfg(feature = "backtrace")] + use crate::app_error::reset_backtrace_preference; use crate::{ AppCode, AppErrorKind, app_error::{Context, FieldValue, MessageEditPolicy}, field }; + #[cfg(feature = "backtrace")] + static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); + #[derive(Debug)] struct DummyError; @@ -70,6 +77,23 @@ mod tests { impl StdError for DummyError {} + #[derive(Debug)] + struct LayeredError { + inner: DummyError + } + + impl Display for LayeredError { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.inner, f) + } + } + + impl StdError for LayeredError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + Some(&self.inner) + } + } + #[test] fn ctx_preserves_ok() { let res: Result = Ok(5); @@ -106,6 +130,20 @@ mod tests { assert!(metadata.get("caller.column").is_some()); } + #[test] + fn ctx_preserves_error_chain() { + let err = Result::<(), LayeredError>::Err(LayeredError { + inner: DummyError + }) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + + let mut source = StdError::source(&err).expect("layered source"); + assert!(source.is::()); + source = source.source().expect("inner source"); + assert!(source.is::()); + } + #[derive(Debug, Clone)] struct SharedError(Arc); @@ -149,4 +187,35 @@ mod tests { .expect("shared source"); assert!(Arc::ptr_eq(&stored.0, &inner)); } + + #[cfg(feature = "backtrace")] + fn with_backtrace_env(value: Option<&str>, test: impl FnOnce()) { + let _guard = BACKTRACE_ENV_GUARD.lock().expect("env guard"); + reset_backtrace_preference(); + match value { + Some(value) => env::set_var("RUST_BACKTRACE", value), + None => env::remove_var("RUST_BACKTRACE") + } + test(); + env::remove_var("RUST_BACKTRACE"); + reset_backtrace_preference(); + } + + #[cfg(feature = "backtrace")] + #[test] + fn ctx_respects_backtrace_environment() { + with_backtrace_env(Some("0"), || { + let err = Result::<(), DummyError>::Err(DummyError) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + assert!(err.backtrace().is_none()); + }); + + with_backtrace_env(Some("1"), || { + let err = Result::<(), DummyError>::Err(DummyError) + .ctx(|| Context::new(AppErrorKind::Internal)) + .expect_err("err"); + assert!(err.backtrace().is_some()); + }); + } } diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs index 38b162f..4ea50d0 100644 --- a/tests/masterror_macro.rs +++ b/tests/masterror_macro.rs @@ -1,6 +1,6 @@ #![allow(non_shorthand_field_patterns)] -use std::sync::Arc; +use std::{error::Error as StdError, sync::Arc}; use masterror::{ AppCode, AppErrorKind, Error as MasterrorError, FieldRedaction, Masterror, MessageEditPolicy, @@ -96,6 +96,8 @@ fn struct_masterror_conversion_populates_metadata_and_source() { assert_eq!(attempt, Some(3)); assert!(converted.source_ref().is_some()); + let converted_source = StdError::source(&converted).expect("masterror source"); + assert!(converted_source.is::()); assert_eq!( MissingFlag::HTTP_MAPPING, From cf38a28d01e3372505c6a82bb815bd83cb90edce Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:24:26 +0700 Subject: [PATCH 14/25] Add metadata redaction tests and builder updates --- CHANGELOG.md | 13 +++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++++----- src/app_error/context.rs | 31 ++++++++++++++------ src/app_error/tests.rs | 15 ++++++++++ src/response/problem_json.rs | 55 ++++++++++++++++++++++++++++++++++++ src/response/tests.rs | 14 +++++++++ 8 files changed, 129 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d329bab..27be5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.0] - 2025-09-30 + +### Added +- Added a `Context::redact_field_mut` builder method to tweak metadata + redaction policies in place before attaching additional fields. +- Extended response tests to cover JSON serialization of redacted payloads and + hashed metadata along with checks for the opt-in internal formatters. + +### Changed +- Verified `ErrorResponse` and `ProblemJson` serialization respects message and + metadata redaction policies, ensuring secrets stay out of wire payloads while + keeping diagnostic logging intact. + ## [0.19.0] - 2025-09-29 ### Changed diff --git a/Cargo.lock b/Cargo.lock index cd5621a..a3abcc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.19.0" +version = "0.20.0" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 4bef584..5a58911 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.19.0" +version = "0.20.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 7d8e8f7..b333634 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.19.0", default-features = false } +masterror = { version = "0.20.0", default-features = false } # or with features: -# masterror = { version = "0.19.0", features = [ +# masterror = { version = "0.20.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.19.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.19.0", default-features = false } +masterror = { version = "0.20.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.19.0", features = [ +# masterror = { version = "0.20.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.19.0", default-features = false } +masterror = { version = "0.20.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.19.0", features = [ +masterror = { version = "0.20.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.19.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.19.0", features = [ +masterror = { version = "0.20.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 3539194..a3ad334 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -97,14 +97,18 @@ impl Context { /// Override the redaction policy for a metadata field. #[must_use] pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { - self.field_policies - .retain(|(existing, _)| *existing != name); - self.field_policies.push((name, redaction)); - for field in &mut self.fields { - if field.name() == name { - field.set_redaction(redaction); - } - } + self.set_field_policy(name, redaction); + self + } + + /// Override the redaction policy for a metadata field in place. + #[must_use] + pub fn redact_field_mut( + &mut self, + name: &'static str, + redaction: FieldRedaction + ) -> &mut Self { + self.set_field_policy(name, redaction); self } @@ -180,4 +184,15 @@ impl Context { } } } + + fn set_field_policy(&mut self, name: &'static str, redaction: FieldRedaction) { + self.field_policies + .retain(|(existing, _)| *existing != name); + self.field_policies.push((name, redaction)); + for field in &mut self.fields { + if field.name() == name { + field.set_redaction(redaction); + } + } + } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 3d9812c..d1d33d0 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -185,6 +185,21 @@ fn context_redact_field_overrides_policy() { assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Redact)); } +#[test] +fn context_redact_field_mut_applies_policies() { + let mut context = super::Context::new(AppErrorKind::Service); + let _ = context.redact_field_mut("token", FieldRedaction::Hash); + context = context.with(field::str("token", "super-secret")); + + let err = context.into_error(DummyError); + let metadata = err.metadata(); + assert_eq!( + metadata.get("token"), + Some(&FieldValue::Str(Cow::Borrowed("super-secret"))) + ); + assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Hash)); +} + #[test] fn app_error_redact_field_updates_metadata() { let err = AppError::internal("boom") diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index bdb2a0b..067de0a 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -843,6 +843,11 @@ pub fn mapping_for_code(code: AppCode) -> CodeMapping { #[cfg(test)] mod tests { + use std::fmt::Write; + + use serde_json::Value; + use sha2::{Digest, Sha256}; + use super::*; use crate::AppError; @@ -909,6 +914,56 @@ mod tests { } } + #[test] + fn problem_json_serialization_masks_sensitive_metadata() { + let secret = "super-secret"; + let err = AppError::internal("oops").with_field(crate::field::str("token", secret)); + let problem = ProblemJson::from_ref(&err); + let json = serde_json::to_value(&problem).expect("serialize problem"); + + let metadata = json + .get("metadata") + .and_then(Value::as_object) + .expect("metadata present"); + let hashed = metadata + .get("token") + .and_then(Value::as_str) + .expect("hashed token"); + + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + let digest = hasher.finalize(); + let expected = digest + .iter() + .fold(String::with_capacity(64), |mut acc, byte| { + let _ = write!(&mut acc, "{:02x}", byte); + acc + }); + + assert_eq!(hashed, expected); + assert!(!json.to_string().contains(secret)); + + let debug_repr = format!("{:?}", problem.internal()); + assert!(debug_repr.contains("metadata")); + assert!(!debug_repr.contains(secret)); + } + + #[test] + fn problem_json_serialization_omits_metadata_when_redacted() { + let secret_value = "sensitive-value"; + let err = AppError::internal("secret") + .redactable() + .with_field(crate::field::str("token", secret_value)); + let problem = ProblemJson::from_ref(&err); + let json = serde_json::to_value(&problem).expect("serialize problem"); + + assert!(json.get("metadata").is_none()); + assert!(!json.to_string().contains(secret_value)); + + let debug_repr = format!("{:?}", problem.internal()); + assert!(debug_repr.contains("ProblemJson")); + } + #[test] fn mapping_for_every_code_matches_http_status() { for (code, mapping) in CODE_MAPPINGS { diff --git a/src/response/tests.rs b/src/response/tests.rs index 89777e5..d7f11b0 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -193,6 +193,20 @@ fn from_app_error_redacts_message_when_policy_allows() { assert_eq!(resp_ref.message, AppErrorKind::Internal.to_string()); } +#[test] +fn error_response_serialization_hides_redacted_message() { + let secret = "super-secret"; + let resp: ErrorResponse = AppError::internal(secret).redactable().into(); + let json = serde_json::to_value(&resp).expect("serialize response"); + + let fallback = AppErrorKind::Internal.to_string(); + assert_eq!( + json.get("message").and_then(|value| value.as_str()), + Some(fallback.as_str()) + ); + assert!(!json.to_string().contains(secret)); +} + // --- Display formatting -------------------------------------------------- #[test] From 37b06fc69fa4257c8027d5f9e0ba6d4b1987fd93 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 09:07:31 +0700 Subject: [PATCH 15/25] refactor: enrich converter errors with context metadata --- CHANGELOG.md | 15 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++-- src/convert/config.rs | 5 +- src/convert/multipart.rs | 42 ++++++++-- src/convert/redis.rs | 13 ++- src/convert/reqwest.rs | 12 ++- src/convert/serde_json.rs | 15 +++- src/convert/sqlx.rs | 17 ++-- src/convert/telegram_webapp_sdk.rs | 5 +- src/convert/teloxide.rs | 81 +++++++++++++++---- src/convert/tokio.rs | 5 +- src/convert/validator.rs | 5 +- .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 30 files changed, 199 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27be5bd..5d6eccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,21 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.1] - 2025-10-01 + +### Changed +- Enriched converter metadata across `multipart`, `redis`, `reqwest`, + `serde_json` and `sqlx` integrations to surface HTTP status details, + retry-after hints and structured failure positions while keeping existing + error categories intact. +- Updated the Teloxide mapping to classify `ApiError::InvalidToken` as + `Unauthorized` and hash potentially sensitive network error details before + emitting telemetry. + +### Tests +- Extended integration tests to assert the new metadata fields, retry hints, + and redaction policies covering the updated converters. + ## [0.20.0] - 2025-09-30 ### Added diff --git a/Cargo.lock b/Cargo.lock index a3abcc5..18742cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.0" +version = "0.20.1" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 5a58911..b36f3aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.0" +version = "0.20.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index b333634..b5cdc06 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.20.0", default-features = false } +masterror = { version = "0.20.1", default-features = false } # or with features: -# masterror = { version = "0.20.0", features = [ +# masterror = { version = "0.20.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.20.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.20.0", default-features = false } +masterror = { version = "0.20.1", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.0", features = [ +# masterror = { version = "0.20.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.20.0", default-features = false } +masterror = { version = "0.20.1", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.20.0", features = [ +masterror = { version = "0.20.1", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.20.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.20.0", features = [ +masterror = { version = "0.20.1", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/convert/config.rs b/src/convert/config.rs index d3ef335..44e7650 100644 --- a/src/convert/config.rs +++ b/src/convert/config.rs @@ -17,10 +17,7 @@ use config::ConfigError; #[cfg(feature = "config")] -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, field}; #[cfg(feature = "config")] #[cfg_attr(docsrs, doc(cfg(feature = "config")))] diff --git a/src/convert/multipart.rs b/src/convert/multipart.rs index 212db57..890c24e 100644 --- a/src/convert/multipart.rs +++ b/src/convert/multipart.rs @@ -8,16 +8,25 @@ use axum::extract::multipart::MultipartError; -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, field}; impl From for Error { fn from(err: MultipartError) -> Self { - Context::new(AppErrorKind::BadRequest) - .with(field::str("multipart.reason", err.to_string())) - .into_error(err) + let status = err.status(); + let body_text = err.body_text(); + let mut context = Context::new(AppErrorKind::BadRequest) + .with(field::str("multipart.reason", body_text)) + .with(field::u64("http.status", u64::from(status.as_u16()))) + .with(field::bool( + "http.is_client_error", + status.is_client_error() + )); + + if let Some(reason) = status.canonical_reason() { + context = context.with(field::str("http.status_reason", reason)); + } + + context.into_error(err) } } @@ -47,12 +56,29 @@ mod tests { .expect("extractor"); let err = multipart.next_field().await.expect_err("error"); + let status = err.status(); + let body_text = err.body_text(); let app_err: Error = err.into(); assert_eq!(app_err.kind, AppErrorKind::BadRequest); assert_eq!( app_err.metadata().get("multipart.reason"), - Some(&FieldValue::Str(err.to_string().into())) + Some(&FieldValue::Str(body_text.into())) + ); + assert_eq!( + app_err.metadata().get("http.status"), + Some(&FieldValue::U64(u64::from(status.as_u16()))) + ); + assert_eq!( + app_err.metadata().get("http.status_reason"), + status + .canonical_reason() + .map(|reason| FieldValue::Str(reason.into())) + .as_ref() + ); + assert_eq!( + app_err.metadata().get("http.is_client_error"), + Some(&FieldValue::Bool(status.is_client_error())) ); } } diff --git a/src/convert/redis.rs b/src/convert/redis.rs index 05cc4c3..a0fc969 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -36,10 +36,7 @@ use redis::{RedisError, RetryMethod}; #[cfg(feature = "redis")] -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, field}; /// Map any [`redis::RedisError`] into an [`AppError`] with kind `Cache`. /// @@ -104,6 +101,10 @@ fn build_context(err: &RedisError) -> (Context, Option) { format!("{:?}", retry_method) )); + if let Some(secs) = retry_after { + context = context.with(field::u64("redis.retry_after_hint_secs", secs)); + } + (context, retry_after) } @@ -144,5 +145,9 @@ mod tests { let app_err: Error = err.into(); assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(2)); assert!(matches!(app_err.kind, AppErrorKind::DependencyUnavailable)); + assert_eq!( + app_err.metadata().get("redis.retry_after_hint_secs"), + Some(&FieldValue::U64(2)) + ); } } diff --git a/src/convert/reqwest.rs b/src/convert/reqwest.rs index 9eeb29f..d79324f 100644 --- a/src/convert/reqwest.rs +++ b/src/convert/reqwest.rs @@ -47,9 +47,7 @@ #[cfg(feature = "reqwest")] use reqwest::{Error as ReqwestError, StatusCode}; -use crate::AppErrorKind; -#[cfg(feature = "reqwest")] -use crate::app_error::{Context, Error, FieldRedaction, field}; +use crate::{AppErrorKind, Context, Error, FieldRedaction, field}; /// Map a [`reqwest::Error`] into an [`Error`] according to its category. /// @@ -116,6 +114,10 @@ fn classify_reqwest_error(err: &ReqwestError) -> (Context, Option) { context = context.with(field::str("http.host", host.to_owned())); } + if let Some(port) = url.port() { + context = context.with(field::u64("http.port", u64::from(port))); + } + let path = url.path(); if !path.is_empty() { context = context.with(field::str("http.path", path.to_owned())); @@ -205,6 +207,10 @@ mod tests { assert_eq!(app_err.retry.map(|r| r.after_seconds), Some(1)); let metadata = app_err.metadata(); assert_eq!(metadata.get("http.status"), Some(&FieldValue::U64(429))); + assert_eq!( + metadata.get("http.port"), + Some(&FieldValue::U64(u64::from(addr.port()))) + ); server.abort(); } diff --git a/src/convert/serde_json.rs b/src/convert/serde_json.rs index f307217..b24ba10 100644 --- a/src/convert/serde_json.rs +++ b/src/convert/serde_json.rs @@ -34,10 +34,7 @@ use serde_json::{Error as SjError, error::Category}; #[cfg(feature = "serde_json")] -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, field}; /// Map a [`serde_json::Error`] into an [`AppError`]. /// @@ -71,6 +68,12 @@ fn build_context(err: &SjError) -> Context { if column != 0 { context = context.with(field::u64("serde_json.column", u64::from(column))); } + if line != 0 && column != 0 { + context = context.with(field::str( + "serde_json.position", + format!("{line}:{column}") + )); + } context } @@ -117,5 +120,9 @@ mod tests { metadata.get("serde_json.category"), Some(&FieldValue::Str("Syntax".into())) ); + assert_eq!( + metadata.get("serde_json.position"), + Some(&FieldValue::Str("1:1".into())) + ); } } diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index 1fbe506..9710414 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -40,10 +40,7 @@ use sqlx::migrate::MigrateError; use sqlx_core::error::{DatabaseError, Error as SqlxError, ErrorKind as SqlxErrorKind}; #[cfg(any(feature = "sqlx", feature = "sqlx-migrate"))] -use crate::{ - AppCode, AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppCode, AppErrorKind, Context, Error, field}; #[cfg(feature = "sqlx")] const SQLSTATE_CODE_OVERRIDES: &[(&str, AppCode)] = &[ @@ -88,7 +85,7 @@ impl From for Error { #[cfg(feature = "sqlx")] fn build_sqlx_context(err: &SqlxError) -> (Context, Option) { - match err { + let (mut context, retry_after) = match err { SqlxError::RowNotFound => ( Context::new(AppErrorKind::NotFound).with(field::str("db.reason", "row_not_found")), None @@ -196,7 +193,13 @@ fn build_sqlx_context(err: &SqlxError) -> (Context, Option) { .with(field::str("db.detail", format!("{:?}", other))), None ) + }; + + if let Some(secs) = retry_after { + context = context.with(field::u64("db.retry_after_hint_secs", secs)); } + + (context, retry_after) } #[cfg(feature = "sqlx")] @@ -346,6 +349,10 @@ mod tests_sqlx { }; let err: Error = SqlxError::Database(Box::new(db_err)).into(); assert_eq!(err.retry.map(|r| r.after_seconds), Some(1)); + assert_eq!( + err.metadata().get("db.retry_after_hint_secs"), + Some(&FieldValue::U64(1)) + ); } #[derive(Debug)] diff --git a/src/convert/telegram_webapp_sdk.rs b/src/convert/telegram_webapp_sdk.rs index 063eeba..d5529b0 100644 --- a/src/convert/telegram_webapp_sdk.rs +++ b/src/convert/telegram_webapp_sdk.rs @@ -39,10 +39,7 @@ use telegram_webapp_sdk::utils::validate_init_data::ValidationError; #[cfg(feature = "telegram-webapp-sdk")] -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, field}; /// Map [`ValidationError`] into an [`AppError`] with kind `TelegramAuth`. #[cfg(feature = "telegram-webapp-sdk")] diff --git a/src/convert/teloxide.rs b/src/convert/teloxide.rs index 9686f55..4f29f2b 100644 --- a/src/convert/teloxide.rs +++ b/src/convert/teloxide.rs @@ -4,8 +4,9 @@ //! //! ## Mapping //! -//! - [`RequestError::Api`] or [`RequestError::MigrateToChatId`] → -//! `AppErrorKind::ExternalApi` +//! - [`RequestError::Api`] → `AppErrorKind::ExternalApi` (invalid token → +//! `AppErrorKind::Unauthorized`) +//! - [`RequestError::MigrateToChatId`] → `AppErrorKind::ExternalApi` //! - [`RequestError::RetryAfter`] → `AppErrorKind::RateLimited` //! - [`RequestError::Network`] → `AppErrorKind::Network` //! - [`RequestError::InvalidJson`] → `AppErrorKind::Deserialization` @@ -27,13 +28,10 @@ //! assert!(matches!(app_err.kind, AppErrorKind::RateLimited)); //! ``` #[cfg(feature = "teloxide")] -use teloxide_core::RequestError; +use teloxide_core::{RequestError, errors::ApiError}; #[cfg(feature = "teloxide")] -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, FieldRedaction, field}; #[cfg(feature = "teloxide")] #[cfg_attr(docsrs, doc(cfg(feature = "teloxide")))] @@ -51,12 +49,21 @@ impl From for Error { #[cfg(feature = "teloxide")] fn build_teloxide_context(err: &RequestError) -> (Context, Option) { match err { - RequestError::Api(api) => ( - Context::new(AppErrorKind::ExternalApi) + RequestError::Api(api) => { + let mut context = Context::new(AppErrorKind::ExternalApi) .with(field::str("telegram.reason", "api")) - .with(field::str("telegram.api_error", api.to_string())), - None - ), + .with(field::str("telegram.api_error", api.to_string())) + .with(field::str( + "telegram.api_error_variant", + format!("{:?}", api) + )); + + if matches!(api, ApiError::InvalidToken) { + context = context.category(AppErrorKind::Unauthorized); + } + + (context, None) + } RequestError::MigrateToChatId(id) => ( Context::new(AppErrorKind::ExternalApi) .with(field::str("telegram.reason", "migrate_to_chat")) @@ -75,7 +82,8 @@ fn build_teloxide_context(err: &RequestError) -> (Context, Option) { RequestError::Network(e) => ( Context::new(AppErrorKind::Network) .with(field::str("telegram.reason", "network")) - .with(field::str("telegram.detail", e.to_string())), + .with(field::str("telegram.detail", e.to_string())) + .redact_field("telegram.detail", FieldRedaction::Hash), None ), RequestError::InvalidJson { @@ -99,12 +107,18 @@ fn build_teloxide_context(err: &RequestError) -> (Context, Option) { #[cfg(all(test, feature = "teloxide"))] mod tests { + #[cfg(feature = "reqwest")] + use std::time::Duration; use std::{io, sync::Arc}; use teloxide_core::{errors::ApiError, types::Seconds}; + #[cfg(feature = "reqwest")] + use tokio::runtime::Builder; use super::*; - use crate::{AppErrorKind, FieldValue}; + #[cfg(feature = "reqwest")] + use crate::FieldRedaction; + use crate::{AppCode, AppErrorKind, FieldValue}; #[test] fn api_maps_to_external_api() { @@ -136,4 +150,43 @@ mod tests { Some(&FieldValue::Str("io".into())) ); } + + #[test] + fn invalid_token_maps_to_unauthorized() { + let err = RequestError::Api(ApiError::InvalidToken); + let app_err: Error = err.into(); + assert_eq!(app_err.kind, AppErrorKind::Unauthorized); + assert_eq!(app_err.code, AppCode::Unauthorized); + let metadata = app_err.metadata(); + assert_eq!( + metadata.get("telegram.api_error_variant"), + Some(&FieldValue::Str("InvalidToken".into())) + ); + } + + #[cfg(feature = "reqwest")] + #[test] + fn network_detail_is_hashed() { + let runtime = Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let reqwest_err = runtime.block_on(async { + reqwest::Client::builder() + .timeout(Duration::from_millis(10)) + .build() + .expect("client") + .get("http://127.0.0.1:65535") + .send() + .await + .expect_err("expected failure") + }); + let err = RequestError::Network(Arc::new(reqwest_err)); + let app_err: Error = err.into(); + let metadata = app_err.metadata(); + assert_eq!( + metadata.redaction("telegram.detail"), + Some(FieldRedaction::Hash) + ); + } } diff --git a/src/convert/tokio.rs b/src/convert/tokio.rs index a2c79e1..f436017 100644 --- a/src/convert/tokio.rs +++ b/src/convert/tokio.rs @@ -36,10 +36,7 @@ use tokio::time::error::Elapsed; #[cfg(feature = "tokio")] -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, field}; /// Map a [`tokio::time::error::Elapsed`] into an [`AppError`] with kind /// `Timeout`. diff --git a/src/convert/validator.rs b/src/convert/validator.rs index e0fb4e2..fd15b69 100644 --- a/src/convert/validator.rs +++ b/src/convert/validator.rs @@ -42,10 +42,7 @@ use validator::{ValidationErrors, ValidationErrorsKind}; #[cfg(feature = "validator")] -use crate::{ - AppErrorKind, - app_error::{Context, Error, field} -}; +use crate::{AppErrorKind, Context, Error, field}; /// Map [`validator::ValidationErrors`] into an [`AppError`] with kind /// `Validation`. diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index c3fb86b..113a10d 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index b331baa..9ada290 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index b2658a1..fd151cc 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 83d517f..5a25e12 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | #[error("missing")] - | ^ +8 | / #[error("missing")] +9 | | Missing + | |___________^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index f929951..bdadf45 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 34abc91..037fac8 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index d579838..1822edf 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default From 3dd2760ed7b4eba417104794d9c562eced395b12 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:18:43 +0700 Subject: [PATCH 16/25] Fix conversions for Rust 1.89 compatibility --- CHANGELOG.md | 12 ++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++--- src/app_error.rs | 4 +- src/app_error/core.rs | 44 +++++++++++-- src/app_error/tests.rs | 19 +++--- src/convert/actix.rs | 8 +-- src/convert/config.rs | 3 + src/convert/redis.rs | 23 ++++--- src/convert/serde_json.rs | 15 ++++- src/convert/sqlx.rs | 29 +++++++-- src/convert/tonic.rs | 62 ++++++++----------- src/result_ext.rs | 19 +++--- .../fail/enum_missing_variant.stderr | 7 +-- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 - .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +-- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 30 files changed, 181 insertions(+), 130 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6eccd..9a0b0af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.2] - 2025-10-02 + +### Fixed +- Restored compatibility with Rust 1.89 by updating gRPC, Redis, SQLx and + serde_json integrations to avoid deprecated APIs, unsafe environment + mutations and Debug requirements that no longer hold. +- Added deterministic backtrace preference overrides for unit tests so + telemetry behavior remains covered without mutating global environment + variables. +- Ensured config error mapping gracefully handles new non-exhaustive variants + by falling back to a generic context that captures the formatted error. + ## [0.20.1] - 2025-10-01 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 18742cd..bba324d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.1" +version = "0.20.2" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index b36f3aa..236991d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.1" +version = "0.20.2" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index b5cdc06..4bedfff 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.20.1", default-features = false } +masterror = { version = "0.20.2", default-features = false } # or with features: -# masterror = { version = "0.20.1", features = [ +# masterror = { version = "0.20.2", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.20.1", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.20.1", default-features = false } +masterror = { version = "0.20.2", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.1", features = [ +# masterror = { version = "0.20.2", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.20.1", default-features = false } +masterror = { version = "0.20.2", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.20.1", features = [ +masterror = { version = "0.20.2", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.20.1", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.20.1", features = [ +masterror = { version = "0.20.2", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/app_error.rs b/src/app_error.rs index a154446..d952cc0 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -67,9 +67,9 @@ mod context; mod core; mod metadata; -#[cfg(all(test, feature = "backtrace"))] -pub(crate) use core::reset_backtrace_preference; pub use core::{AppError, AppResult, Error, MessageEditPolicy}; +#[cfg(all(test, feature = "backtrace"))] +pub(crate) use core::{reset_backtrace_preference, set_backtrace_preference_override}; pub use context::Context; pub use metadata::{Field, FieldRedaction, FieldValue, Metadata, field}; diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 38cff1b..cf43ec8 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -95,6 +95,11 @@ fn should_capture_backtrace() -> bool { #[cfg(feature = "backtrace")] fn detect_backtrace_preference() -> bool { + #[cfg(all(test, feature = "backtrace"))] + if let Some(value) = test_backtrace_override::get() { + return value; + } + match env::var_os("RUST_BACKTRACE") { None => false, Some(value) => { @@ -112,6 +117,40 @@ fn detect_backtrace_preference() -> bool { #[cfg(all(test, feature = "backtrace"))] pub(crate) fn reset_backtrace_preference() { BACKTRACE_STATE.store(BACKTRACE_STATE_UNSET, AtomicOrdering::Release); + test_backtrace_override::set(None); +} + +#[cfg(all(test, feature = "backtrace"))] +pub(crate) fn set_backtrace_preference_override(value: Option) { + test_backtrace_override::set(value); +} + +#[cfg(all(test, feature = "backtrace"))] +mod test_backtrace_override { + use std::sync::atomic::{AtomicI8, Ordering}; + + const OVERRIDE_UNSET: i8 = -1; + const OVERRIDE_DISABLED: i8 = 0; + const OVERRIDE_ENABLED: i8 = 1; + + static OVERRIDE_STATE: AtomicI8 = AtomicI8::new(OVERRIDE_UNSET); + + pub(super) fn set(value: Option) { + let state = match value { + Some(true) => OVERRIDE_ENABLED, + Some(false) => OVERRIDE_DISABLED, + None => OVERRIDE_UNSET + }; + OVERRIDE_STATE.store(state, Ordering::Release); + } + + pub(super) fn get() -> Option { + match OVERRIDE_STATE.load(Ordering::Acquire) { + OVERRIDE_ENABLED => Some(true), + OVERRIDE_DISABLED => Some(false), + _ => None + } + } } /// Rich application error preserving domain code, taxonomy and metadata. @@ -151,11 +190,6 @@ impl StdError for Error { .as_deref() .map(|source| source as &(dyn StdError + 'static)) } - - #[cfg(feature = "backtrace")] - fn backtrace(&self) -> Option<&Backtrace> { - self.capture_backtrace() - } } /// Conventional result alias for application code. diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index d1d33d0..69be3b7 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,9 +1,9 @@ -use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; #[cfg(feature = "backtrace")] -use std::{env, sync::Mutex}; +use std::sync::Mutex; +use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; #[cfg(feature = "backtrace")] -use super::core::reset_backtrace_preference; +use super::core::{reset_backtrace_preference, set_backtrace_preference_override}; #[cfg(feature = "backtrace")] static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); @@ -270,22 +270,19 @@ fn error_chain_is_preserved() { } #[cfg(feature = "backtrace")] -fn with_backtrace_env(value: Option<&str>, test: F) { +fn with_backtrace_preference(value: Option, test: F) { let _guard = BACKTRACE_ENV_GUARD.lock().expect("env guard"); reset_backtrace_preference(); - match value { - Some(val) => env::set_var("RUST_BACKTRACE", val), - None => env::remove_var("RUST_BACKTRACE") - } + set_backtrace_preference_override(value); test(); - env::remove_var("RUST_BACKTRACE"); + set_backtrace_preference_override(None); reset_backtrace_preference(); } #[cfg(feature = "backtrace")] #[test] fn backtrace_respects_disabled_env() { - with_backtrace_env(Some("0"), || { + with_backtrace_preference(Some(false), || { let err = AppError::internal("boom"); assert!(err.backtrace().is_none()); }); @@ -294,7 +291,7 @@ fn backtrace_respects_disabled_env() { #[cfg(feature = "backtrace")] #[test] fn backtrace_enabled_when_env_requests() { - with_backtrace_env(Some("1"), || { + with_backtrace_preference(Some(true), || { let err = AppError::internal("boom"); assert!(err.backtrace().is_some()); }); diff --git a/src/convert/actix.rs b/src/convert/actix.rs index b58108a..f4abbb0 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -72,13 +72,7 @@ //! See also: Axum integration in [`convert::axum`]. #[cfg(feature = "actix")] -use actix_web::{ - HttpResponse, ResponseError, - http::{ - StatusCode as ActixStatus, - header::{RETRY_AFTER, WWW_AUTHENTICATE} - } -}; +use actix_web::{HttpResponse, ResponseError, http::StatusCode as ActixStatus}; #[cfg(feature = "actix")] use crate::response::actix_impl::respond_with_problem_json; diff --git a/src/convert/config.rs b/src/convert/config.rs index 44e7650..c8c1913 100644 --- a/src/convert/config.rs +++ b/src/convert/config.rs @@ -88,6 +88,9 @@ fn build_context(error: &ConfigError) -> Context { ConfigError::Foreign(_) => { Context::new(AppErrorKind::Config).with(field::str("config.phase", "foreign")) } + other => Context::new(AppErrorKind::Config) + .with(field::str("config.phase", "unclassified")) + .with(field::str("config.debug", other.to_string())) } } diff --git a/src/convert/redis.rs b/src/convert/redis.rs index a0fc969..919de00 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -94,12 +94,8 @@ fn build_context(err: &RedisError) -> (Context, Option) { .with(field::u64("redis.redirect_slot", u64::from(slot))); } - let retry_method = err.retry_method(); - let retry_after = retry_after_hint(retry_method); - context = context.with(field::str( - "redis.retry_method", - format!("{:?}", retry_method) - )); + let (retry_method_label, retry_after) = retry_method_details(err.retry_method()); + context = context.with(field::str("redis.retry_method", retry_method_label)); if let Some(secs) = retry_after { context = context.with(field::u64("redis.retry_after_hint_secs", secs)); @@ -109,14 +105,17 @@ fn build_context(err: &RedisError) -> (Context, Option) { } #[cfg(feature = "redis")] -const fn retry_after_hint(method: RetryMethod) -> Option { +const fn retry_method_details(method: RetryMethod) -> (&'static str, Option) { match method { - RetryMethod::NoRetry => None, - RetryMethod::RetryImmediately | RetryMethod::AskRedirect | RetryMethod::MovedRedirect => { - Some(0) + RetryMethod::NoRetry => ("NoRetry", None), + RetryMethod::RetryImmediately => ("RetryImmediately", Some(0)), + RetryMethod::AskRedirect => ("AskRedirect", Some(0)), + RetryMethod::MovedRedirect => ("MovedRedirect", Some(0)), + RetryMethod::Reconnect => ("Reconnect", Some(1)), + RetryMethod::ReconnectFromInitialConnections => { + ("ReconnectFromInitialConnections", Some(1)) } - RetryMethod::Reconnect | RetryMethod::ReconnectFromInitialConnections => Some(1), - RetryMethod::WaitAndRetry => Some(2) + RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2)) } } diff --git a/src/convert/serde_json.rs b/src/convert/serde_json.rs index b24ba10..a47b854 100644 --- a/src/convert/serde_json.rs +++ b/src/convert/serde_json.rs @@ -30,6 +30,9 @@ //! assert!(matches!(app_err.kind, AppErrorKind::Deserialization)); //! ``` +#[cfg(feature = "serde_json")] +use std::convert::TryFrom; + #[cfg(feature = "serde_json")] use serde_json::{Error as SjError, error::Category}; @@ -62,11 +65,19 @@ fn build_context(err: &SjError) -> Context { let line = err.line(); if line != 0 { - context = context.with(field::u64("serde_json.line", u64::from(line))); + let value = match u64::try_from(line) { + Ok(converted) => converted, + Err(_) => u64::MAX + }; + context = context.with(field::u64("serde_json.line", value)); } let column = err.column(); if column != 0 { - context = context.with(field::u64("serde_json.column", u64::from(column))); + let value = match u64::try_from(column) { + Ok(converted) => converted, + Err(_) => u64::MAX + }; + context = context.with(field::u64("serde_json.column", value)); } if line != 0 && column != 0 { context = context.with(field::str( diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index 9710414..b8a377a 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -255,6 +255,11 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O #[cfg(feature = "sqlx-migrate")] fn build_migrate_context(err: &MigrateError) -> Context { + if is_invalid_mix(err) { + return Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "invalid_mix")); + } + match err { MigrateError::Execute(inner) => Context::new(AppErrorKind::Database) .with(field::str("migration.phase", "execute")) @@ -285,12 +290,20 @@ fn build_migrate_context(err: &MigrateError) -> Context { .with(field::i64("migration.latest", *latest)), MigrateError::ForceNotSupported => Context::new(AppErrorKind::Database) .with(field::str("migration.phase", "force_not_supported")), - MigrateError::InvalidMixReversibleAndSimple => { - Context::new(AppErrorKind::Database).with(field::str("migration.phase", "invalid_mix")) - } MigrateError::Dirty(version) => Context::new(AppErrorKind::Database) .with(field::str("migration.phase", "dirty")) - .with(field::i64("migration.version", *version)) + .with(field::i64("migration.version", *version)), + _ => Context::new(AppErrorKind::Database) + .with(field::str("migration.phase", "unclassified")) + .with(field::str("migration.detail", err.to_string())) + } +} + +#[cfg(feature = "sqlx-migrate")] +fn is_invalid_mix(err: &MigrateError) -> bool { + #[allow(deprecated)] + { + matches!(err, MigrateError::InvalidMixReversibleAndSimple) } } @@ -402,7 +415,13 @@ mod tests_sqlx { } fn kind(&self) -> SqlxErrorKind { - self.kind + match self.kind { + SqlxErrorKind::UniqueViolation => SqlxErrorKind::UniqueViolation, + SqlxErrorKind::ForeignKeyViolation => SqlxErrorKind::ForeignKeyViolation, + SqlxErrorKind::NotNullViolation => SqlxErrorKind::NotNullViolation, + SqlxErrorKind::CheckViolation => SqlxErrorKind::CheckViolation, + SqlxErrorKind::Other => SqlxErrorKind::Other + } } } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index e56c68e..8a567bf 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -18,9 +18,6 @@ //! assert_eq!(status.code(), tonic::Code::NotFound); //! ``` -#![cfg(feature = "tonic")] -#![cfg_attr(docsrs, doc(cfg(feature = "tonic")))] - use std::{borrow::Cow, convert::Infallible}; use tonic::{ @@ -39,29 +36,19 @@ impl TryFrom for Status { type Error = Infallible; fn try_from(error: Error) -> Result { - Ok(status_from_error(error)) + Ok(status_from_error(&error)) } } -fn status_from_error(error: Error) -> Status { +fn status_from_error(error: &Error) -> Status { error.emit_telemetry(); - let Error { - code, - kind, - message, - metadata, - edit_policy, - retry, - www_authenticate, - .. - } = error; - - let mapping = mapping_for_code(code); + + let mapping = mapping_for_code(error.code); let grpc_code = Code::from_i32(mapping.grpc().value); - let detail = sanitize_detail(message, kind, edit_policy); + let detail = sanitize_detail(error.message.as_ref(), error.kind, error.edit_policy); let mut meta = MetadataMap::new(); - insert_ascii(&mut meta, "app-code", code.as_str()); + insert_ascii(&mut meta, "app-code", error.code.as_str()); insert_ascii( &mut meta, "app-http-status", @@ -69,24 +56,24 @@ fn status_from_error(error: Error) -> Status { ); insert_ascii(&mut meta, "app-problem-type", mapping.problem_type()); - if let Some(advice) = retry { + if let Some(advice) = error.retry { insert_retry(&mut meta, advice); } - if let Some(challenge) = www_authenticate { - if is_ascii_metadata_value(&challenge) { + if let Some(challenge) = error.www_authenticate.as_deref() { + if is_ascii_metadata_value(challenge) { insert_ascii(&mut meta, "www-authenticate", challenge); } } - if !matches!(edit_policy, MessageEditPolicy::Redact) { - attach_metadata(&mut meta, metadata); + if !matches!(error.edit_policy, MessageEditPolicy::Redact) { + attach_metadata(&mut meta, error.metadata()); } Status::with_metadata(grpc_code, detail, meta) } fn sanitize_detail( - message: Option>, + message: Option<&Cow<'static, str>>, kind: AppErrorKind, policy: MessageEditPolicy ) -> String { @@ -94,16 +81,15 @@ fn sanitize_detail( return kind.to_string(); } - message.map_or_else(|| kind.to_string(), Cow::into_owned) + message.map_or_else(|| kind.to_string(), |msg| msg.as_ref().to_owned()) } fn insert_retry(meta: &mut MetadataMap, retry: RetryAdvice) { insert_ascii(meta, "retry-after", retry.after_seconds.to_string()); } -fn attach_metadata(meta: &mut MetadataMap, metadata: Metadata) { - for field in metadata { - let (name, value, redaction) = field.into_parts(); +fn attach_metadata(meta: &mut MetadataMap, metadata: &Metadata) { + for (name, value, redaction) in metadata.iter_with_redaction() { if !matches!(redaction, FieldRedaction::None) { continue; } @@ -124,21 +110,23 @@ fn insert_ascii(meta: &mut MetadataMap, key: &'static str, value: impl AsRef Option { +fn metadata_value_to_ascii(value: &FieldValue) -> Option> { match value { FieldValue::Str(value) => { - let owned = value.into_owned(); - is_ascii_metadata_value(&owned).then_some(owned) + let text = value.as_ref(); + is_ascii_metadata_value(text).then(|| Cow::Borrowed(text)) } - FieldValue::I64(value) => Some(value.to_string()), - FieldValue::U64(value) => Some(value.to_string()), - FieldValue::Bool(value) => Some(if value { "true" } else { "false" }.to_string()), - FieldValue::Uuid(value) => Some(value.to_string()) + FieldValue::I64(value) => Some(Cow::Owned(value.to_string())), + FieldValue::U64(value) => Some(Cow::Owned(value.to_string())), + FieldValue::Bool(value) => Some(Cow::Owned( + if *value { "true" } else { "false" }.to_string() + )), + FieldValue::Uuid(value) => Some(Cow::Owned(value.to_string())) } } diff --git a/src/result_ext.rs b/src/result_ext.rs index 60ec0e9..31d5f6c 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -45,18 +45,18 @@ impl ResultExt for Result { #[cfg(test)] mod tests { + #[cfg(feature = "backtrace")] + use std::sync::Mutex; use std::{ borrow::Cow, error::Error as StdError, fmt::{Display, Formatter, Result as FmtResult}, sync::Arc }; - #[cfg(feature = "backtrace")] - use std::{env, sync::Mutex}; use super::ResultExt; #[cfg(feature = "backtrace")] - use crate::app_error::reset_backtrace_preference; + use crate::app_error::{reset_backtrace_preference, set_backtrace_preference_override}; use crate::{ AppCode, AppErrorKind, app_error::{Context, FieldValue, MessageEditPolicy}, @@ -189,29 +189,26 @@ mod tests { } #[cfg(feature = "backtrace")] - fn with_backtrace_env(value: Option<&str>, test: impl FnOnce()) { + fn with_backtrace_preference(value: Option, test: impl FnOnce()) { let _guard = BACKTRACE_ENV_GUARD.lock().expect("env guard"); reset_backtrace_preference(); - match value { - Some(value) => env::set_var("RUST_BACKTRACE", value), - None => env::remove_var("RUST_BACKTRACE") - } + set_backtrace_preference_override(value); test(); - env::remove_var("RUST_BACKTRACE"); + set_backtrace_preference_override(None); reset_backtrace_preference(); } #[cfg(feature = "backtrace")] #[test] fn ctx_respects_backtrace_environment() { - with_backtrace_env(Some("0"), || { + with_backtrace_preference(Some(false), || { let err = Result::<(), DummyError>::Err(DummyError) .ctx(|| Context::new(AppErrorKind::Internal)) .expect_err("err"); assert!(err.backtrace().is_none()); }); - with_backtrace_env(Some("1"), || { + with_backtrace_preference(Some(true), || { let err = Result::<(), DummyError>::Err(DummyError) .ctx(|| Context::new(AppErrorKind::Internal)) .expect_err("err"); diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index bbc297c..d000de1 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("without")] -9 | | Without, - | |___________^ +8 | #[error("without")] + | ^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 4f02301..70ccade 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index 021c135..c615e98 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^^^^^^^^^^^^^^^^^^^^^ + | ^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b8f363..5b08225 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index be76742..d416399 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,5 +8,4 @@ error: multiple unused formatting arguments | argument never used | argument never used | - = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index b8bf229..d7acdb1 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index a6a40c2..5869420 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 | 4 | #[error("{value:y}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index 3d332c7..bbe04b4 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 | 4 | #[error("{value:B}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 0bd10fa..2c30e71 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 | 4 | #[error("{value:P}")] - | ^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index 113a10d..c3fb86b 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index 9ada290..b331baa 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index fd151cc..b2658a1 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 5a25e12..83d517f 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,9 +1,8 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | / #[error("missing")] -9 | | Missing - | |___________^ +8 | #[error("missing")] + | ^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -11,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index bdadf45..f929951 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 037fac8..34abc91 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | ^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index 1822edf..d579838 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default + = note: `#[warn(unused_imports)]` on by default From a592ededcf30751ef43d54a2833b12e28a20d40d Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 11:44:50 +0700 Subject: [PATCH 17/25] Fix feature regressions and prepare 0.20.3 --- CHANGELOG.md | 13 +++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +++++++------- src/convert.rs | 4 ++++ src/convert/actix.rs | 25 +++++++++++++++++++----- src/convert/axum.rs | 37 ++++++++++++++++++++++++------------ src/convert/multipart.rs | 2 +- src/convert/redis.rs | 5 +++-- src/convert/reqwest.rs | 2 +- src/convert/sqlx.rs | 5 +++-- src/convert/tonic.rs | 2 +- src/response/problem_json.rs | 14 +++++++------- 13 files changed, 87 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a0b0af..2f5377d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.3] - 2025-10-03 + +### Fixed +- Restored the Axum transport adapter in builds by wiring the `convert::axum` + module into the crate graph and relaxing the tests to validate responses via + `serde_json::Value` instead of requiring `ProblemJson` deserialization. +- Hardened converter telemetry for Redis, Reqwest, SQLx, Tonic and multipart + integrations by owning metadata strings where necessary and covering + non-exhaustive enums so the crate compiles cleanly on Rust 1.90. +- Reworked `ProblemJson` metadata internals to use `Cow<'static, str>` keys and + values, preserving zero-copy behaviour for borrowed data while allowing owned + fallbacks required by the updated converters. + ## [0.20.2] - 2025-10-02 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index bba324d..e9bdc54 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.2" +version = "0.20.3" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 236991d..23b8ad6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.2" +version = "0.20.3" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 4bedfff..5e30d22 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.20.2", default-features = false } +masterror = { version = "0.20.3", default-features = false } # or with features: -# masterror = { version = "0.20.2", features = [ +# masterror = { version = "0.20.3", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.20.2", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.20.2", default-features = false } +masterror = { version = "0.20.3", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.2", features = [ +# masterror = { version = "0.20.3", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.20.2", default-features = false } +masterror = { version = "0.20.3", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.20.2", features = [ +masterror = { version = "0.20.3", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.20.2", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.20.2", features = [ +masterror = { version = "0.20.3", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/convert.rs b/src/convert.rs index 7b74603..bb409a5 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -78,6 +78,10 @@ use std::io::Error as IoError; use crate::AppError; +#[cfg(feature = "axum")] +#[cfg_attr(docsrs, doc(cfg(feature = "axum")))] +mod axum; + #[cfg(all(feature = "axum", feature = "multipart"))] #[cfg_attr(docsrs, doc(cfg(all(feature = "axum", feature = "multipart"))))] mod multipart; diff --git a/src/convert/actix.rs b/src/convert/actix.rs index f4abbb0..b1c34f7 100644 --- a/src/convert/actix.rs +++ b/src/convert/actix.rs @@ -98,13 +98,15 @@ impl ResponseError for AppError { #[cfg(all(test, feature = "actix"))] mod actix_tests { + use std::str::FromStr; + use actix_web::{ ResponseError, body::to_bytes, http::header::{RETRY_AFTER, WWW_AUTHENTICATE} }; - use crate::{AppCode, AppError, AppErrorKind, AppResult, ProblemJson}; + use crate::{AppCode, AppError, AppErrorKind, AppResult}; #[test] fn maps_status_consistently() { @@ -132,10 +134,23 @@ mod actix_tests { ); let bytes = to_bytes(resp.into_body()).await?; - let body: ProblemJson = serde_json::from_slice(&bytes)?; - assert_eq!(body.status, 401); - assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.detail.as_deref(), Some("no token")); + let body: serde_json::Value = serde_json::from_slice(&bytes)?; + assert_eq!( + body.get("status").and_then(|value| value.as_u64()), + Some(401) + ); + assert_eq!( + body.get("code") + .and_then(|value| value.as_str()) + .map(AppCode::from_str) + .transpose() + .expect("parse app code"), + Some(AppCode::Unauthorized) + ); + assert_eq!( + body.get("detail").and_then(|value| value.as_str()), + Some("no token") + ); Ok(()) } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 14abf21..61d501e 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -69,6 +69,8 @@ impl IntoResponse for AppError { #[cfg(test)] mod tests { + use std::str::FromStr; + use axum::http::StatusCode; use super::*; @@ -127,14 +129,26 @@ mod tests { let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - let body: crate::response::ProblemJson = - serde_json::from_slice(&bytes).expect("json body"); - - assert_eq!(body.status, 401); - assert!(matches!(body.code, AppCode::Unauthorized)); - assert_eq!(body.detail.as_deref(), Some("missing token")); - assert!(body.metadata.is_none()); - assert!(body.grpc.is_some()); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); + + assert_eq!( + body.get("status").and_then(|value| value.as_u64()), + Some(401) + ); + assert_eq!( + body.get("code") + .and_then(|value| value.as_str()) + .map(AppCode::from_str) + .transpose() + .expect("parse app code"), + Some(AppCode::Unauthorized) + ); + assert_eq!( + body.get("detail").and_then(|value| value.as_str()), + Some("missing token") + ); + assert!(body.get("metadata").is_none()); + assert!(body.get("grpc").is_some()); } #[tokio::test] @@ -149,10 +163,9 @@ mod tests { let bytes = to_bytes(resp.into_body(), usize::MAX) .await .expect("read body"); - let body: crate::response::ProblemJson = - serde_json::from_slice(&bytes).expect("json body"); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json body"); - assert!(body.detail.is_none()); - assert!(body.metadata.is_none()); + assert!(body.get("detail").is_none()); + assert!(body.get("metadata").is_none()); } } diff --git a/src/convert/multipart.rs b/src/convert/multipart.rs index 890c24e..64510a3 100644 --- a/src/convert/multipart.rs +++ b/src/convert/multipart.rs @@ -38,7 +38,7 @@ mod tests { http::Request }; - use crate::{AppErrorKind, FieldValue}; + use crate::{AppErrorKind, Error, FieldValue}; #[tokio::test] async fn multipart_error_maps_to_bad_request() { diff --git a/src/convert/redis.rs b/src/convert/redis.rs index 919de00..fb7511d 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -59,7 +59,7 @@ impl From for Error { fn build_context(err: &RedisError) -> (Context, Option) { let mut context = Context::new(AppErrorKind::Cache) .with(field::str("redis.kind", format!("{:?}", err.kind()))) - .with(field::str("redis.category", err.category())) + .with(field::str("redis.category", err.category().to_owned())) .with(field::bool("redis.is_timeout", err.is_timeout())) .with(field::bool( "redis.is_cluster_error", @@ -115,7 +115,8 @@ const fn retry_method_details(method: RetryMethod) -> (&'static str, Option RetryMethod::ReconnectFromInitialConnections => { ("ReconnectFromInitialConnections", Some(1)) } - RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2)) + RetryMethod::WaitAndRetry => ("WaitAndRetry", Some(2)), + _ => ("Other", None) } } diff --git a/src/convert/reqwest.rs b/src/convert/reqwest.rs index d79324f..5fa756e 100644 --- a/src/convert/reqwest.rs +++ b/src/convert/reqwest.rs @@ -107,7 +107,7 @@ fn classify_reqwest_error(err: &ReqwestError) -> (Context, Option) { if let Some(url) = err.url() { context = context - .with(field::str("http.url", url.as_str())) + .with(field::str("http.url", url.to_string())) .redact_field("http.url", FieldRedaction::Hash); if let Some(host) = url.host_str() { diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index b8a377a..ced79fb 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -242,7 +242,7 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O SqlxErrorKind::NotNullViolation | SqlxErrorKind::CheckViolation => { AppErrorKind::Validation } - SqlxErrorKind::Other => AppErrorKind::Database + _ => AppErrorKind::Database }; context = context.category(category); @@ -420,7 +420,8 @@ mod tests_sqlx { SqlxErrorKind::ForeignKeyViolation => SqlxErrorKind::ForeignKeyViolation, SqlxErrorKind::NotNullViolation => SqlxErrorKind::NotNullViolation, SqlxErrorKind::CheckViolation => SqlxErrorKind::CheckViolation, - SqlxErrorKind::Other => SqlxErrorKind::Other + SqlxErrorKind::Other => SqlxErrorKind::Other, + _ => SqlxErrorKind::Other } } } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 8a567bf..677e378 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -110,7 +110,7 @@ fn insert_ascii(meta: &mut MetadataMap, key: &'static str, value: impl AsRef, + pub type_uri: Option>, /// Short, human-friendly title describing the error category. pub title: Cow<'static, str>, /// HTTP status code returned to the client. @@ -159,7 +159,7 @@ impl ProblemJson { let metadata = sanitize_metadata_owned(metadata, edit_policy); Self { - type_uri: Some(mapping.problem_type()), + type_uri: Some(Cow::Borrowed(mapping.problem_type())), title, status, detail, @@ -195,7 +195,7 @@ impl ProblemJson { let metadata = sanitize_metadata_ref(error.metadata(), error.edit_policy); Self { - type_uri: Some(mapping.problem_type()), + type_uri: Some(Cow::Borrowed(mapping.problem_type())), title, status, detail, @@ -231,7 +231,7 @@ impl ProblemJson { }; Self { - type_uri: Some(mapping.problem_type()), + type_uri: Some(Cow::Borrowed(mapping.problem_type())), title: Cow::Owned(mapping.kind().to_string()), status: response.status, detail, @@ -284,7 +284,7 @@ impl ProblemJson { /// ``` #[derive(Clone, Debug, Serialize)] #[serde(transparent)] -pub struct ProblemMetadata(BTreeMap<&'static str, ProblemMetadataValue>); +pub struct ProblemMetadata(BTreeMap, ProblemMetadataValue>); impl ProblemMetadata { #[cfg(test)] @@ -373,7 +373,7 @@ fn sanitize_metadata_owned( for field in metadata { let (name, value, redaction) = field.into_parts(); if let Some(sanitized) = sanitize_problem_metadata_value_owned(value, redaction) { - public.insert(name, sanitized); + public.insert(Cow::Borrowed(name), sanitized); } } @@ -395,7 +395,7 @@ fn sanitize_metadata_ref( let mut public = BTreeMap::new(); for (name, value, redaction) in metadata.iter_with_redaction() { if let Some(sanitized) = sanitize_problem_metadata_value_ref(value, redaction) { - public.insert(name, sanitized); + public.insert(Cow::Borrowed(name), sanitized); } } From f8ba879bf162593e832f4a99207db843f0d71b6f Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:13:27 +0700 Subject: [PATCH 18/25] Add AppCode parser and fix lint issues --- CHANGELOG.md | 14 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +++--- src/code.rs | 2 +- src/code/app_code.rs | 98 ++++++++++++++++++++++++++++++++++++++++- src/convert/axum.rs | 7 ++- src/convert/sqlx.rs | 3 +- src/convert/tonic.rs | 2 +- src/lib.rs | 2 +- src/response/details.rs | 1 + 11 files changed, 127 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f5377d..cf24aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.4] - 2025-10-04 + +### Added +- Implemented `FromStr` support for `AppCode` together with a lightweight + `ParseAppCodeError` so RFC7807 responses and documentation examples can parse + machine codes without bespoke helpers. + +### Fixed +- Removed the redundant `#![cfg(feature = "axum")]` attribute and tightened + Axum, SQLx and Tonic integration tests to satisfy `-D warnings` builds. +- Allowed attaching JSON details via `ErrorResponse::with_details` without + tripping Clippy's `result_large_err` lint by documenting the intentional + `AppError` return shape. + ## [0.20.3] - 2025-10-03 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e9bdc54..92e21e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.3" +version = "0.20.4" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 23b8ad6..641a46b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.3" +version = "0.20.4" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 5e30d22..9cbec41 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.20.3", default-features = false } +masterror = { version = "0.20.4", default-features = false } # or with features: -# masterror = { version = "0.20.3", features = [ +# masterror = { version = "0.20.4", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.20.3", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.20.3", default-features = false } +masterror = { version = "0.20.4", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.3", features = [ +# masterror = { version = "0.20.4", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.20.3", default-features = false } +masterror = { version = "0.20.4", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.20.3", features = [ +masterror = { version = "0.20.4", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.20.3", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.20.3", features = [ +masterror = { version = "0.20.4", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/code.rs b/src/code.rs index 8ce7783..b9dc44a 100644 --- a/src/code.rs +++ b/src/code.rs @@ -69,4 +69,4 @@ mod app_code; -pub use app_code::AppCode; +pub use app_code::{AppCode, ParseAppCodeError}; diff --git a/src/code/app_code.rs b/src/code/app_code.rs index b46d699..9afc1f3 100644 --- a/src/code/app_code.rs +++ b/src/code/app_code.rs @@ -1,4 +1,8 @@ -use std::fmt::{self, Display}; +use std::{ + error::Error as StdError, + fmt::{self, Display}, + str::FromStr +}; use serde::{Deserialize, Serialize}; #[cfg(feature = "openapi")] @@ -6,6 +10,21 @@ use utoipa::ToSchema; use crate::kind::AppErrorKind; +/// Error returned when parsing [`AppCode`] from a string fails. +/// +/// The parser only accepts the canonical SCREAMING_SNAKE_CASE representations +/// emitted by [`AppCode::as_str`]. Any other value results in this error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ParseAppCodeError; + +impl Display for ParseAppCodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("invalid app code") + } +} + +impl StdError for ParseAppCodeError {} + /// Stable machine-readable error code exposed to clients. /// /// Values are serialized as **SCREAMING_SNAKE_CASE** strings (e.g., @@ -188,6 +207,59 @@ impl Display for AppCode { } } +/// Parse an [`AppCode`] from its canonical string representation. +/// +/// # Errors +/// +/// Returns [`ParseAppCodeError`] when the input does not match any known code. +/// +/// # Examples +/// ``` +/// use std::str::FromStr; +/// +/// use masterror::{AppCode, ParseAppCodeError}; +/// +/// let code = AppCode::from_str("NOT_FOUND")?; +/// assert_eq!(code, AppCode::NotFound); +/// # Ok::<(), ParseAppCodeError>(()) +/// ``` +impl FromStr for AppCode { + type Err = ParseAppCodeError; + + fn from_str(s: &str) -> Result { + match s { + // 4xx + "NOT_FOUND" => Ok(Self::NotFound), + "VALIDATION" => Ok(Self::Validation), + "CONFLICT" => Ok(Self::Conflict), + "USER_ALREADY_EXISTS" => Ok(Self::UserAlreadyExists), + "UNAUTHORIZED" => Ok(Self::Unauthorized), + "FORBIDDEN" => Ok(Self::Forbidden), + "NOT_IMPLEMENTED" => Ok(Self::NotImplemented), + "BAD_REQUEST" => Ok(Self::BadRequest), + "RATE_LIMITED" => Ok(Self::RateLimited), + "TELEGRAM_AUTH" => Ok(Self::TelegramAuth), + "INVALID_JWT" => Ok(Self::InvalidJwt), + + // 5xx + "INTERNAL" => Ok(Self::Internal), + "DATABASE" => Ok(Self::Database), + "SERVICE" => Ok(Self::Service), + "CONFIG" => Ok(Self::Config), + "TURNKEY" => Ok(Self::Turnkey), + "TIMEOUT" => Ok(Self::Timeout), + "NETWORK" => Ok(Self::Network), + "DEPENDENCY_UNAVAILABLE" => Ok(Self::DependencyUnavailable), + "SERIALIZATION" => Ok(Self::Serialization), + "DESERIALIZATION" => Ok(Self::Deserialization), + "EXTERNAL_API" => Ok(Self::ExternalApi), + "QUEUE" => Ok(Self::Queue), + "CACHE" => Ok(Self::Cache), + _ => Err(ParseAppCodeError) + } + } +} + impl From for AppCode { /// Map internal taxonomy (`AppErrorKind`) to public machine code /// (`AppCode`). @@ -227,7 +299,9 @@ impl From for AppCode { #[cfg(test)] mod tests { - use super::{AppCode, AppErrorKind}; + use std::str::FromStr; + + use super::{AppCode, AppErrorKind, ParseAppCodeError}; #[test] fn as_str_matches_json_serde_names() { @@ -264,4 +338,24 @@ mod tests { fn display_uses_screaming_snake_case() { assert_eq!(AppCode::BadRequest.to_string(), "BAD_REQUEST"); } + + #[test] + fn from_str_parses_known_codes() { + for code in [ + AppCode::NotFound, + AppCode::Validation, + AppCode::Unauthorized, + AppCode::Internal, + AppCode::Timeout + ] { + let parsed = AppCode::from_str(code.as_str()).expect("parse"); + assert_eq!(parsed, code); + } + } + + #[test] + fn from_str_rejects_unknown_code() { + let err = AppCode::from_str("NOT_A_REAL_CODE").unwrap_err(); + assert_eq!(err, ParseAppCodeError); + } } diff --git a/src/convert/axum.rs b/src/convert/axum.rs index 61d501e..d0e5af5 100644 --- a/src/convert/axum.rs +++ b/src/convert/axum.rs @@ -37,7 +37,6 @@ //! - This module does not expose internal error sources; only `kind`, `status`, //! and optional public `message` are surfaced. -#![cfg(feature = "axum")] #![cfg_attr(docsrs, doc(cfg(feature = "axum")))] use axum::{ @@ -74,7 +73,7 @@ mod tests { use axum::http::StatusCode; use super::*; - use crate::{AppCode, AppErrorKind}; + use crate::AppCode; // --- http_status mapping ------------------------------------------------- @@ -101,7 +100,7 @@ mod tests { let app_err = AppError::unauthorized("missing token") .with_retry_after_secs(7) .with_www_authenticate("Bearer realm=\"api\""); - let mut resp = app_err.into_response(); + let resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); @@ -156,7 +155,7 @@ mod tests { use axum::{body::to_bytes, response::IntoResponse}; let app_err = AppError::internal("secret").redactable(); - let mut resp = app_err.into_response(); + let resp = app_err.into_response(); assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); diff --git a/src/convert/sqlx.rs b/src/convert/sqlx.rs index ced79fb..d16f549 100644 --- a/src/convert/sqlx.rs +++ b/src/convert/sqlx.rs @@ -216,7 +216,6 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O } let mut retry_after = None; - let mut category = AppErrorKind::Database; let mut code_override = None; let code = error.code().map(|code| code.into_owned()); @@ -236,7 +235,7 @@ fn classify_database_error(error: &(dyn DatabaseError + 'static)) -> (Context, O } } - category = match error.kind() { + let category = match error.kind() { SqlxErrorKind::UniqueViolation => AppErrorKind::Conflict, SqlxErrorKind::ForeignKeyViolation => AppErrorKind::Conflict, SqlxErrorKind::NotNullViolation | SqlxErrorKind::CheckViolation => { diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 677e378..9328132 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -119,7 +119,7 @@ fn metadata_value_to_ascii(value: &FieldValue) -> Option> { match value { FieldValue::Str(value) => { let text = value.as_ref(); - is_ascii_metadata_value(text).then(|| Cow::Borrowed(text)) + is_ascii_metadata_value(text).then_some(Cow::Borrowed(text)) } FieldValue::I64(value) => Some(Cow::Owned(value.to_string())), FieldValue::U64(value) => Some(Cow::Owned(value.to_string())), diff --git a/src/lib.rs b/src/lib.rs index 0f912bd..1bcac14 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -326,7 +326,7 @@ pub use app_error::{ AppError, AppResult, Context, Error, Field, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, field }; -pub use code::AppCode; +pub use code::{AppCode, ParseAppCodeError}; pub use kind::AppErrorKind; /// Re-export derive macros so users only depend on this crate. /// diff --git a/src/response/details.rs b/src/response/details.rs index ceca799..faa699e 100644 --- a/src/response/details.rs +++ b/src/response/details.rs @@ -54,6 +54,7 @@ impl ErrorResponse { /// assert!(resp.details.is_some()); /// # } /// ``` + #[allow(clippy::result_large_err)] pub fn with_details(self, payload: T) -> AppResult where T: Serialize From 87984f76530163d2746cdadb2b4c110b79c4527d Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:46:36 +0700 Subject: [PATCH 19/25] docs: rewrite README for 0.20 workspace --- CHANGELOG.md | 5 + Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 577 +++++++-------------------------------------- README.ru.md | 498 ++++++++++++++++---------------------- README.template.md | 540 ++++++------------------------------------ 6 files changed, 364 insertions(+), 1260 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf24aca..16e02c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.5] - 2025-10-05 + +### Changed +- Rewrote the English and Russian READMEs to reflect the matured workspace, feature flags, telemetry flows and transport integrations introduced across the 0.20 releases. + ## [0.20.4] - 2025-10-04 ### Added diff --git a/Cargo.lock b/Cargo.lock index 92e21e8..d6d73a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.4" +version = "0.20.5" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 641a46b..9fd4521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.4" +version = "0.20.5" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 9cbec41..13d327c 100644 --- a/README.md +++ b/README.md @@ -14,33 +14,66 @@ > 🇷🇺 Читайте README на [русском языке](README.ru.md). -Small, pragmatic error model for API-heavy Rust services with native derives -and typed telemetry. -Core is framework-agnostic; integrations are opt-in via feature flags. -Stable categories, conservative HTTP mapping, no `unsafe`. - -- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata` -- Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, - `#[masterror(...)]`, `#[provide]` for domain mappings and structured - telemetry -- Optional Axum/Actix integration and browser/WASM console logging -- Optional OpenAPI schema (via `utoipa`) -- Structured metadata helpers via `field::*` builders -- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` -- Turnkey domain taxonomy and helpers (`turnkey` feature) - -👉 Explore the new [error-handling wiki](docs/wiki/index.md) for step-by-step -guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. - ---- +`masterror` grew from a handful of helpers into a workspace of composable crates for +building consistent, observable error surfaces across Rust services. The core +crate stays framework-agnostic, while feature flags light up transport adapters, +integrations and telemetry without pulling in heavyweight defaults. No +`unsafe`, MSRV is pinned, and the derive macros keep your domain types in charge +of redaction and metadata. + +### Highlights + +- **Unified taxonomy.** `AppError`, `AppErrorKind` and `AppCode` model domain and + transport concerns with conservative HTTP/gRPC mappings, turnkey retry/auth + hints and RFC7807 output via `ProblemJson`. +- **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while + forwarding sources, backtraces, telemetry providers and redaction policy. +- **Typed telemetry.** `Metadata` stores structured key/value context with + per-field redaction controls and builders in `field::*`, so logs stay + structured without manual `String` maps. +- **Transport adapters.** Optional features expose Actix/Axum responders, + `tonic::Status` conversions, WASM/browser logging and OpenAPI schema + generation without contaminating the lean default build. +- **Battle-tested integrations.** Enable focused mappings for `sqlx`, + `reqwest`, `redis`, `validator`, `config`, `tokio`, `teloxide`, `multipart`, + Telegram WebApp SDK and more — each translating library errors into the + taxonomy with telemetry attached. +- **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, + helper builders and tracing instrumentation for teams that want a consistent + baseline out of the box. + +### Workspace crates + +| Crate | What it provides | When to depend on it | +| --- | --- | --- | +| [`masterror`](https://crates.io/crates/masterror) | Core error types, metadata builders, transports, integrations and the prelude. | Application crates, services and libraries that want a stable error surface. | +| [`masterror-derive`](masterror-derive/README.md) | Proc-macros backing `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]` and `#[provide]`. | Brought in automatically via `masterror`; depend directly only for macro hacking. | +| [`masterror-template`](masterror-template/README.md) | Shared template parser used by the derive macros for formatter analysis. | Internal dependency; reuse when you need the template parser elsewhere. | + +### Feature flags at a glance + +Pick only what you need; everything is off by default. + +- **Web transports:** `axum`, `actix`, `multipart`, `openapi`, `serde_json`. +- **Telemetry & observability:** `tracing`, `metrics`, `backtrace`. +- **Async & IO integrations:** `tokio`, `reqwest`, `sqlx`, `sqlx-migrate`, + `redis`, `validator`, `config`. +- **Messaging & bots:** `teloxide`, `telegram-webapp-sdk`. +- **Front-end tooling:** `frontend` for WASM/browser console logging. +- **gRPC:** `tonic` to emit `tonic::Status` responses. +- **Batteries included:** `turnkey` to adopt the pre-built taxonomy and helpers. + +The build script keeps the full feature snippet below in sync with +`Cargo.toml`. ### TL;DR ~~~toml [dependencies] -masterror = { version = "0.20.4", default-features = false } +masterror = { version = "0.20.5", default-features = false } # or with features: -# masterror = { version = "0.20.4", features = [ +# masterror = { version = "0.20.5", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -49,52 +82,8 @@ masterror = { version = "0.20.4", default-features = false } # ] } ~~~ -*Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* -*Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* -*Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* -*Since v0.15.0: RFC7807 `ProblemJson` responses for HTTP integrations and `tonic::Status` conversion.* - --- -
- 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.** `ProblemJson { type?, title, status, detail?, code, grpc?, metadata? }` with `Retry-After` / `WWW-Authenticate` headers when present. -- **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. -- **One log at boundary.** Log once with `tracing`. -- **Less boilerplate.** Built-in conversions, compact prelude, and the - native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` - support. -- **Consistent workspace.** Same error surface across crates. - -
- -
- Installation - -~~~toml -[dependencies] -# lean core -masterror = { version = "0.20.4", default-features = false } - -# with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.4", features = [ -# "axum", "actix", "openapi", "serde_json", -# "tracing", "metrics", "backtrace", "sqlx", -# "sqlx-migrate", "reqwest", "redis", "validator", -# "config", "tokio", "multipart", "teloxide", -# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" -# ] } -~~~ - -**MSRV:** 1.90 -**No unsafe:** forbidden by crate. - -
-
Quick start @@ -126,7 +115,10 @@ fn do_work(flag: bool) -> AppResult<()> {
- Derive custom errors + Derive domain errors and map them to transports + +`masterror` ships native derives so your domain types stay expressive while the +crate handles conversions, telemetry and redaction for you. ~~~rust use std::io; @@ -160,7 +152,7 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` brings the crate's derive macro into scope. +- `use masterror::Error;` brings the derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward @@ -183,12 +175,15 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. -#### `#[derive(Masterror)]` and `#[masterror(...)]` +
+ +
+ Attach telemetry, redaction policy and conversions -`#[derive(Masterror)]` wires a domain error directly into [`masterror::Error`], -augmenting it with metadata, redaction policy and optional transport mappings. -The accompanying `#[masterror(...)]` attribute mirrors the `#[app_error]` -syntax while remaining explicit about telemetry: +`#[derive(Masterror)]` wires a domain error into [`masterror::Error`], adds +metadata, redaction policy and optional transport mappings. The accompanying +`#[masterror(...)]` attribute mirrors the `#[app_error]` syntax while staying +explicit about telemetry and redaction. ~~~rust use masterror::{ @@ -255,103 +250,10 @@ All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) are still honoured. Sources and backtraces are automatically attached to the generated [`masterror::Error`]. -#### Display shorthand projections - -`#[error("...")]` supports the same shorthand syntax as `thiserror` for -referencing fields with `.field` or `.0`. The derive now understands chained -segments, so projections like `.limits.lo`, `.0.data` or -`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw -identifiers and tuple indices are preserved, ensuring keywords such as -`r#type` and tuple fields continue to work even when you call methods on the -projected value. - -~~~rust -use masterror::Error; - -#[derive(Debug)] -struct Limits { - lo: i32, - hi: i32, -} - -#[derive(Debug, Error)] -#[error( - "range {lo}-{hi} suggestion {suggestion}", - lo = .limits.lo, - hi = .limits.hi, - suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) -)] -struct RangeError { - limits: Limits, - suggestion: Option, -} - -#[derive(Debug)] -struct Payload { - data: &'static str, -} - -#[derive(Debug, Error)] -enum UiError { - #[error("tuple data {data}", data = .0.data)] - Tuple(Payload), - #[error( - "named suggestion {value}", - value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) - )] - Named { suggestion: Option }, -} -~~~ - -#### AppError conversions - -Annotating structs or enum variants with `#[app_error(...)]` captures the -metadata required to convert the domain error into `AppError` (and optionally -`AppCode`). Every variant in an enum must provide the mapping when any variant -requests it. - -~~~rust -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)); -assert_eq!(app.message.as_deref(), Some("missing flag: feature")); - -let code: AppCode = MissingFlag { name: "feature" }.into(); -assert!(matches!(code, AppCode::BadRequest)); -~~~ - -For enums, each variant specifies the mapping while the derive generates a -single `From` implementation that matches every variant: - -~~~rust -#[derive(Debug, Error)] -enum ApiError { - #[error("missing resource {id}")] - #[app_error( - kind = AppErrorKind::NotFound, - code = AppCode::NotFound, - message - )] - Missing { id: u64 }, - #[error("backend unavailable")] - #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] - Backend, -} - -let missing = ApiError::Missing { id: 7 }; -let as_app: AppError = missing.into(); -assert_eq!(as_app.message.as_deref(), Some("missing resource 7")); -~~~ +
-#### Structured telemetry providers and AppError mappings +
+ Structured telemetry providers and AppError mappings `#[provide(...)]` exposes typed context through `std::error::Request`, while `#[app_error(...)]` records how your domain error translates into `AppError` @@ -452,173 +354,12 @@ assert!(matches!(app.kind, AppErrorKind::Service)); Compared to `thiserror`, you retain the familiar deriving surface while gaining structured telemetry (`#[provide]`) and first-class conversions into -`AppError`/`AppCode` without writing manual `From` implementations. - -#### Formatter traits - -Placeholders default to `Display` (`{value}`) but can opt into richer -formatters via the same specifiers supported by `thiserror` v2. -`TemplateFormatter::is_alternate()` tracks the `#` flag, while -`TemplateFormatterKind` exposes the underlying `core::fmt` trait so derived -code can branch on the requested renderer without manual pattern matching. -Unsupported formatters surface a compile error that mirrors `thiserror`'s -diagnostics. - -| Specifier | `core::fmt` trait | Example output | Notes | -|------------------|----------------------------|------------------------|-------| -| _default_ | `core::fmt::Display` | `value` | User-facing strings; `#` has no effect. | -| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / multi-line | Mirrors `Debug`; `#` pretty-prints structs. | -| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Hexadecimal; `#` prepends `0x`. | -| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Uppercase hex; `#` prepends `0x`. | -| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Raw pointers; `#` is accepted for compatibility. | -| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Binary; `#` prepends `0b`. | -| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Octal; `#` prepends `0o`. | -| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Scientific notation; `#` forces the decimal point. | -| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Uppercase scientific; `#` forces the decimal point. | - -- `TemplateFormatterKind::supports_alternate()` reports whether the `#` flag is - meaningful for the requested trait (pointer accepts it even though the output - matches the non-alternate form). -- `TemplateFormatterKind::specifier()` returns the canonical format specifier - character when one exists, enabling custom derives to re-render placeholders - in their original style. -- `TemplateFormatter::from_kind(kind, alternate)` reconstructs a formatter from - the lightweight `TemplateFormatterKind`, making it easy to toggle the - alternate flag in generated code. - -~~~rust -use core::ptr; - -use masterror::Error; - -#[derive(Debug, Error)] -#[error( - "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ - oct={mask:o}, lower={ratio:e}, upper={ratio:E}" -)] -struct FormattedError { - id: u32, - payload: String, - ptr: *const u8, - mask: u8, - ratio: f32, -} - -let err = FormattedError { - id: 0x2a, - payload: "hello".into(), - ptr: ptr::null(), - mask: 0b1010_0001, - ratio: 0.15625, -}; - -let rendered = err.to_string(); -assert!(rendered.contains("debug=\"hello\"")); -assert!(rendered.contains("hex=0x2a")); -assert!(rendered.contains("ptr=0x0")); -assert!(rendered.contains("bin=0b10100001")); -assert!(rendered.contains("oct=241")); -assert!(rendered.contains("lower=1.5625e-1")); -assert!(rendered.contains("upper=1.5625E-1")); -~~~ - -~~~rust -use masterror::error::template::{ - ErrorTemplate, TemplateFormatter, TemplateFormatterKind -}; - -let template = ErrorTemplate::parse("{code:#x} → {payload:?}").expect("parse"); -let mut placeholders = template.placeholders(); - -let code = placeholders.next().expect("code placeholder"); -let code_formatter = code.formatter(); -assert!(matches!( - code_formatter, - TemplateFormatter::LowerHex { alternate: true } -)); -let code_kind = code_formatter.kind(); -assert_eq!(code_kind, TemplateFormatterKind::LowerHex); -assert!(code_formatter.is_alternate()); -assert_eq!(code_kind.specifier(), Some('x')); -assert!(code_kind.supports_alternate()); -let lowered = TemplateFormatter::from_kind(code_kind, false); -assert!(matches!( - lowered, - TemplateFormatter::LowerHex { alternate: false } -)); - -let payload = placeholders.next().expect("payload placeholder"); -let payload_formatter = payload.formatter(); -assert_eq!( - payload_formatter, - &TemplateFormatter::Debug { alternate: false } -); -let payload_kind = payload_formatter.kind(); -assert_eq!(payload_kind, TemplateFormatterKind::Debug); -assert_eq!(payload_kind.specifier(), Some('?')); -assert!(payload_kind.supports_alternate()); -let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); -assert!(matches!( - pretty_debug, - TemplateFormatter::Debug { alternate: true } -)); -assert!(pretty_debug.is_alternate()); -~~~ - -Display-only format specs (alignment, precision, fill — including `#` as a fill -character) are preserved so you can forward them to `write!` without rebuilding -the fragment: - -~~~rust -use masterror::error::template::ErrorTemplate; - -let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); -let display = aligned.placeholders().next().expect("display placeholder"); -assert_eq!(display.formatter().display_spec(), Some(">8")); -assert_eq!( - display - .formatter() - .format_fragment() - .as_deref(), - Some(">8") -); - -let hashed = ErrorTemplate::parse("{value:#>4}").expect("parse"); -let hash_placeholder = hashed - .placeholders() - .next() - .expect("hash-fill display placeholder"); -assert_eq!(hash_placeholder.formatter().display_spec(), Some("#>4")); -assert_eq!( - hash_placeholder - .formatter() - .format_fragment() - .as_deref(), - Some("#>4") -); -~~~ - -> **Compatibility with `thiserror` v2:** the derive understands the extended -> formatter set introduced in `thiserror` 2.x and reports identical diagnostics -> for unsupported specifiers, so migrating existing derives is drop-in. - -```rust -use masterror::error::template::{ErrorTemplate, TemplateIdentifier}; - -let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); -let display = template.display_with(|placeholder, f| match placeholder.identifier() { - TemplateIdentifier::Named("code") => write!(f, "{}", 404), - TemplateIdentifier::Named("message") => f.write_str("Not Found"), - _ => Ok(()), -}); - -assert_eq!(display.to_string(), "404: Not Found"); -``` +`AppError`/`AppCode` without manual glue.
- Error response payload + Problem JSON payloads and retry/authentication hints ~~~rust use masterror::{AppError, AppErrorKind, ProblemJson}; @@ -637,172 +378,14 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");
-
- Web framework integrations - -
- Axum - -~~~rust -// features = ["axum", "serde_json"] -... - assert!(payload.is_object()); - - #[cfg(target_arch = "wasm32")] - { - if let Err(console_err) = err.log_to_browser_console() { - eprintln!( - "failed to log to browser console: {:?}", - console_err.context() - ); - } - } - - Ok(()) -} -~~~ - -- On non-WASM targets `log_to_browser_console` returns - `BrowserConsoleError::UnsupportedTarget`. -- `BrowserConsoleError::context()` exposes optional browser diagnostics for - logging/telemetry when console logging fails. - -
- -
- -
- Feature flags - -- `axum` — IntoResponse integration with structured JSON bodies -- `actix` — Actix Web ResponseError and Responder implementations -- `openapi` — Generate utoipa OpenAPI schema for ErrorResponse -- `serde_json` — Attach structured JSON details to AppError -- `tracing` — Emit structured tracing events when errors are constructed -- `metrics` — Increment `error_total{code,category}` counter for each AppError -- `backtrace` — Capture lazy `Backtrace` snapshots when telemetry is flushed -- `sqlx` — Classify sqlx_core::Error variants into AppError kinds -- `sqlx-migrate` — Map sqlx::migrate::MigrateError into AppError (Database) -- `reqwest` — Classify reqwest::Error as timeout/network/external API -- `redis` — Map redis::RedisError into cache-aware AppError -- `validator` — Convert validator::ValidationErrors into validation failures -- `config` — Propagate config::ConfigError as configuration issues -- `tokio` — Classify tokio::time::error::Elapsed as timeout -- `multipart` — Handle axum multipart extraction errors -- `teloxide` — Convert teloxide_core::RequestError into domain errors -- `telegram-webapp-sdk` — Surface Telegram WebApp validation failures -- `tonic` — Convert AppError into tonic::Status with redaction -- `frontend` — Log to the browser console and convert to JsValue on WASM -- `turnkey` — Ship Turnkey-specific error taxonomy and conversions - -
- -
- Conversions - -- `std::io::Error` → Internal -- `String` → BadRequest -- `sqlx::Error` → NotFound/Database -- `redis::RedisError` → Cache -- `reqwest::Error` → Timeout/Network/ExternalApi -- `axum::extract::multipart::MultipartError` → BadRequest -- `validator::ValidationErrors` → Validation -- `config::ConfigError` → Config -- `tokio::time::error::Elapsed` → Timeout -- `teloxide_core::RequestError` → RateLimited/Network/ExternalApi/Deserialization/Internal -- `telegram_webapp_sdk::utils::validate_init_data::ValidationError` → TelegramAuth - -
- -
- Typical setups - -Minimal core: - -~~~toml -masterror = { version = "0.20.4", default-features = false } -~~~ - -API (Axum + JSON + deps): - -~~~toml -masterror = { version = "0.20.4", features = [ - "axum", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -API (Actix + JSON + deps): - -~~~toml -masterror = { version = "0.20.4", features = [ - "actix", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -
- -
- Turnkey - -~~~rust -// 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 +### Further resources -Semantic versioning. Breaking API/wire contract → major bump. -MSRV = 1.90 (may raise in minor, never in patch). +- Explore the [error-handling wiki](docs/wiki/index.md) for step-by-step guides, + comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. +- Browse the [crate documentation on docs.rs](https://docs.rs/masterror) for API + details, feature-specific guides and transport tables. +- Check [`CHANGELOG.md`](CHANGELOG.md) for release highlights and migration notes. -
- -
- Release checklist - -1. `cargo +nightly fmt --` -1. `cargo clippy -- -D warnings` -1. `cargo test --all` -1. `cargo build` (regenerates README.md from the template) -1. `cargo doc --no-deps` -1. `cargo package --locked` - -
- -
- 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. +--- -
+MSRV: **1.90** · License: **MIT OR Apache-2.0** · No `unsafe` diff --git a/README.ru.md b/README.ru.md index d87a385..2c90dfc 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,6 +1,6 @@ # masterror · Каркас-независимые типы ошибок приложений -> Этот документ — русская версия основной документации. Английскую версию см. в [README.md](README.md). +> Эта страница — русская версия основной документации. Английский оригинал см. в [README.md](README.md). [![Crates.io](https://img.shields.io/crates/v/masterror)](https://crates.io/crates/masterror) [![docs.rs](https://img.shields.io/docsrs/masterror)](https://docs.rs/masterror) @@ -11,47 +11,74 @@ [![Security audit](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml/badge.svg?branch=main&label=Security%20audit)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) [![Cargo Deny](https://img.shields.io/github/actions/workflow/status/RAprogramm/masterror/ci.yml?branch=main&label=Cargo%20Deny)](https://github.com/RAprogramm/masterror/actions/workflows/ci.yml?query=branch%3Amain) -Небольшая прагматичная модель ошибок для Rust-сервисов с выраженным API и -встроенными деривами. -Основной крейт не зависит от веб-фреймворков, а расширения включаются через -фичи. Таксономия ошибок стабильна, соответствие HTTP-кодам консервативно, -`unsafe` запрещён. - -## Основные возможности - -- Базовые типы: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata`. -- Деривы `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, - `#[masterror(...)]`, `#[provide]` для типизированного телеметрического - контекста и прямых конверсий доменных ошибок. -- Адаптеры для Axum и Actix плюс логирование в браузер/`JsValue` для WASM (по - фичам). -- Генерация схем OpenAPI через `utoipa`. -- Структурированные поля `Metadata` через билдеры `field::*`. -- Конверсии из распространённых библиотек (`sqlx`, `reqwest`, `redis`, - `validator`, `config`, `tokio` и др.). -- Готовый прелюдия-модуль и расширение `turnkey` с собственной таксономией - ошибок. - -## Установка - -Добавьте зависимость в `Cargo.toml`: +`masterror` вырос из набора вспомогательных функций в полноценный workspace с +модульными крейтами для построения наблюдаемых и последовательных ошибок в +Rust-сервисах. Базовый крейт остаётся независимым от веб-фреймворков, а фичи +включают только нужные адаптеры, интеграции и телеметрию. `unsafe` запрещён, +MSRV зафиксирован, а родные деривы позволяют доменным типам управлять +редактированием сообщений и метаданными. + +## Ключевые возможности + +- **Единая таксономия.** `AppError`, `AppErrorKind` и `AppCode` описывают + доменные и транспортные аспекты, имеют консервативное соответствие HTTP/gRPC, + готовые подсказки retry/auth и RFC7807-ответы через `ProblemJson`. +- **Родные деривы.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]` и `#[provide]` соединяют ваши типы с `AppError`, пробрасывая + источники, бэктрейсы, телеметрию и политику редактирования. +- **Типизированная телеметрия.** `Metadata` хранит структурированные ключи и + значения с индивидуальными правилами маскирования, а билдеры `field::*` + избавляют от ручных `String`-карт. +- **Транспортные адаптеры.** Опциональные фичи включают респондеры для Actix/Axum, + конвертацию в `tonic::Status`, логирование в браузер/WASM и генерацию схем + OpenAPI без утяжеления дефолтной сборки. +- **Интеграции, проверенные в бою.** Активируйте маппинги для `sqlx`, `reqwest`, + `redis`, `validator`, `config`, `tokio`, `teloxide`, `multipart`, Telegram + WebApp SDK и других библиотек — каждая переводит ошибки в таксономию с + прикреплённой телеметрией. +- **Готовые настройки.** Модуль `turnkey` поставляет готовый каталог ошибок, + билдеры и интеграцию с `tracing` для команд, которым нужна стартовая + конфигурация «из коробки». + +## Состав workspace + +| Крейт | Что содержит | Когда подключать | +| --- | --- | --- | +| [`masterror`](https://crates.io/crates/masterror) | Основные типы ошибок, билдеры метаданных, транспорты, интеграции и прелюдия. | Боевые сервисы и библиотеки, которым нужна стабильная поверхность ошибок. | +| [`masterror-derive`](masterror-derive/README.md) | Процедурные макросы `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, `#[provide]`. | Уже идёт транзитивно; подключайте напрямую только для экспериментов с макросами. | +| [`masterror-template`](masterror-template/README.md) | Общий парсер шаблонов для анализа форматтеров в деривах. | Внутренний компонент; используйте, если нужен этот парсер в другом коде. | + +## Флаги фич + +Все фичи отключены по умолчанию — выбирайте только нужное. + +- **Веб и API:** `axum`, `actix`, `multipart`, `openapi`, `serde_json`. +- **Наблюдаемость:** `tracing`, `metrics`, `backtrace`. +- **Async и IO:** `tokio`, `reqwest`, `sqlx`, `sqlx-migrate`, `redis`, `validator`, + `config`. +- **Боты и мессенджеры:** `teloxide`, `telegram-webapp-sdk`. +- **Фронтенд:** `frontend` для логирования в браузере/WASM. +- **gRPC:** `tonic` для генерации `tonic::Status`. +- **Готовая таксономия:** `turnkey`. + +## TL;DR ~~~toml [dependencies] -# минимальное ядро -masterror = { version = "0.15.0", default-features = false } -# или с нужными интеграциями -# masterror = { version = "0.15.0", features = [ +masterror = { version = "0.20.5", default-features = false } +# или с нужными фичами: +# masterror = { version = "0.20.5", features = [ # "axum", "actix", "openapi", "serde_json", -# "sqlx", "sqlx-migrate", "reqwest", "redis", -# "validator", "config", "tokio", "multipart", -# "teloxide", "telegram-webapp-sdk", "frontend", "turnkey" +# "tracing", "metrics", "backtrace", "sqlx", +# "sqlx-migrate", "reqwest", "redis", "validator", +# "config", "tokio", "multipart", "teloxide", +# "telegram-webapp-sdk", "tonic", "frontend", "turnkey" # ] } ~~~ -**MSRV:** 1.90 +--- -## Быстрый старт +### Быстрый старт Создание ошибки вручную: @@ -78,29 +105,121 @@ fn do_work(flag: bool) -> AppResult<()> { } ~~~ -## Дополнительные интеграции - -- `sqlx` — классификация `sqlx::Error` по видам ошибок. -- `sqlx-migrate` — обработка `sqlx::migrate::MigrateError` как базы данных. -- `reqwest` — перевод сетевых/HTTP-сбоев в доменные категории. -- `redis` — корректная обработка ошибок кеша. -- `validator` — преобразование `ValidationErrors` в валидационные ошибки API. -- `config` — типизированные ошибки конфигурации. -- `tokio` — маппинг таймаутов (`tokio::time::error::Elapsed`). -- `metadata` — типизированные поля `Metadata` без аллокаций строк. -- `multipart` — обработка ошибок извлечения multipart в Axum. -- `teloxide` — маппинг `teloxide_core::RequestError` в доменные категории. -- `telegram-webapp-sdk` — обработка ошибок валидации данных Telegram WebApp. -- `tonic` — преобразование `AppError` в `tonic::Status` с учётом редактирования. -- `frontend` — логирование в браузере и преобразование в `JsValue` для WASM. -- `turnkey` — расширение таксономии для Turnkey SDK. - -## Атрибуты `#[provide]` и `#[app_error]` - -Атрибут `#[provide(...)]` позволяет передавать структурированную телеметрию через -`std::error::Request`, а `#[app_error(...)]` описывает прямой маппинг доменной -ошибки в `AppError` и `AppCode`. Дерив сохраняет синтаксис `thiserror`, но -дополняет его провайдерами телеметрии и готовыми конверсиями в типы `masterror`. +### Деривы для доменных ошибок и транспорта + +`masterror` предоставляет родные деривы, чтобы типы оставались выразительными, а +crate отвечал за конверсии, телеметрию и редактирование сообщений. + +~~~rust +use std::io; + +use masterror::Error; + +#[derive(Debug, Error)] +#[error("I/O failed: {source}")] +pub struct DomainError { + #[from] + #[source] + source: io::Error, +} + +#[derive(Debug, Error)] +#[error(transparent)] +pub struct WrappedDomainError( + #[from] + #[source] + DomainError +); + +fn load() -> Result<(), DomainError> { + Err(io::Error::other("disk offline").into()) +} + +let err = load().unwrap_err(); +assert_eq!(err.to_string(), "I/O failed: disk offline"); + +let wrapped = WrappedDomainError::from(err); +assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); +~~~ + +- `use masterror::Error;` подключает макрос дерива. +- `#[from]` автоматически реализует `From<...>` и проверяет форму враппера. +- `#[error(transparent)]` гарантирует корректную прокладку `Display`/`source`. +- `#[app_error(kind = ..., code = ..., message)]` сопоставляет ошибку с + `AppError`/`AppCode`; `code = ...` добавляет `From for AppCode`, а + `message` публикует форматированную строку вместо обезличенного текста. +- `masterror::error::template::ErrorTemplate` разбирает строки формата, позволяя + строить собственные деривы без зависимости от `thiserror`. + +### Телеметрия, редактирование и маппинги транспортов + +`#[derive(Masterror)]` преобразует доменную ошибку в [`masterror::Error`], +прикрепляя метаданные, политику редактирования и маппинги для HTTP/gRPC/RFC7807. + +~~~rust +use masterror::{ + mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy +}; + +#[derive(Debug, Masterror)] +#[error("user {user_id} missing flag {flag}")] +#[masterror( + code = AppCode::NotFound, + category = AppErrorKind::NotFound, + message, + redact(message, fields("user_id" = hash)), + telemetry( + Some(masterror::field::str("user_id", user_id.clone())), + attempt.map(|value| masterror::field::u64("attempt", value)) + ), + map.grpc = 5, + map.problem = "https://errors.example.com/not-found" +)] +struct MissingFlag { + user_id: String, + flag: &'static str, + attempt: Option, + #[source] + source: Option +} + +let err = MissingFlag { + user_id: "alice".into(), + flag: "beta", + attempt: Some(2), + source: None +}; +let converted: Error = err.into(); +assert_eq!(converted.code, AppCode::NotFound); +assert_eq!(converted.kind, AppErrorKind::NotFound); +assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); +assert!(converted.metadata().get("user_id").is_some()); + +assert_eq!( + MissingFlag::HTTP_MAPPING, + HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) +); +~~~ + +- `code` / `category` задают публичный [`AppCode`] и внутренний + [`AppErrorKind`]. +- `message` публикует форматированную строку как безопасное сообщение. +- `redact(message)` включает редактирование, а `fields("name" = hash)` задаёт + правила маскирования для метаданных. +- `telemetry(...)` принимает выражения, дающие `Option`; заполненные поля + попадают в [`Metadata`]. +- `map.grpc` / `map.problem` добавляют gRPC-код и RFC7807 `type` URI. Дерив + генерирует таблицы `HTTP_MAPPING`, `GRPC_MAPPING`, `PROBLEM_MAPPING`. + +Все атрибуты уровня полей (`#[from]`, `#[source]`, `#[backtrace]`) продолжают +работать. Источники и бэктрейсы автоматически прикрепляются к +[`masterror::Error`]. + +### Провайдеры телеметрии и `AppError` + +`#[provide(...)]` раскрывает структурированную телеметрию через +`std::error::Request`, а `#[app_error(...)]` описывает конверсию в `AppError` и +`AppCode`. ~~~rust use std::error::request_ref; @@ -136,8 +255,8 @@ let via_app = request_ref::(&app).expect("telemetry"); assert_eq!(via_app.name, "db.query"); ~~~ -Опциональные поля автоматически пропускаются, если значения нет. При запросе -значения `Option` можно вернуть как по ссылке, так и передать владение: +Опциональная телеметрия не регистрирует провайдер, если значение `None`, а +владение можно передать через `value = ...`. ~~~rust use masterror::{AppCode, AppErrorKind, Error}; @@ -162,8 +281,7 @@ assert!(request_ref::(&noisy).is_some()); assert!(request_ref::(&silent).is_none()); ~~~ -Для перечислений каждая ветка может задавать собственную телеметрию и -конверсию. Дерив сгенерирует единый `From` для `AppError`/`AppCode`: +Перечисления поддерживают собственные маппинги и провайдеры на вариант: ~~~rust #[derive(Debug, Error)] @@ -191,252 +309,34 @@ let app: AppError = owned.into(); assert!(matches!(app.kind, AppErrorKind::Service)); ~~~ -В отличие от `thiserror`, вы получаете дополнительную структурированную -информацию и прямой маппинг в `AppError`/`AppCode` без ручных реализаций -`From`. - -## `#[derive(Masterror)]` и атрибут `#[masterror(...)]` - -Когда нужно сразу получить [`masterror::Error`], используйте `#[derive(Masterror)]`. -Атрибут `#[masterror(...)]` описывает код, категорию, телеметрию, политику -редактирования и транспортные подсказки: - -~~~rust -use masterror::{ - mapping::HttpMapping, AppCode, AppErrorKind, Error, Masterror, MessageEditPolicy -}; - -#[derive(Debug, Masterror)] -#[error("пользователь {user_id} без флага {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, - #[source] - source: Option, -} - -let err = MissingFlag { - user_id: "alice".into(), - flag: "beta", - attempt: Some(2), - source: None, -}; -let converted: Error = err.into(); -assert_eq!(converted.code, AppCode::NotFound); -assert_eq!(converted.kind, AppErrorKind::NotFound); -assert_eq!(converted.edit_policy, MessageEditPolicy::Redact); -assert!(converted.metadata().get("user_id").is_some()); - -assert_eq!( - MissingFlag::HTTP_MAPPING, - HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) -); -~~~ - -- `code` / `category` задают публичный [`AppCode`] и внутренний - [`AppErrorKind`]. -- `message` включает текст, возвращаемый [`Display`], в публичное сообщение. -- `redact(message)` выставляет [`MessageEditPolicy`] в режим редактирования на - транспортной границе, `fields("name" = hash, "card" = last4)` переопределяет - обработку метаданных (`hash`, `last4`, `redact`, `none`). -- `telemetry(...)` принимает выражения, возвращающие - `Option`. Каждое присутствующее поле добавляется в - [`Metadata`]; пустые выражения пропускаются. -- `map.grpc` / `map.problem` позволяют зафиксировать код gRPC (целое `i32`) и - URI для problem+json. Дерив генерирует таблицы `TYPE::HTTP_MAPPING`, - `TYPE::GRPC_MAPPING` и `TYPE::PROBLEM_MAPPING` (или срезы для перечислений) - для дальнейшей интеграции. - -Атрибуты `#[from]`, `#[source]`, `#[backtrace]` продолжают работать: источники и -бектрейсы автоматически прикрепляются к результирующему [`masterror::Error`]. - -## Форматирование шаблонов `#[error]` - -Шаблон `#[error("...")]` по умолчанию использует `Display`, но любая -подстановка может запросить другой форматтер. -`TemplateFormatter::is_alternate()` фиксирует флаг `#`, а `TemplateFormatterKind` -сообщает, какой трейт `core::fmt` нужен, поэтому порождённый код может -переключаться между вариантами без ручного `match`. Неподдержанные спецификаторы -приводят к диагностике на этапе компиляции, совпадающей с `thiserror`. - -| Спецификатор | Трейт | Пример результата | Примечания | -|------------------|-------------------------|--------------------------|------------| -| _по умолчанию_ | `core::fmt::Display` | `value` | Пользовательские сообщения; `#` игнорируется. | -| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / многострочный | Поведение `Debug`; `#` включает pretty-print. | -| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Шестнадцатеричный вывод; `#` добавляет `0x`. | -| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Верхний регистр; `#` добавляет `0x`. | -| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Сырые указатели; `#` поддерживается для совместимости. | -| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Двоичный вывод; `#` добавляет `0b`. | -| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Восьмеричный вывод; `#` добавляет `0o`. | -| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Научная запись; `#` заставляет выводить десятичную точку. | -| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Верхний регистр научной записи; `#` заставляет выводить точку. | - -- `TemplateFormatterKind::supports_alternate()` сообщает, имеет ли смысл `#` для - выбранного трейта (для указателей вывод совпадает с обычным). -- `TemplateFormatterKind::specifier()` возвращает канонический символ - спецификатора, что упрощает повторный рендеринг плейсхолдеров. -- `TemplateFormatter::from_kind(kind, alternate)` собирает форматтер из - `TemplateFormatterKind`, позволяя программно переключать флаг `#`. -- Display-плейсхолдеры сохраняют исходные параметры форматирования: - методы `TemplateFormatter::display_spec()` и - `TemplateFormatter::format_fragment()` возвращают `:>8`, `:.3` и другие - варианты без необходимости собирать строку вручную. - -~~~rust -use core::ptr; - -use masterror::Error; - -#[derive(Debug, Error)] -#[error( - "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ - oct={mask:o}, lower={ratio:e}, upper={ratio:E}" -)] -struct FormatterDemo { - id: u32, - payload: String, - ptr: *const u8, - mask: u8, - ratio: f32, -} - -let err = FormatterDemo { - id: 0x2a, - payload: "hello".into(), - ptr: ptr::null(), - mask: 0b1010_0001, - ratio: 0.15625, -}; - -let rendered = err.to_string(); -assert!(rendered.contains("debug=\"hello\"")); -assert!(rendered.contains("hex=0x2a")); -assert!(rendered.contains("ptr=0x0")); -assert!(rendered.contains("bin=0b10100001")); -assert!(rendered.contains("oct=241")); -assert!(rendered.contains("lower=1.5625e-1")); -assert!(rendered.contains("upper=1.5625E-1")); -~~~ +Так вы сохраняете знакомый интерфейс `thiserror`, но получаете телеметрию и +готовые конверсии в `AppError`/`AppCode` без ручного кода. -`masterror::error::template::ErrorTemplate` позволяет разобрать шаблон и -программно проверить запрошенные форматтеры; перечисление -`TemplateFormatterKind` возвращает название трейта для каждого плейсхолдера: +### Problem JSON и подсказки retry/auth ~~~rust -use masterror::error::template::{ - ErrorTemplate, TemplateFormatter, TemplateFormatterKind -}; +use masterror::{AppError, AppErrorKind, ProblemJson}; +use std::time::Duration; -let template = ErrorTemplate::parse("{code:#x} → {payload:?}").expect("parse"); -let mut placeholders = template.placeholders(); - -let code = placeholders.next().expect("code placeholder"); -let code_formatter = code.formatter(); -assert!(matches!( - code_formatter, - TemplateFormatter::LowerHex { alternate: true } -)); -let code_kind = code_formatter.kind(); -assert_eq!(code_kind, TemplateFormatterKind::LowerHex); -assert!(code_formatter.is_alternate()); -assert_eq!(code_kind.specifier(), Some('x')); -assert!(code_kind.supports_alternate()); -let lowered = TemplateFormatter::from_kind(code_kind, false); -assert!(matches!( - lowered, - TemplateFormatter::LowerHex { alternate: false } -)); - -let payload = placeholders.next().expect("payload placeholder"); -let payload_formatter = payload.formatter(); -assert_eq!( - payload_formatter, - &TemplateFormatter::Debug { alternate: false } +let problem = ProblemJson::from_app_error( + AppError::new(AppErrorKind::Unauthorized, "Token expired") + .with_retry_after_duration(Duration::from_secs(30)) + .with_www_authenticate(r#"Bearer realm="api", error="invalid_token""#) ); -let payload_kind = payload_formatter.kind(); -assert_eq!(payload_kind, TemplateFormatterKind::Debug); -assert_eq!(payload_kind.specifier(), Some('?')); -assert!(payload_kind.supports_alternate()); -let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); -assert!(matches!( - pretty_debug, - TemplateFormatter::Debug { alternate: true } -)); -assert!(pretty_debug.is_alternate()); -~~~ -Опции выравнивания, точности и заполнения для `Display` сохраняются и доступны -для прямой передачи в `write!`: - -~~~rust -use masterror::error::template::ErrorTemplate; - -let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); -let display = aligned.placeholders().next().expect("display placeholder"); -assert_eq!(display.formatter().display_spec(), Some(">8")); -assert_eq!( - display - .formatter() - .format_fragment() - .as_deref(), - Some(">8") -); +assert_eq!(problem.status, 401); +assert_eq!(problem.retry_after, Some(30)); +assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~ -Динамические ширина и точность (`{value:>width$}`, `{value:.precision$}`) -тоже доходят до вызова `write!`, если объявить соответствующие аргументы в -атрибуте `#[error(...)]`: - -~~~rust -use masterror::Error; - -#[derive(Debug, Error)] -#[error("{value:>width$}", value = .value, width = .width)] -struct DynamicWidth { - value: &'static str, - width: usize, -} - -#[derive(Debug, Error)] -#[error("{value:.precision$}", value = .value, precision = .precision)] -struct DynamicPrecision { - value: f64, - precision: usize, -} - -let width = DynamicWidth { - value: "x", - width: 5, -}; -let precision = DynamicPrecision { - value: 123.456_f64, - precision: 4, -}; - -assert_eq!(width.to_string(), format!("{value:>width$}", value = "x", width = 5)); -assert_eq!( - precision.to_string(), - format!("{value:.precision$}", value = 123.456_f64, precision = 4) -); -~~~ +### Дополнительные материалы -> **Совместимость с `thiserror` v2.** Доступные спецификаторы, сообщения об -> ошибках и поведение совпадают с `thiserror` 2.x, поэтому миграция с -> `thiserror::Error` на `masterror::Error` не требует переписывать шаблоны. +- [Вики по обработке ошибок](docs/wiki/index.md) с пошаговыми руководствами, + сравнением `thiserror`/`anyhow` и рецептами устранения проблем. +- [Документация на docs.rs](https://docs.rs/masterror) с подробными таблицами по + фичам и транспортам. +- [`CHANGELOG.md`](CHANGELOG.md) для истории релизов и миграций. -## Лицензия +--- -Проект распространяется по лицензии Apache-2.0 или MIT на ваш выбор. +MSRV: **1.90** · Лицензия: **MIT OR Apache-2.0** · Без `unsafe` diff --git a/README.template.md b/README.template.md index 1095ee8..1f743b3 100644 --- a/README.template.md +++ b/README.template.md @@ -14,25 +14,58 @@ > 🇷🇺 Читайте README на [русском языке](README.ru.md). -Small, pragmatic error model for API-heavy Rust services with native derives -and typed telemetry. -Core is framework-agnostic; integrations are opt-in via feature flags. -Stable categories, conservative HTTP mapping, no `unsafe`. - -- Core types: `AppError`, `AppErrorKind`, `AppResult`, `AppCode`, `ProblemJson`, `ErrorResponse`, `Metadata` -- Derive macros: `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, - `#[masterror(...)]`, `#[provide]` for domain mappings and structured - telemetry -- Optional Axum/Actix integration and browser/WASM console logging -- Optional OpenAPI schema (via `utoipa`) -- Structured metadata helpers via `field::*` builders -- Conversions from `sqlx`, `reqwest`, `redis`, `validator`, `config`, `tokio` -- Turnkey domain taxonomy and helpers (`turnkey` feature) - -👉 Explore the new [error-handling wiki](docs/wiki/index.md) for step-by-step -guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. - ---- +`masterror` grew from a handful of helpers into a workspace of composable crates for +building consistent, observable error surfaces across Rust services. The core +crate stays framework-agnostic, while feature flags light up transport adapters, +integrations and telemetry without pulling in heavyweight defaults. No +`unsafe`, MSRV is pinned, and the derive macros keep your domain types in charge +of redaction and metadata. + +### Highlights + +- **Unified taxonomy.** `AppError`, `AppErrorKind` and `AppCode` model domain and + transport concerns with conservative HTTP/gRPC mappings, turnkey retry/auth + hints and RFC7807 output via `ProblemJson`. +- **Native derives.** `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]`, + `#[masterror(...)]` and `#[provide]` wire custom types into `AppError` while + forwarding sources, backtraces, telemetry providers and redaction policy. +- **Typed telemetry.** `Metadata` stores structured key/value context with + per-field redaction controls and builders in `field::*`, so logs stay + structured without manual `String` maps. +- **Transport adapters.** Optional features expose Actix/Axum responders, + `tonic::Status` conversions, WASM/browser logging and OpenAPI schema + generation without contaminating the lean default build. +- **Battle-tested integrations.** Enable focused mappings for `sqlx`, + `reqwest`, `redis`, `validator`, `config`, `tokio`, `teloxide`, `multipart`, + Telegram WebApp SDK and more — each translating library errors into the + taxonomy with telemetry attached. +- **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, + helper builders and tracing instrumentation for teams that want a consistent + baseline out of the box. + +### Workspace crates + +| Crate | What it provides | When to depend on it | +| --- | --- | --- | +| [`masterror`](https://crates.io/crates/masterror) | Core error types, metadata builders, transports, integrations and the prelude. | Application crates, services and libraries that want a stable error surface. | +| [`masterror-derive`](masterror-derive/README.md) | Proc-macros backing `#[derive(Error)]`, `#[derive(Masterror)]`, `#[app_error]` and `#[provide]`. | Brought in automatically via `masterror`; depend directly only for macro hacking. | +| [`masterror-template`](masterror-template/README.md) | Shared template parser used by the derive macros for formatter analysis. | Internal dependency; reuse when you need the template parser elsewhere. | + +### Feature flags at a glance + +Pick only what you need; everything is off by default. + +- **Web transports:** `axum`, `actix`, `multipart`, `openapi`, `serde_json`. +- **Telemetry & observability:** `tracing`, `metrics`, `backtrace`. +- **Async & IO integrations:** `tokio`, `reqwest`, `sqlx`, `sqlx-migrate`, + `redis`, `validator`, `config`. +- **Messaging & bots:** `teloxide`, `telegram-webapp-sdk`. +- **Front-end tooling:** `frontend` for WASM/browser console logging. +- **gRPC:** `tonic` to emit `tonic::Status` responses. +- **Batteries included:** `turnkey` to adopt the pre-built taxonomy and helpers. + +The build script keeps the full feature snippet below in sync with +`Cargo.toml`. ### TL;DR @@ -45,48 +78,8 @@ masterror = { version = "{{CRATE_VERSION}}", default-features = false } # ] } ~~~ -*Since v0.5.0: derive custom errors via `#[derive(Error)]` (`use masterror::Error;`) and inspect browser logging failures with `BrowserConsoleError::context()`.* -*Since v0.4.0: optional `frontend` feature for WASM/browser console logging.* -*Since v0.3.0: stable `AppCode` enum and extended `ErrorResponse` with retry/authentication metadata.* -*Since v0.15.0: RFC7807 `ProblemJson` responses for HTTP integrations and `tonic::Status` conversion.* - --- -
- 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.** `ProblemJson { type?, title, status, detail?, code, grpc?, metadata? }` with `Retry-After` / `WWW-Authenticate` headers when present. -- **Typed telemetry.** `Metadata` preserves structured key/value context without `String` maps. -- **One log at boundary.** Log once with `tracing`. -- **Less boilerplate.** Built-in conversions, compact prelude, and the - native `masterror::Error` derive with `#[from]` / `#[error(transparent)]` - support. -- **Consistent workspace.** Same error surface across crates. - -
- -
- Installation - -~~~toml -[dependencies] -# lean core -masterror = { version = "{{CRATE_VERSION}}", default-features = false } - -# with Axum/Actix + JSON + integrations -# masterror = { version = "{{CRATE_VERSION}}", features = [ -{{FEATURE_SNIPPET}} -# ] } -~~~ - -**MSRV:** {{MSRV}} -**No unsafe:** forbidden by crate. - -
-
Quick start @@ -118,7 +111,10 @@ fn do_work(flag: bool) -> AppResult<()> {
- Derive custom errors + Derive domain errors and map them to transports + +`masterror` ships native derives so your domain types stay expressive while the +crate handles conversions, telemetry and redaction for you. ~~~rust use std::io; @@ -152,7 +148,7 @@ let wrapped = WrappedDomainError::from(err); assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); ~~~ -- `use masterror::Error;` brings the crate's derive macro into scope. +- `use masterror::Error;` brings the derive macro into scope. - `#[from]` automatically implements `From<...>` while ensuring wrapper shapes are valid. - `#[error(transparent)]` enforces single-field wrappers that forward @@ -175,12 +171,15 @@ assert_eq!(wrapped.to_string(), "I/O failed: disk offline"); placeholder, making it easy to branch on the requested rendering behaviour without manually matching every enum variant. -#### `#[derive(Masterror)]` and `#[masterror(...)]` +
+ +
+ Attach telemetry, redaction policy and conversions -`#[derive(Masterror)]` wires a domain error directly into [`masterror::Error`], -augmenting it with metadata, redaction policy and optional transport mappings. -The accompanying `#[masterror(...)]` attribute mirrors the `#[app_error]` -syntax while remaining explicit about telemetry: +`#[derive(Masterror)]` wires a domain error into [`masterror::Error`], adds +metadata, redaction policy and optional transport mappings. The accompanying +`#[masterror(...)]` attribute mirrors the `#[app_error]` syntax while staying +explicit about telemetry and redaction. ~~~rust use masterror::{ @@ -247,103 +246,10 @@ All familiar field-level attributes (`#[from]`, `#[source]`, `#[backtrace]`) are still honoured. Sources and backtraces are automatically attached to the generated [`masterror::Error`]. -#### Display shorthand projections - -`#[error("...")]` supports the same shorthand syntax as `thiserror` for -referencing fields with `.field` or `.0`. The derive now understands chained -segments, so projections like `.limits.lo`, `.0.data` or -`.suggestion.as_ref().map_or_else(...)` keep compiling unchanged. Raw -identifiers and tuple indices are preserved, ensuring keywords such as -`r#type` and tuple fields continue to work even when you call methods on the -projected value. - -~~~rust -use masterror::Error; - -#[derive(Debug)] -struct Limits { - lo: i32, - hi: i32, -} - -#[derive(Debug, Error)] -#[error( - "range {lo}-{hi} suggestion {suggestion}", - lo = .limits.lo, - hi = .limits.hi, - suggestion = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) -)] -struct RangeError { - limits: Limits, - suggestion: Option, -} - -#[derive(Debug)] -struct Payload { - data: &'static str, -} - -#[derive(Debug, Error)] -enum UiError { - #[error("tuple data {data}", data = .0.data)] - Tuple(Payload), - #[error( - "named suggestion {value}", - value = .suggestion.as_ref().map_or_else(|| "", |s| s.as_str()) - )] - Named { suggestion: Option }, -} -~~~ - -#### AppError conversions - -Annotating structs or enum variants with `#[app_error(...)]` captures the -metadata required to convert the domain error into `AppError` (and optionally -`AppCode`). Every variant in an enum must provide the mapping when any variant -requests it. - -~~~rust -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)); -assert_eq!(app.message.as_deref(), Some("missing flag: feature")); - -let code: AppCode = MissingFlag { name: "feature" }.into(); -assert!(matches!(code, AppCode::BadRequest)); -~~~ - -For enums, each variant specifies the mapping while the derive generates a -single `From` implementation that matches every variant: - -~~~rust -#[derive(Debug, Error)] -enum ApiError { - #[error("missing resource {id}")] - #[app_error( - kind = AppErrorKind::NotFound, - code = AppCode::NotFound, - message - )] - Missing { id: u64 }, - #[error("backend unavailable")] - #[app_error(kind = AppErrorKind::Service, code = AppCode::Service)] - Backend, -} - -let missing = ApiError::Missing { id: 7 }; -let as_app: AppError = missing.into(); -assert_eq!(as_app.message.as_deref(), Some("missing resource 7")); -~~~ +
-#### Structured telemetry providers and AppError mappings +
+ Structured telemetry providers and AppError mappings `#[provide(...)]` exposes typed context through `std::error::Request`, while `#[app_error(...)]` records how your domain error translates into `AppError` @@ -444,173 +350,12 @@ assert!(matches!(app.kind, AppErrorKind::Service)); Compared to `thiserror`, you retain the familiar deriving surface while gaining structured telemetry (`#[provide]`) and first-class conversions into -`AppError`/`AppCode` without writing manual `From` implementations. - -#### Formatter traits - -Placeholders default to `Display` (`{value}`) but can opt into richer -formatters via the same specifiers supported by `thiserror` v2. -`TemplateFormatter::is_alternate()` tracks the `#` flag, while -`TemplateFormatterKind` exposes the underlying `core::fmt` trait so derived -code can branch on the requested renderer without manual pattern matching. -Unsupported formatters surface a compile error that mirrors `thiserror`'s -diagnostics. - -| Specifier | `core::fmt` trait | Example output | Notes | -|------------------|----------------------------|------------------------|-------| -| _default_ | `core::fmt::Display` | `value` | User-facing strings; `#` has no effect. | -| `:?` / `:#?` | `core::fmt::Debug` | `Struct { .. }` / multi-line | Mirrors `Debug`; `#` pretty-prints structs. | -| `:x` / `:#x` | `core::fmt::LowerHex` | `0x2a` | Hexadecimal; `#` prepends `0x`. | -| `:X` / `:#X` | `core::fmt::UpperHex` | `0x2A` | Uppercase hex; `#` prepends `0x`. | -| `:p` / `:#p` | `core::fmt::Pointer` | `0x1f00` / `0x1f00` | Raw pointers; `#` is accepted for compatibility. | -| `:b` / `:#b` | `core::fmt::Binary` | `101010` / `0b101010` | Binary; `#` prepends `0b`. | -| `:o` / `:#o` | `core::fmt::Octal` | `52` / `0o52` | Octal; `#` prepends `0o`. | -| `:e` / `:#e` | `core::fmt::LowerExp` | `1.5e-2` | Scientific notation; `#` forces the decimal point. | -| `:E` / `:#E` | `core::fmt::UpperExp` | `1.5E-2` | Uppercase scientific; `#` forces the decimal point. | - -- `TemplateFormatterKind::supports_alternate()` reports whether the `#` flag is - meaningful for the requested trait (pointer accepts it even though the output - matches the non-alternate form). -- `TemplateFormatterKind::specifier()` returns the canonical format specifier - character when one exists, enabling custom derives to re-render placeholders - in their original style. -- `TemplateFormatter::from_kind(kind, alternate)` reconstructs a formatter from - the lightweight `TemplateFormatterKind`, making it easy to toggle the - alternate flag in generated code. - -~~~rust -use core::ptr; - -use masterror::Error; - -#[derive(Debug, Error)] -#[error( - "debug={payload:?}, hex={id:#x}, ptr={ptr:p}, bin={mask:#b}, \ - oct={mask:o}, lower={ratio:e}, upper={ratio:E}" -)] -struct FormattedError { - id: u32, - payload: String, - ptr: *const u8, - mask: u8, - ratio: f32, -} - -let err = FormattedError { - id: 0x2a, - payload: "hello".into(), - ptr: ptr::null(), - mask: 0b1010_0001, - ratio: 0.15625, -}; - -let rendered = err.to_string(); -assert!(rendered.contains("debug=\"hello\"")); -assert!(rendered.contains("hex=0x2a")); -assert!(rendered.contains("ptr=0x0")); -assert!(rendered.contains("bin=0b10100001")); -assert!(rendered.contains("oct=241")); -assert!(rendered.contains("lower=1.5625e-1")); -assert!(rendered.contains("upper=1.5625E-1")); -~~~ - -~~~rust -use masterror::error::template::{ - ErrorTemplate, TemplateFormatter, TemplateFormatterKind -}; - -let template = ErrorTemplate::parse("{code:#x} → {payload:?}").expect("parse"); -let mut placeholders = template.placeholders(); - -let code = placeholders.next().expect("code placeholder"); -let code_formatter = code.formatter(); -assert!(matches!( - code_formatter, - TemplateFormatter::LowerHex { alternate: true } -)); -let code_kind = code_formatter.kind(); -assert_eq!(code_kind, TemplateFormatterKind::LowerHex); -assert!(code_formatter.is_alternate()); -assert_eq!(code_kind.specifier(), Some('x')); -assert!(code_kind.supports_alternate()); -let lowered = TemplateFormatter::from_kind(code_kind, false); -assert!(matches!( - lowered, - TemplateFormatter::LowerHex { alternate: false } -)); - -let payload = placeholders.next().expect("payload placeholder"); -let payload_formatter = payload.formatter(); -assert_eq!( - payload_formatter, - &TemplateFormatter::Debug { alternate: false } -); -let payload_kind = payload_formatter.kind(); -assert_eq!(payload_kind, TemplateFormatterKind::Debug); -assert_eq!(payload_kind.specifier(), Some('?')); -assert!(payload_kind.supports_alternate()); -let pretty_debug = TemplateFormatter::from_kind(payload_kind, true); -assert!(matches!( - pretty_debug, - TemplateFormatter::Debug { alternate: true } -)); -assert!(pretty_debug.is_alternate()); -~~~ - -Display-only format specs (alignment, precision, fill — including `#` as a fill -character) are preserved so you can forward them to `write!` without rebuilding -the fragment: - -~~~rust -use masterror::error::template::ErrorTemplate; - -let aligned = ErrorTemplate::parse("{value:>8}").expect("parse"); -let display = aligned.placeholders().next().expect("display placeholder"); -assert_eq!(display.formatter().display_spec(), Some(">8")); -assert_eq!( - display - .formatter() - .format_fragment() - .as_deref(), - Some(">8") -); - -let hashed = ErrorTemplate::parse("{value:#>4}").expect("parse"); -let hash_placeholder = hashed - .placeholders() - .next() - .expect("hash-fill display placeholder"); -assert_eq!(hash_placeholder.formatter().display_spec(), Some("#>4")); -assert_eq!( - hash_placeholder - .formatter() - .format_fragment() - .as_deref(), - Some("#>4") -); -~~~ - -> **Compatibility with `thiserror` v2:** the derive understands the extended -> formatter set introduced in `thiserror` 2.x and reports identical diagnostics -> for unsupported specifiers, so migrating existing derives is drop-in. - -```rust -use masterror::error::template::{ErrorTemplate, TemplateIdentifier}; - -let template = ErrorTemplate::parse("{code}: {message}").expect("parse"); -let display = template.display_with(|placeholder, f| match placeholder.identifier() { - TemplateIdentifier::Named("code") => write!(f, "{}", 404), - TemplateIdentifier::Named("message") => f.write_str("Not Found"), - _ => Ok(()), -}); - -assert_eq!(display.to_string(), "404: Not Found"); -``` +`AppError`/`AppCode` without manual glue.
- Error response payload + Problem JSON payloads and retry/authentication hints ~~~rust use masterror::{AppError, AppErrorKind, ProblemJson}; @@ -629,143 +374,14 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED");
-
- Web framework integrations - -
- Axum - -~~~rust -// features = ["axum", "serde_json"] -... - assert!(payload.is_object()); - - #[cfg(target_arch = "wasm32")] - { - if let Err(console_err) = err.log_to_browser_console() { - eprintln!( - "failed to log to browser console: {:?}", - console_err.context() - ); - } - } - - Ok(()) -} -~~~ - -- On non-WASM targets `log_to_browser_console` returns - `BrowserConsoleError::UnsupportedTarget`. -- `BrowserConsoleError::context()` exposes optional browser diagnostics for - logging/telemetry when console logging fails. - -
- -
- -
- Feature flags - -{{FEATURE_BULLETS}} - -
- -
- Conversions - -{{CONVERSION_BULLETS}} - -
- -
- Typical setups - -Minimal core: - -~~~toml -masterror = { version = "{{CRATE_VERSION}}", default-features = false } -~~~ - -API (Axum + JSON + deps): - -~~~toml -masterror = { version = "{{CRATE_VERSION}}", features = [ - "axum", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -API (Actix + JSON + deps): - -~~~toml -masterror = { version = "{{CRATE_VERSION}}", features = [ - "actix", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -
- -
- Turnkey - -~~~rust -// 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 +### Further resources -Semantic versioning. Breaking API/wire contract → major bump. -MSRV = {{MSRV}} (may raise in minor, never in patch). +- Explore the [error-handling wiki](docs/wiki/index.md) for step-by-step guides, + comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. +- Browse the [crate documentation on docs.rs](https://docs.rs/masterror) for API + details, feature-specific guides and transport tables. +- Check [`CHANGELOG.md`](CHANGELOG.md) for release highlights and migration notes. -
- -
- Release checklist - -1. `cargo +nightly fmt --` -1. `cargo clippy -- -D warnings` -1. `cargo test --all` -1. `cargo build` (regenerates README.md from the template) -1. `cargo doc --no-deps` -1. `cargo package --locked` - -
- -
- 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. +--- -
+MSRV: **{{MSRV}}** · License: **MIT OR Apache-2.0** · No `unsafe` From 533e86c95883c26b587b186088ca9fd6bcb53f6a Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 12:46:47 +0700 Subject: [PATCH 20/25] Fix tonic conversion clippy warnings --- CHANGELOG.md | 11 ++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 +++++----- src/app_error/tests.rs | 13 +++------- src/convert/tonic.rs | 58 ++++++++++++++++++++++++++++++++++-------- src/lib.rs | 4 +++ 7 files changed, 75 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf24aca..bcfb589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.5] - 2025-10-05 + +### Fixed +- Promoted the gRPC converter to an infallible `From` implementation + while retaining the `TryFrom` API via the new documented + `StatusConversionError`, satisfying Clippy's infallible conversion lint. +- Collapsed nested metadata guards in the Tonic adapter and reused borrowed + booleans to silence Clippy without regressing runtime behaviour. +- Simplified the `AppResult` alias test to avoid large `Err` variant warnings + from Clippy's `result_large_err` lint. + ## [0.20.4] - 2025-10-04 ### Added diff --git a/Cargo.lock b/Cargo.lock index 92e21e8..d6d73a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.4" +version = "0.20.5" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 641a46b..9fd4521 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.4" +version = "0.20.5" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 9cbec41..684ce25 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.20.4", default-features = false } +masterror = { version = "0.20.5", default-features = false } # or with features: -# masterror = { version = "0.20.4", features = [ +# masterror = { version = "0.20.5", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.20.4", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.20.4", default-features = false } +masterror = { version = "0.20.5", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.20.4", features = [ +# masterror = { version = "0.20.5", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.20.4", default-features = false } +masterror = { version = "0.20.5", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.20.4", features = [ +masterror = { version = "0.20.5", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.20.4", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.20.4", features = [ +masterror = { version = "0.20.5", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 69be3b7..04fdf9d 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -537,14 +537,9 @@ fn metrics_counter_is_incremented_once() { #[test] fn result_alias_is_generic() { - fn app() -> super::AppResult { - Ok(1) - } - - fn other() -> super::AppResult { - Ok(2) - } + let default_result: super::AppResult = Ok(1); + let custom_result: super::AppResult = Ok(2); - assert_eq!(app().unwrap(), 1); - assert_eq!(other().unwrap(), 2); + assert_eq!(default_result.unwrap(), 1); + assert_eq!(custom_result.unwrap(), 2); } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 9328132..07e4b87 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -14,11 +14,11 @@ //! ```rust,ignore //! use masterror::{AppError, AppErrorKind}; //! -//! let status = tonic::Status::try_from(AppError::not_found("missing"))?; +//! let status = tonic::Status::from(AppError::not_found("missing")); //! assert_eq!(status.code(), tonic::Code::NotFound); //! ``` -use std::{borrow::Cow, convert::Infallible}; +use std::{borrow::Cow, fmt}; use tonic::{ Code, Status, @@ -32,11 +32,49 @@ use crate::{ mapping_for_code }; +/// Error returned when converting [`Error`] into [`Status`] fails. +/// +/// This type is never constructed in practice because the conversion is +/// guaranteed to succeed. It exists solely to preserve the `TryFrom` API in +/// addition to the infallible [`From`] conversion. +/// +/// # Examples +/// ```rust,ignore +/// use masterror::{AppError, StatusConversionError}; +/// use tonic::{Code, Status}; +/// +/// fn convert() -> Result { +/// Status::try_from(AppError::not_found("missing")) +/// } +/// +/// # fn main() -> Result<(), StatusConversionError> { +/// let status = convert()?; +/// assert_eq!(status.code(), Code::NotFound); +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StatusConversionError; + +impl fmt::Display for StatusConversionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("conversion to tonic::Status cannot fail") + } +} + +impl std::error::Error for StatusConversionError {} + +impl From for Status { + fn from(error: Error) -> Self { + status_from_error(&error) + } +} + impl TryFrom for Status { - type Error = Infallible; + type Error = StatusConversionError; fn try_from(error: Error) -> Result { - Ok(status_from_error(&error)) + Ok(Status::from(error)) } } @@ -59,10 +97,10 @@ fn status_from_error(error: &Error) -> Status { if let Some(advice) = error.retry { insert_retry(&mut meta, advice); } - if let Some(challenge) = error.www_authenticate.as_deref() { - if is_ascii_metadata_value(challenge) { - insert_ascii(&mut meta, "www-authenticate", challenge); - } + if let Some(challenge) = error.www_authenticate.as_deref() + && is_ascii_metadata_value(challenge) + { + insert_ascii(&mut meta, "www-authenticate", challenge); } if !matches!(error.edit_policy, MessageEditPolicy::Redact) { @@ -123,9 +161,7 @@ fn metadata_value_to_ascii(value: &FieldValue) -> Option> { } FieldValue::I64(value) => Some(Cow::Owned(value.to_string())), FieldValue::U64(value) => Some(Cow::Owned(value.to_string())), - FieldValue::Bool(value) => Some(Cow::Owned( - if *value { "true" } else { "false" }.to_string() - )), + FieldValue::Bool(value) => Some(Cow::Borrowed(if *value { "true" } else { "false" })), FieldValue::Uuid(value) => Some(Cow::Owned(value.to_string())) } } diff --git a/src/lib.rs b/src/lib.rs index 1bcac14..956876b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -363,3 +363,7 @@ pub use response::{ } }; pub use result_ext::ResultExt; + +#[cfg(feature = "tonic")] +#[cfg_attr(docsrs, doc(cfg(feature = "tonic")))] +pub use crate::convert::tonic::StatusConversionError; From d7251805a0e4387435a671b412a608ca853a1897 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 13:18:23 +0700 Subject: [PATCH 21/25] Fix tonic status conversion for Rust 1.90 --- CHANGELOG.md | 7 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 142 +------------------------------------------ src/convert.rs | 3 + src/convert/tonic.rs | 45 ++++---------- src/lib.rs | 2 +- 7 files changed, 29 insertions(+), 174 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91611e9..1278292 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.6] - 2025-10-06 + +### Fixed +- Restored compilation on Rust 1.90+ by aliasing the infallible gRPC + conversion error to `core::convert::Infallible` and re-exporting it without + exposing the private `convert::tonic` module. + ## [0.20.5] - 2025-10-05 ### Changed diff --git a/Cargo.lock b/Cargo.lock index d6d73a1..e4be860 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.5" +version = "0.20.6" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 9fd4521..7a09469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.5" +version = "0.20.6" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 2b5e531..8740547 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.20.5", default-features = false } +masterror = { version = "0.20.6", default-features = false } # or with features: -# masterror = { version = "0.20.5", features = [ +# masterror = { version = "0.20.6", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -85,7 +85,6 @@ masterror = { version = "0.20.5", default-features = false } ---
- Quick start Create an error: @@ -378,143 +377,8 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); ~~~
-
- Web framework integrations - -
- Axum - -~~~rust -// features = ["axum", "serde_json"] -... - assert!(payload.is_object()); - - #[cfg(target_arch = "wasm32")] - { - if let Err(console_err) = err.log_to_browser_console() { - eprintln!( - "failed to log to browser console: {:?}", - console_err.context() - ); - } - } - - Ok(()) -} -~~~ - -- On non-WASM targets `log_to_browser_console` returns - `BrowserConsoleError::UnsupportedTarget`. -- `BrowserConsoleError::context()` exposes optional browser diagnostics for - logging/telemetry when console logging fails. - -
- -
- -
- Feature flags - -- `axum` — IntoResponse integration with structured JSON bodies -- `actix` — Actix Web ResponseError and Responder implementations -- `openapi` — Generate utoipa OpenAPI schema for ErrorResponse -- `serde_json` — Attach structured JSON details to AppError -- `tracing` — Emit structured tracing events when errors are constructed -- `metrics` — Increment `error_total{code,category}` counter for each AppError -- `backtrace` — Capture lazy `Backtrace` snapshots when telemetry is flushed -- `sqlx` — Classify sqlx_core::Error variants into AppError kinds -- `sqlx-migrate` — Map sqlx::migrate::MigrateError into AppError (Database) -- `reqwest` — Classify reqwest::Error as timeout/network/external API -- `redis` — Map redis::RedisError into cache-aware AppError -- `validator` — Convert validator::ValidationErrors into validation failures -- `config` — Propagate config::ConfigError as configuration issues -- `tokio` — Classify tokio::time::error::Elapsed as timeout -- `multipart` — Handle axum multipart extraction errors -- `teloxide` — Convert teloxide_core::RequestError into domain errors -- `telegram-webapp-sdk` — Surface Telegram WebApp validation failures -- `tonic` — Convert AppError into tonic::Status with redaction -- `frontend` — Log to the browser console and convert to JsValue on WASM -- `turnkey` — Ship Turnkey-specific error taxonomy and conversions - -
- -
- Conversions - -- `std::io::Error` → Internal -- `String` → BadRequest -- `sqlx::Error` → NotFound/Database -- `redis::RedisError` → Cache -- `reqwest::Error` → Timeout/Network/ExternalApi -- `axum::extract::multipart::MultipartError` → BadRequest -- `validator::ValidationErrors` → Validation -- `config::ConfigError` → Config -- `tokio::time::error::Elapsed` → Timeout -- `teloxide_core::RequestError` → RateLimited/Network/ExternalApi/Deserialization/Internal -- `telegram_webapp_sdk::utils::validate_init_data::ValidationError` → TelegramAuth - -
- -
- Typical setups - -Minimal core: -~~~toml -masterror = { version = "0.20.5", default-features = false } -~~~ - -API (Axum + JSON + deps): - -~~~toml -masterror = { version = "0.20.5", features = [ - "axum", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -API (Actix + JSON + deps): - -~~~toml -masterror = { version = "0.20.5", features = [ - "actix", "serde_json", "openapi", - "sqlx", "reqwest", "redis", "validator", "config", "tokio" -] } -~~~ - -
- -
- Turnkey - -~~~rust -// 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 +### Further resources - Explore the [error-handling wiki](docs/wiki/index.md) for step-by-step guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. diff --git a/src/convert.rs b/src/convert.rs index bb409a5..1dc4966 100644 --- a/src/convert.rs +++ b/src/convert.rs @@ -130,6 +130,9 @@ mod telegram_webapp_sdk; #[cfg_attr(docsrs, doc(cfg(feature = "tonic")))] mod tonic; +#[cfg(feature = "tonic")] +pub use self::tonic::StatusConversionError; + /// Map `std::io::Error` to an internal application error. /// /// Rationale: I/O failures are infrastructure-level and should not leak diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 07e4b87..9691f6f 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -12,13 +12,14 @@ //! ## Example //! //! ```rust,ignore -//! use masterror::{AppError, AppErrorKind}; +//! use masterror::AppError; //! //! let status = tonic::Status::from(AppError::not_found("missing")); //! assert_eq!(status.code(), tonic::Code::NotFound); //! ``` -use std::{borrow::Cow, fmt}; +use core::convert::Infallible; +use std::borrow::Cow; use tonic::{ Code, Status, @@ -32,37 +33,25 @@ use crate::{ mapping_for_code }; -/// Error returned when converting [`Error`] into [`Status`] fails. +/// Error alias retained for backwards compatibility with 0.20 conversions. /// -/// This type is never constructed in practice because the conversion is -/// guaranteed to succeed. It exists solely to preserve the `TryFrom` API in -/// addition to the infallible [`From`] conversion. +/// Since Rust 1.90 the standard library implements [`TryFrom`] for every +/// [`Into`] conversion with [`core::convert::Infallible`] as the error type. +/// Tonic conversions are therefore guaranteed to succeed, and this alias keeps +/// the historic [`StatusConversionError`] name available for downstream APIs. /// /// # Examples /// ```rust,ignore /// use masterror::{AppError, StatusConversionError}; /// use tonic::{Code, Status}; /// -/// fn convert() -> Result { -/// Status::try_from(AppError::not_found("missing")) -/// } -/// -/// # fn main() -> Result<(), StatusConversionError> { -/// let status = convert()?; +/// let status: Result = Status::try_from( +/// AppError::not_found("missing") +/// ); +/// let status = status.expect("conversion cannot fail"); /// assert_eq!(status.code(), Code::NotFound); -/// # Ok(()) -/// # } /// ``` -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct StatusConversionError; - -impl fmt::Display for StatusConversionError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str("conversion to tonic::Status cannot fail") - } -} - -impl std::error::Error for StatusConversionError {} +pub type StatusConversionError = Infallible; impl From for Status { fn from(error: Error) -> Self { @@ -70,14 +59,6 @@ impl From for Status { } } -impl TryFrom for Status { - type Error = StatusConversionError; - - fn try_from(error: Error) -> Result { - Ok(Status::from(error)) - } -} - fn status_from_error(error: &Error) -> Status { error.emit_telemetry(); diff --git a/src/lib.rs b/src/lib.rs index 956876b..b91d32d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -366,4 +366,4 @@ pub use result_ext::ResultExt; #[cfg(feature = "tonic")] #[cfg_attr(docsrs, doc(cfg(feature = "tonic")))] -pub use crate::convert::tonic::StatusConversionError; +pub use crate::convert::StatusConversionError; From 09e3d84a0eb0724519264b10e2bee1dbefef9e2d Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 15:10:47 +0700 Subject: [PATCH 22/25] Fix Tonic conversion lint and bump version --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- src/app_error/tests.rs | 4 ++-- src/convert/tonic.rs | 6 +++--- 6 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1278292..9909129 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.7] - 2025-10-07 + +### Fixed +- Replaced the remaining fallible `Status::try_from` conversions in the Tonic + adapter tests with the infallible `Status::from` API so Clippy's + `unnecessary_fallible_conversions` lint passes under `-D warnings`. + ## [0.20.6] - 2025-10-06 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index e4be860..8d14c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.6" +version = "0.20.7" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 7a09469..d79a866 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.6" +version = "0.20.7" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 8740547..cd31c96 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.20.6", default-features = false } +masterror = { version = "0.20.7", default-features = false } # or with features: -# masterror = { version = "0.20.6", features = [ +# masterror = { version = "0.20.7", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 04fdf9d..73633a0 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -540,6 +540,6 @@ fn result_alias_is_generic() { let default_result: super::AppResult = Ok(1); let custom_result: super::AppResult = Ok(2); - assert_eq!(default_result.unwrap(), 1); - assert_eq!(custom_result.unwrap(), 2); + assert!(matches!(default_result, Ok(value) if value == 1)); + assert!(matches!(custom_result, Ok(value) if value == 2)); } diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 9691f6f..648074c 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -167,7 +167,7 @@ mod tests { fn status_maps_codes_correctly() { for (code, mapping) in CODE_MAPPINGS.iter() { let err = AppError::with(mapping.kind(), format!("{:?}", code)); - let status = Status::try_from(err).expect("status"); + let status = Status::from(err); assert_eq!(status.code(), Code::from_i32(mapping.grpc().value)); let expected_detail = format!("{:?}", code); assert_eq!( @@ -184,7 +184,7 @@ mod tests { let err = AppError::internal("secret") .redactable() .with_field(field::str("request_id", "abc")); - let status = Status::try_from(err).expect("status"); + let status = Status::from(err); assert_eq!(status.message(), AppErrorKind::Internal.to_string()); assert!(status.metadata().get("request_id").is_none()); } @@ -194,7 +194,7 @@ mod tests { let err = AppError::service("downstream") .with_field(field::str("request_id", "abc")) .with_field(field::u64("attempt", 2)); - let status = Status::try_from(err).expect("status"); + let status = Status::from(err); assert_eq!( status .metadata() From 76341e4456449fe77ab49f1297e5cbbc03cb6706 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:37:48 +0700 Subject: [PATCH 23/25] Fix redis busy loading classification --- CHANGELOG.md | 12 ++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- src/app_error/tests.rs | 7 ++++++- src/convert/redis.rs | 3 ++- src/convert/serde_json.rs | 2 +- 7 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9909129..7f606d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.8] - 2025-10-08 + +### Fixed +- Classified Redis `BusyLoadingError` responses as `DependencyUnavailable` and + preserved their retry advice in metadata so downstreams can distinguish cache + warmup from client mistakes when the `redis` feature is enabled. +- Serialized the serde_json syntax error position using the location reported + by `serde_json::Error` to stay aligned with the upstream parser changes. +- Guarded the tracing telemetry test with a process-wide mutex to prevent + spurious race failures when the full feature suite runs the test harness in + parallel. + ## [0.20.7] - 2025-10-07 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 8d14c1d..5f937c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.7" +version = "0.20.8" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index d79a866..8c1a7d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.7" +version = "0.20.8" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index cd31c96..3932ed6 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.20.7", default-features = false } +masterror = { version = "0.20.8", default-features = false } # or with features: -# masterror = { version = "0.20.7", features = [ +# masterror = { version = "0.20.8", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 73633a0..4f21968 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "backtrace")] +#[cfg(any(feature = "backtrace", feature = "tracing"))] use std::sync::Mutex; use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; @@ -8,6 +8,9 @@ use super::core::{reset_backtrace_preference, set_backtrace_preference_override} #[cfg(feature = "backtrace")] static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); +#[cfg(feature = "tracing")] +static TELEMETRY_GUARD: Mutex<()> = Mutex::new(()); + use super::{AppError, FieldRedaction, FieldValue, MessageEditPolicy, field}; use crate::{AppCode, AppErrorKind}; @@ -314,6 +317,8 @@ fn log_uses_kind_and_code() { #[cfg(feature = "tracing")] #[test] fn telemetry_emits_single_tracing_event_with_trace_id() { + let _guard = TELEMETRY_GUARD.lock().expect("telemetry guard"); + use std::{ fmt, sync::{Arc, Mutex} diff --git a/src/convert/redis.rs b/src/convert/redis.rs index fb7511d..7a4a99c 100644 --- a/src/convert/redis.rs +++ b/src/convert/redis.rs @@ -33,7 +33,7 @@ //! ``` #[cfg(feature = "redis")] -use redis::{RedisError, RetryMethod}; +use redis::{ErrorKind, RedisError, RetryMethod}; #[cfg(feature = "redis")] use crate::{AppErrorKind, Context, Error, field}; @@ -84,6 +84,7 @@ fn build_context(err: &RedisError) -> (Context, Option) { || err.is_connection_dropped() || err.is_cluster_error() || err.is_io_error() + || matches!(err.kind(), ErrorKind::BusyLoadingError) { context = context.category(AppErrorKind::DependencyUnavailable); } diff --git a/src/convert/serde_json.rs b/src/convert/serde_json.rs index a47b854..bd22754 100644 --- a/src/convert/serde_json.rs +++ b/src/convert/serde_json.rs @@ -133,7 +133,7 @@ mod tests { ); assert_eq!( metadata.get("serde_json.position"), - Some(&FieldValue::Str("1:1".into())) + Some(&FieldValue::Str("1:2".into())) ); } } From fb82ecd359afd1d779b2eee70d12bfc1cd98b0ef Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 16:38:02 +0700 Subject: [PATCH 24/25] Add typed control-flow macros for early error returns --- CHANGELOG.md | 11 +++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 32 +++++++++++++- README.ru.md | 29 ++++++++++++- README.template.md | 28 ++++++++++++ src/lib.rs | 30 +++++++++++++ src/macros.rs | 100 +++++++++++++++++++++++++++++++++++++++++++ tests/ensure_fail.rs | 78 +++++++++++++++++++++++++++++++++ 9 files changed, 306 insertions(+), 6 deletions(-) create mode 100644 src/macros.rs create mode 100644 tests/ensure_fail.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9909129..dd9e8f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.21.0] - 2025-10-08 + +### Added +- Introduced typed `ensure!` and `fail!` macros as allocation-free alternatives + to `anyhow::ensure!`/`anyhow::bail!`, complete with documentation and tests. + +### Changed +- Highlighted the new control-flow macros across the English and Russian + READMEs and module documentation so adopters discover them alongside the + derive tooling. + ## [0.20.7] - 2025-10-07 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 8d14c1d..b71bd79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.20.7" +version = "0.21.0" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index d79a866..ffeffb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.20.7" +version = "0.21.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index cd31c96..57a881b 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,8 @@ of redaction and metadata. - **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, helper builders and tracing instrumentation for teams that want a consistent baseline out of the box. +- **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions + with your domain errors without allocating or formatting on the happy path. ### Workspace crates @@ -71,9 +73,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.20.7", default-features = false } +masterror = { version = "0.21.0", default-features = false } # or with features: -# masterror = { version = "0.20.7", features = [ +# masterror = { version = "0.21.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -114,6 +116,32 @@ fn do_work(flag: bool) -> AppResult<()> {
+
+ Fail fast without sacrificing typing + +`ensure!` and `fail!` provide typed alternatives to the formatting-heavy +`anyhow::ensure!`/`anyhow::bail!` helpers. They evaluate the error expression +only when the guard trips, so success paths stay allocation-free. + +~~~rust +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)); +~~~ + +
+
Derive domain errors and map them to transports diff --git a/README.ru.md b/README.ru.md index 2c90dfc..a3d5331 100644 --- a/README.ru.md +++ b/README.ru.md @@ -39,6 +39,8 @@ MSRV зафиксирован, а родные деривы позволяют - **Готовые настройки.** Модуль `turnkey` поставляет готовый каталог ошибок, билдеры и интеграцию с `tracing` для команд, которым нужна стартовая конфигурация «из коробки». +- **Типобезопасные макросы управления потоком.** `ensure!` и `fail!` прерывают + выполнение с доменной ошибкой без аллокаций и форматирования на удачной ветке. ## Состав workspace @@ -65,9 +67,9 @@ MSRV зафиксирован, а родные деривы позволяют ~~~toml [dependencies] -masterror = { version = "0.20.5", default-features = false } +masterror = { version = "0.21.0", default-features = false } # или с нужными фичами: -# masterror = { version = "0.20.5", features = [ +# masterror = { version = "0.21.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -105,6 +107,29 @@ fn do_work(flag: bool) -> AppResult<()> { } ~~~ +### Макросы для раннего возврата без потери типизации + +`ensure!` и `fail!` — типизированные аналоги `anyhow::ensure!`/`anyhow::bail!`. +Они вычисляют выражение ошибки только при срабатывании гварда, поэтому +успешный путь остаётся без аллокаций. + +~~~rust +use masterror::{AppError, AppErrorKind, AppResult}; + +fn guard(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::bad_request("Флаг обязателен")); + Ok(()) +} + +fn bail() -> AppResult<()> { + masterror::fail!(AppError::unauthorized("Токен истёк")); +} + +assert!(guard(true).is_ok()); +assert!(matches!(guard(false).unwrap_err().kind, AppErrorKind::BadRequest)); +assert!(matches!(bail().unwrap_err().kind, AppErrorKind::Unauthorized)); +~~~ + ### Деривы для доменных ошибок и транспорта `masterror` предоставляет родные деривы, чтобы типы оставались выразительными, а diff --git a/README.template.md b/README.template.md index 1f743b3..74dc2d7 100644 --- a/README.template.md +++ b/README.template.md @@ -42,6 +42,8 @@ of redaction and metadata. - **Turnkey defaults.** The `turnkey` module ships a ready-to-use error catalog, helper builders and tracing instrumentation for teams that want a consistent baseline out of the box. +- **Typed control-flow macros.** `ensure!` and `fail!` short-circuit functions + with your domain errors without allocating or formatting on the happy path. ### Workspace crates @@ -110,6 +112,32 @@ fn do_work(flag: bool) -> AppResult<()> {
+
+ Fail fast without sacrificing typing + +`ensure!` and `fail!` provide typed alternatives to the formatting-heavy +`anyhow::ensure!`/`anyhow::bail!` helpers. They evaluate the error expression +only when the guard trips, so success paths stay allocation-free. + +~~~rust +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)); +~~~ + +
+
Derive domain errors and map them to transports diff --git a/src/lib.rs b/src/lib.rs index b91d32d..dff5e40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -246,6 +246,35 @@ //! assert!(matches!(resp.code, AppCode::NotFound)); //! ``` //! +//! # Typed control-flow macros +//! +//! Reach for [`ensure!`] and [`fail!`] when you need to exit early with a typed +//! error without paying for string formatting or heap allocations on the +//! success path. +//! +//! ```rust +//! 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 +//! )); +//! ``` +//! //! # Axum integration //! //! With the `axum` feature enabled, you can return [`AppError`] directly from @@ -302,6 +331,7 @@ mod code; mod convert; pub mod error; mod kind; +mod macros; #[cfg(error_generic_member_access)] #[doc(hidden)] pub mod provide; diff --git a/src/macros.rs b/src/macros.rs new file mode 100644 index 0000000..321f9a0 --- /dev/null +++ b/src/macros.rs @@ -0,0 +1,100 @@ +//! Control-flow macros for early returns with typed errors. +//! +//! These macros complement the typed [`AppError`](crate::AppError) APIs by +//! providing a lightweight, allocation-free way to short-circuit functions when +//! invariants are violated. Unlike the dynamic formatting helpers offered by +//! `anyhow` or `eyre`, the macros operate on pre-constructed error values so +//! the compiler keeps strong typing guarantees and no formatting work happens +//! on the success path. +//! +//! ```rust +//! use masterror::{AppError, AppErrorKind, AppResult}; +//! +//! fn guard(flag: bool) -> AppResult<()> { +//! masterror::ensure!(flag, AppError::bad_request("flag must be true")); +//! Ok(()) +//! } +//! +//! assert!(guard(true).is_ok()); +//! assert!(matches!( +//! guard(false).unwrap_err().kind, +//! AppErrorKind::BadRequest +//! )); +//! ``` + +/// Abort the enclosing function with an error when a condition fails. +/// +/// The macro takes either a bare condition and error expression, or the more +/// explicit `cond = ..., else = ...` form. The error expression is evaluated +/// lazily only when the condition is false. +/// +/// # Examples +/// +/// Short-circuit a typed error: +/// +/// ```rust +/// use masterror::{AppError, AppErrorKind, AppResult}; +/// +/// fn require(flag: bool) -> AppResult<()> { +/// masterror::ensure!(flag, AppError::bad_request("flag required")); +/// Ok(()) +/// } +/// +/// assert!(matches!( +/// require(false).unwrap_err().kind, +/// AppErrorKind::BadRequest +/// )); +/// ``` +/// +/// Use the verbose syntax for clarity in complex conditions: +/// +/// ```rust +/// use masterror::{AppError, AppResult}; +/// +/// fn bounded(value: i32, max: i32) -> AppResult<()> { +/// masterror::ensure!( +/// cond = value <= max, +/// else = AppError::service("value too large") +/// ); +/// Ok(()) +/// } +/// +/// assert!(bounded(2, 3).is_ok()); +/// assert!(bounded(5, 3).is_err()); +/// ``` +#[macro_export] +macro_rules! ensure { + (cond = $cond:expr, else = $err:expr $(,)?) => { + $crate::ensure!($cond, $err) + }; + ($cond:expr, $err:expr $(,)?) => { + if !$cond { + return Err($err); + } + }; +} + +/// Abort the enclosing function with the provided error. +/// +/// This macro is a typed alternative to `anyhow::bail!`, delegating the +/// decision of how to construct the error to the caller. It never performs +/// formatting or allocations on the success path. +/// +/// # Examples +/// +/// ```rust +/// use masterror::{AppError, AppErrorKind, AppResult}; +/// +/// fn reject() -> AppResult<()> { +/// masterror::fail!(AppError::unauthorized("token expired")); +/// } +/// +/// let err = reject().unwrap_err(); +/// assert!(matches!(err.kind, AppErrorKind::Unauthorized)); +/// ``` +#[macro_export] +macro_rules! fail { + ($err:expr $(,)?) => { + return Err($err); + }; +} diff --git a/tests/ensure_fail.rs b/tests/ensure_fail.rs new file mode 100644 index 0000000..992dffc --- /dev/null +++ b/tests/ensure_fail.rs @@ -0,0 +1,78 @@ +use core::sync::atomic::{AtomicUsize, Ordering}; + +use masterror::{AppError, AppErrorKind, AppResult}; + +static CALLS: AtomicUsize = AtomicUsize::new(0); + +#[test] +fn ensure_allows_success_path() { + fn run(flag: bool) -> AppResult<&'static str> { + masterror::ensure!(flag, AppError::bad_request("flag required")); + Ok("ok") + } + + assert_eq!(run(true).unwrap(), "ok"); +} + +#[test] +fn ensure_yields_error_once() { + fn build_error() -> AppError { + CALLS.fetch_add(1, Ordering::SeqCst); + AppError::service("bounded") + } + + fn run(flag: bool) -> AppResult<()> { + masterror::ensure!(cond = flag, else = build_error()); + Ok(()) + } + + CALLS.store(0, Ordering::SeqCst); + assert!(run(false).is_err()); + assert_eq!(CALLS.load(Ordering::SeqCst), 1); + + CALLS.store(0, Ordering::SeqCst); + assert!(run(true).is_ok()); + assert_eq!(CALLS.load(Ordering::SeqCst), 0); +} + +#[test] +fn ensure_preserves_error_kind() { + fn run(flag: bool) -> AppResult<()> { + masterror::ensure!(flag, AppError::unauthorized("token expired")); + Ok(()) + } + + let err = run(false).unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::Unauthorized)); +} + +#[test] +fn fail_returns_error() { + fn run() -> AppResult<()> { + masterror::fail!(AppError::forbidden("admin only")); + } + + let err = run().unwrap_err(); + assert!(matches!(err.kind, AppErrorKind::Forbidden)); +} + +#[derive(Debug, PartialEq, Eq)] +struct CustomError(&'static str); + +type CustomResult = Result; + +#[test] +fn macros_work_with_custom_error_types() { + fn guard(flag: bool) -> CustomResult<&'static str> { + masterror::ensure!(flag, CustomError("custom failure")); + Ok("ok") + } + + fn bail() -> CustomResult<()> { + masterror::fail!(CustomError("fail")); + } + + assert_eq!(guard(true).unwrap(), "ok"); + assert_eq!(guard(false).unwrap_err(), CustomError("custom failure")); + assert_eq!(bail().unwrap_err(), CustomError("fail")); +} From eb9f02b86419bf3c1e442ca9387c4c36bd80db07 Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:46:26 +0700 Subject: [PATCH 25/25] Shrink AppError footprint --- CHANGELOG.md | 7 +++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 4 ++-- README.ru.md | 4 ++-- src/app_error/core.rs | 40 ++++++++++++++++++++-------------------- src/app_error/tests.rs | 9 +++++++++ 7 files changed, 42 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a21bf9e..39651ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.21.1] - 2025-10-09 + +### Fixed +- Packed rarely used `AppError` context (source and backtrace slots) inside the + boxed inner payload so the `AppResult` alias no longer triggers Clippy's + `result_large_err` lint under `-D warnings`. + ## [0.21.0] - 2025-10-08 ### Added diff --git a/Cargo.lock b/Cargo.lock index b71bd79..8e6eb42 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.21.0" +version = "0.21.1" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index ffeffb4..27f3748 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.21.0" +version = "0.21.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 57a881b..17c08b6 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.21.0", default-features = false } +masterror = { version = "0.21.1", default-features = false } # or with features: -# masterror = { version = "0.21.0", features = [ +# masterror = { version = "0.21.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", diff --git a/README.ru.md b/README.ru.md index a3d5331..a301b06 100644 --- a/README.ru.md +++ b/README.ru.md @@ -67,9 +67,9 @@ MSRV зафиксирован, а родные деривы позволяют ~~~toml [dependencies] -masterror = { version = "0.21.0", default-features = false } +masterror = { version = "0.21.1", default-features = false } # или с нужными фичами: -# masterror = { version = "0.21.0", features = [ +# masterror = { version = "0.21.1", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", diff --git a/src/app_error/core.rs b/src/app_error/core.rs index cf43ec8..3c791b9 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -38,20 +38,25 @@ pub enum MessageEditPolicy { #[doc(hidden)] pub struct ErrorInner { /// Stable machine-readable error code. - pub code: AppCode, + pub code: AppCode, /// Semantic error category. - pub kind: AppErrorKind, + pub kind: AppErrorKind, /// Optional, public-friendly message. - pub message: Option>, + pub message: Option>, /// Structured metadata for telemetry. - pub metadata: Metadata, + pub metadata: Metadata, /// Policy describing whether the message can be redacted. - pub edit_policy: MessageEditPolicy, + pub edit_policy: MessageEditPolicy, /// Optional retry advice rendered as `Retry-After`. - pub retry: Option, + pub retry: Option, /// Optional authentication challenge for `WWW-Authenticate`. - pub www_authenticate: Option, - telemetry_dirty: AtomicBool + pub www_authenticate: Option, + pub source: Option>, + #[cfg(feature = "backtrace")] + pub backtrace: Option, + #[cfg(feature = "backtrace")] + pub captured_backtrace: OnceLock>, + telemetry_dirty: AtomicBool } #[cfg(feature = "backtrace")] @@ -156,12 +161,7 @@ mod test_backtrace_override { /// Rich application error preserving domain code, taxonomy and metadata. #[derive(Debug)] pub struct Error { - inner: Box, - source: Option>, - #[cfg(feature = "backtrace")] - backtrace: Option, - #[cfg(feature = "backtrace")] - captured_backtrace: OnceLock> + inner: Box } impl Deref for Error { @@ -228,13 +228,13 @@ impl Error { edit_policy: MessageEditPolicy::Preserve, retry: None, www_authenticate: None, + source: None, + #[cfg(feature = "backtrace")] + backtrace: None, + #[cfg(feature = "backtrace")] + captured_backtrace: OnceLock::new(), telemetry_dirty: AtomicBool::new(true) - }), - source: None, - #[cfg(feature = "backtrace")] - backtrace: None, - #[cfg(feature = "backtrace")] - captured_backtrace: OnceLock::new() + }) } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 4f21968..592d911 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -548,3 +548,12 @@ fn result_alias_is_generic() { assert!(matches!(default_result, Ok(value) if value == 1)); assert!(matches!(custom_result, Ok(value) if value == 2)); } + +#[test] +fn app_error_fits_result_budget() { + let size = std::mem::size_of::(); + assert!( + size <= 128, + "AppError grew to {size} bytes; keep the Err variant lean" + ); +}