diff --git a/CHANGELOG.md b/CHANGELOG.md index 6da1abf..dfcbfa5 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.23.1] - 2025-10-13 + +### Fixed +- Restored the `AppError::with_context` helper as an alias for `with_source`, + preserving the `Arc` fast-path, updating documentation and README templates, + and adding regression tests for plain and `anyhow::Error` diagnostics. + ## [0.23.0] - 2025-10-12 ### Added diff --git a/Cargo.lock b/Cargo.lock index 1718ae4..1344528 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -229,6 +229,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + [[package]] name = "arraydeque" version = "0.5.1" @@ -1727,9 +1733,10 @@ dependencies = [ [[package]] name = "masterror" -version = "0.23.0" +version = "0.23.1" dependencies = [ "actix-web", + "anyhow", "axum 0.8.4", "config", "http 1.3.1", diff --git a/Cargo.toml b/Cargo.toml index d335367..641c99d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.23.0" +version = "0.23.1" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -129,6 +129,7 @@ uuid = { version = "1", default-features = false } tonic = { version = "0.12", optional = true } [dev-dependencies] +anyhow = { version = "1", default-features = false, features = ["std"] } serde_json = "1" tokio = { version = "1", features = [ "macros", diff --git a/README.md b/README.md index fa67970..87ce51f 100644 --- a/README.md +++ b/README.md @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.23.0", default-features = false } +masterror = { version = "0.23.1", default-features = false } # or with features: -# masterror = { version = "0.23.0", features = [ +# masterror = { version = "0.23.1", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", @@ -101,6 +101,10 @@ assert!(matches!(err.kind, AppErrorKind::BadRequest)); let err_with_meta = AppError::service("downstream") .with_field(field::str("request_id", "abc123")); assert_eq!(err_with_meta.metadata().len(), 1); + +let err_with_context = AppError::internal("db down") + .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); +assert!(err_with_context.source_ref().is_some()); ~~~ With prelude: diff --git a/README.template.md b/README.template.md index 8d5482d..03bbd4f 100644 --- a/README.template.md +++ b/README.template.md @@ -96,6 +96,10 @@ assert!(matches!(err.kind, AppErrorKind::BadRequest)); let err_with_meta = AppError::service("downstream") .with_field(field::str("request_id", "abc123")); assert_eq!(err_with_meta.metadata().len(), 1); + +let err_with_context = AppError::internal("db down") + .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); +assert!(err_with_context.source_ref().is_some()); ~~~ With prelude: diff --git a/docs/wiki/error-crate-comparison.md b/docs/wiki/error-crate-comparison.md index 2fb3c37..b5bf6ba 100644 --- a/docs/wiki/error-crate-comparison.md +++ b/docs/wiki/error-crate-comparison.md @@ -123,8 +123,9 @@ fn load_configuration(path: &std::path::Path) -> masterror::AppResult { ``` `AppError` stores the `anyhow::Error` internally without exposing it to clients. -You still emit clean JSON responses, while logs retain the full diagnostic -payload. +`with_context` reuses any shared `Arc` handles provided by upstream crates, so +you preserve pointer identity without extra allocations. You still emit clean +JSON responses, while logs retain the full diagnostic payload. ## Why choose `masterror` diff --git a/docs/wiki/masterror-application-guide.md b/docs/wiki/masterror-application-guide.md index af9d833..7d854b5 100644 --- a/docs/wiki/masterror-application-guide.md +++ b/docs/wiki/masterror-application-guide.md @@ -66,8 +66,10 @@ pub fn parse_payload(json: &str) -> masterror::AppResult<&str> { } ``` -`with_context` stores the original `serde_json::Error` for logging; clients only -see the sanitized message, code, and JSON details. Enable the `serde_json` +`with_context` stores the original `serde_json::Error` for logging while reusing +any shared `Arc` the upstream library hands you, avoiding extra reference-count +allocations. Clients only see the sanitized message, code, and JSON details. +Enable the `serde_json` feature to use `.with_details(..)`; without it, fall back to `AppError::with_details_text` for plain-text payloads. diff --git a/docs/wiki/patterns-and-troubleshooting.md b/docs/wiki/patterns-and-troubleshooting.md index 2c62db2..c42978d 100644 --- a/docs/wiki/patterns-and-troubleshooting.md +++ b/docs/wiki/patterns-and-troubleshooting.md @@ -80,9 +80,9 @@ fn to_json(err: masterror::AppError) -> serde_json::Value { 1. Log errors at the boundary with `tracing::error!`, including `kind`, `code`, and `retry` metadata. -2. Attach upstream errors via `with_context`. When you need additional metadata, - derive your error type with fields annotated using `#[provide]` from - `masterror::Error`. +2. Attach upstream errors via `with_context` to preserve shared `Arc` handles and + reuse upstream diagnostics. When you need additional metadata, derive your + error type with fields annotated using `#[provide]` from `masterror::Error`. ```rust #[tracing::instrument(skip(err))] diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 64a6e82..96bf40c 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -163,7 +163,7 @@ impl Context { if matches!(self.edit_policy, MessageEditPolicy::Redact) { error.edit_policy = MessageEditPolicy::Redact; } - let error = error.with_source(source); + let error = error.with_context(source); error.emit_telemetry(); error } diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 613c2e8..70a6f97 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -30,6 +30,23 @@ use tracing::{Level, event}; use super::metadata::{Field, FieldRedaction, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; +/// Attachments accepted by [`Error::with_context`]. +#[derive(Debug)] +#[doc(hidden)] +pub enum ContextAttachment { + Owned(Box), + Shared(Arc) +} + +impl From for ContextAttachment +where + E: CoreError + Send + Sync + 'static +{ + fn from(source: E) -> Self { + Self::Owned(Box::new(source)) + } +} + #[cfg(feature = "std")] pub type CapturedBacktrace = std::backtrace::Backtrace; @@ -435,7 +452,40 @@ impl Error { self } + /// Attach upstream diagnostics using [`with_source`](Self::with_source) or + /// an existing [`Arc`]. + /// + /// This is the preferred alias for capturing upstream errors. It accepts + /// either an owned error implementing [`core::error::Error`] or a + /// shared [`Arc`] produced by other APIs, reusing the allocation when + /// possible. + /// + /// # Examples + /// + /// ```rust + /// use masterror::AppError; + /// + /// let err = AppError::service("downstream degraded") + /// .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); + /// assert!(err.source_ref().is_some()); + /// ``` + #[must_use] + pub fn with_context(self, context: impl Into) -> Self { + match context.into() { + ContextAttachment::Owned(source) => { + match source.downcast::>() { + Ok(shared) => self.with_source_arc(*shared), + Err(source) => self.with_source_arc(Arc::from(source)) + } + } + ContextAttachment::Shared(source) => self.with_source_arc(source) + } + } + /// Attach a source error for diagnostics. + /// + /// Prefer [`with_context`](Self::with_context) when capturing upstream + /// diagnostics without additional `Arc` allocations. #[must_use] pub fn with_source(mut self, source: impl CoreError + Send + Sync + 'static) -> Self { self.source = Some(Arc::new(source)); diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 942601a..81962a3 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -1,6 +1,33 @@ #[cfg(any(feature = "backtrace", feature = "tracing"))] use std::sync::Mutex; -use std::{borrow::Cow, error::Error as StdError, fmt::Display, sync::Arc}; +use std::{ + borrow::Cow, + error::Error as StdError, + fmt::{Display, Formatter, Result as FmtResult}, + io::{Error as IoError, ErrorKind as IoErrorKind}, + sync::Arc +}; + +#[cfg(feature = "std")] +use anyhow::Error as AnyhowError; + +#[cfg(feature = "std")] +#[derive(Debug)] +struct AnyhowSource(AnyhowError); + +#[cfg(feature = "std")] +impl Display for AnyhowSource { + fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { + Display::fmt(&self.0, f) + } +} + +#[cfg(feature = "std")] +impl StdError for AnyhowSource { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.0.source() + } +} #[cfg(feature = "backtrace")] use super::core::{reset_backtrace_preference, set_backtrace_preference_override}; @@ -119,6 +146,29 @@ fn constructors_match_kinds() { assert_err_with_msg(AppError::cache("cache"), AppErrorKind::Cache, "cache"); } +#[cfg(feature = "std")] +#[test] +fn with_context_attaches_plain_source() { + let err = AppError::internal("boom").with_context(IoError::from(IoErrorKind::Other)); + + let source = err.source_ref().expect("stored source"); + assert!(source.is::()); + assert_eq!(source.to_string(), IoErrorKind::Other.to_string()); +} + +#[cfg(feature = "std")] +#[test] +fn with_context_accepts_anyhow_error() { + let upstream: AnyhowError = anyhow::anyhow!("context failed"); + let err = AppError::service("downstream").with_context(AnyhowSource(upstream)); + + let source = err.source_ref().expect("stored source"); + let stored = source + .downcast_ref::() + .expect("anyhow source"); + assert_eq!(stored.0.to_string(), "context failed"); +} + #[test] fn database_accepts_optional_message() { let with_msg = AppError::database_with_message("db down"); diff --git a/src/lib.rs b/src/lib.rs index f98b7f4..83fbd13 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -224,6 +224,15 @@ //! assert_eq!(err.metadata().len(), 2); //! ``` //! +//! Attach upstream diagnostics without cloning existing `Arc`s: +//! ```rust +//! use masterror::AppError; +//! +//! let err = AppError::internal("db down") +//! .with_context(std::io::Error::new(std::io::ErrorKind::Other, "boom")); +//! assert!(err.source_ref().is_some()); +//! ``` +//! //! [`AppErrorKind`] controls the default HTTP status mapping. //! [`AppCode`] provides a stable machine-readable code for clients. //! Together, they form the wire contract in [`ErrorResponse`].