diff --git a/CHANGELOG.md b/CHANGELOG.md index cf60a98..1dd5fbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.24.17] - 2025-11-02 + +### Fixed +- Preserve captured backtraces when wrapping `AppError` instances through + `ResultExt::context` by sharing the snapshot instead of attempting to clone + `std::backtrace::Backtrace`. + ## [0.24.16] - 2025-11-01 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index a41ebf0..bfff7a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1804,7 +1804,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.24.16" +version = "0.24.17" dependencies = [ "actix-web", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 5860848..1004cda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ [package] name = "masterror" -version = "0.24.16" +version = "0.24.17" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index b14aa63..520cb40 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,9 @@ The build script keeps the full feature snippet below in sync with ~~~toml [dependencies] -masterror = { version = "0.24.16", default-features = false } +masterror = { version = "0.24.17", default-features = false } # or with features: -# masterror = { version = "0.24.16", features = [ +# masterror = { version = "0.24.17", features = [ # "std", "axum", "actix", "openapi", # "serde_json", "tracing", "metrics", "backtrace", # "sqlx", "sqlx-migrate", "reqwest", "redis", @@ -459,4 +459,3 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); MSRV: **1.90** · License: **MIT OR Apache-2.0** · No `unsafe` - diff --git a/WHY_MIGRATE.md b/WHY_MIGRATE.md index da32f34..282c74f 100644 --- a/WHY_MIGRATE.md +++ b/WHY_MIGRATE.md @@ -91,6 +91,10 @@ use masterror::prelude::*; fn process() -> AppResult<()> { ensure!(condition, AppError::bad_request("invalid input")); + // Simple context (anyhow-style) + database_call().context("db operation failed")?; + + // Or structured context with metadata database_call() .ctx(|| Context::new(AppErrorKind::Database) .with(field::str("table", "users")) @@ -100,7 +104,7 @@ fn process() -> AppResult<()> { fail!(AppError::internal("unrecoverable")); } -// ✅ Same ergonomics as anyhow +// ✅ Same ergonomics as anyhow (.context(), .chain(), .downcast_ref()) // ✅ Plus: typed errors // ✅ Plus: structured metadata // ✅ Plus: automatic tracing @@ -194,6 +198,32 @@ let err = AppError::database("query failed") // ✅ Zero boilerplate ``` +#### 5. Error Introspection (anyhow Parity) + +```rust +// anyhow: type-safe error inspection +if let Some(io_err) = err.downcast_ref::() { + match io_err.kind() { + io::ErrorKind::NotFound => /* handle */, + _ => /* other */ + } +} + +// masterror: same API, works with AppError +use masterror::ResultExt; + +match database_op().context("db failed") { + Err(err) => { + if let Some(io_err) = err.downcast_ref::() { + // ✅ Type-safe downcasting + // ✅ Inspect wrapped error sources + // ✅ Full anyhow API compatibility + } + } + Ok(val) => val +} +``` + ## Migration Guide ### From thiserror @@ -243,18 +273,29 @@ bail!("invalid input"); fail!(AppError::bad_request("invalid input")); ``` -**Step 3:** Enhance context +**Step 3:** Keep using .context() (it just works!) ```rust -// Before +// Before (anyhow) .context("db error")? -// After +// After (masterror) - identical API +.context("db error")? + +// Or use structured context for better observability .ctx(|| Context::new(AppErrorKind::Database) .with(field::str("table", "users")) )? ``` -**Result:** Type-safe, structured, observable errors. +**Step 4:** Error introspection works the same +```rust +// anyhow API still works +if let Some(io_err) = err.downcast_ref::() { + // handle specific error type +} +``` + +**Result:** Type-safe, structured, observable errors with zero API friction. ## Real-World Impact @@ -346,7 +387,11 @@ Binary size: 944KB (vs thiserror 32KB, anyhow 566KB) - 📚 [Full Documentation](https://docs.rs/masterror) - 📊 [Benchmarks](BENCHMARKS.md) -- 🔧 [Examples](examples/) +- 🔧 **[Examples](examples/)** - See working code for: + - [Basic Usage](examples/basic_usage.rs) - Core error handling patterns + - [thiserror Compatibility](examples/derive_error.rs) - Drop-in replacement + - [Structured Metadata](examples/structured_metadata.rs) - Typed fields vs strings + - [Redaction](examples/redaction.rs) - GDPR-compliant privacy controls - 💬 [GitHub Issues](https://github.com/RAprogramm/masterror/issues) --- diff --git a/examples/redaction.rs b/examples/redaction.rs new file mode 100644 index 0000000..0d609d2 --- /dev/null +++ b/examples/redaction.rs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2025 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Redaction example showing GDPR-compliant field masking. + +use masterror::{AppError, FieldRedaction, field}; + +fn main() { + let err = AppError::bad_request("Invalid credentials") + .with_field(field::str("email", "user@example.com").with_redaction(FieldRedaction::Hash)) + .with_field(field::str("ip", "192.168.1.100").with_redaction(FieldRedaction::Redact)) + .with_field(field::str("session_id", "abc123")); + + println!("=== Redacted Metadata ===\n"); + for (key, value, redaction) in err.metadata().iter_with_redaction() { + println!("{key}: {value:?} [{redaction:?}]"); + } +} diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 26e1168..7ccf280 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -91,9 +91,9 @@ pub struct ErrorInner { pub details: Option, pub source: Option>, #[cfg(feature = "backtrace")] - pub backtrace: Option, + pub backtrace: Option>, #[cfg(feature = "backtrace")] - pub captured_backtrace: OnceLock>, + pub captured_backtrace: OnceLock>>, telemetry_dirty: AtomicBool, #[cfg(feature = "tracing")] tracing_dirty: AtomicBool @@ -110,9 +110,9 @@ const BACKTRACE_STATE_DISABLED: u8 = 2; static BACKTRACE_STATE: AtomicU8 = AtomicU8::new(BACKTRACE_STATE_UNSET); #[cfg(feature = "backtrace")] -fn capture_backtrace_snapshot() -> Option { +fn capture_backtrace_snapshot() -> Option> { if should_capture_backtrace() { - Some(Backtrace::capture()) + Some(Arc::new(Backtrace::capture())) } else { None } @@ -321,13 +321,13 @@ impl Error { #[cfg(feature = "backtrace")] fn capture_backtrace(&self) -> Option<&CapturedBacktrace> { - if let Some(backtrace) = self.backtrace.as_ref() { + if let Some(backtrace) = self.backtrace.as_deref() { return Some(backtrace); } self.captured_backtrace .get_or_init(capture_backtrace_snapshot) - .as_ref() + .as_deref() } #[cfg(not(feature = "backtrace"))] @@ -336,7 +336,7 @@ impl Error { } #[cfg(feature = "backtrace")] - fn set_backtrace_slot(&mut self, backtrace: CapturedBacktrace) { + fn set_backtrace_slot(&mut self, backtrace: Arc) { self.backtrace = Some(backtrace); self.captured_backtrace = OnceLock::new(); } @@ -574,6 +574,21 @@ impl Error { /// Attach a captured backtrace. #[must_use] pub fn with_backtrace(mut self, backtrace: CapturedBacktrace) -> Self { + #[cfg(feature = "backtrace")] + { + self.set_backtrace_slot(Arc::new(backtrace)); + } + + #[cfg(not(feature = "backtrace"))] + { + self.set_backtrace_slot(backtrace); + } + self.mark_dirty(); + self + } + + #[cfg(feature = "backtrace")] + pub(crate) fn with_shared_backtrace(mut self, backtrace: Arc) -> Self { self.set_backtrace_slot(backtrace); self.mark_dirty(); self @@ -678,6 +693,18 @@ impl Error { self.capture_backtrace() } + #[cfg(feature = "backtrace")] + pub(crate) fn backtrace_shared(&self) -> Option> { + if let Some(backtrace) = self.backtrace.as_ref() { + return Some(Arc::clone(backtrace)); + } + + self.captured_backtrace + .get_or_init(capture_backtrace_snapshot) + .as_ref() + .map(Arc::clone) + } + /// Borrow the source if present. #[must_use] pub fn source_ref(&self) -> Option<&(dyn CoreError + Send + Sync + 'static)> { diff --git a/src/result_ext.rs b/src/result_ext.rs index 88bfb88..790a095 100644 --- a/src/result_ext.rs +++ b/src/result_ext.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MIT +use alloc::sync::Arc; use core::error::Error as CoreError; use crate::app_error::{Context, Error}; @@ -36,6 +37,33 @@ pub trait ResultExt { fn ctx(self, build: impl FnOnce() -> Context) -> Result where E: CoreError + Send + Sync + 'static; + + /// Wrap the error with a simple context message. + /// + /// This is a convenience method equivalent to anyhow's `.context()`. + /// For more control, use [`ctx`](ResultExt::ctx). + /// + /// # Examples + /// + /// ```rust + /// use std::io::{Error as IoError, ErrorKind}; + /// + /// use masterror::ResultExt; + /// + /// fn read_config() -> Result { + /// Err(IoError::from(ErrorKind::NotFound)) + /// } + /// + /// let err = read_config() + /// .context("Failed to read config file") + /// .unwrap_err(); + /// + /// assert!(err.source_ref().is_some()); + /// ``` + #[allow(clippy::result_large_err)] + fn context(self, msg: impl Into>) -> Result + where + E: CoreError + Send + Sync + 'static; } impl ResultExt for Result { @@ -45,6 +73,48 @@ impl ResultExt for Result { { self.map_err(|err| build().into_error(err)) } + + fn context(self, msg: impl Into>) -> Result + where + E: CoreError + Send + Sync + 'static + { + let msg = msg.into(); + + self.map_err(|err| { + let source: Box = Box::new(err); + + match source.downcast::() { + Ok(app_err) => { + let app_err = *app_err; + let mut enriched = Error::new_raw(app_err.kind, Some(msg.clone())); + + enriched.code = app_err.code.clone(); + enriched.metadata = app_err.metadata.clone(); + enriched.edit_policy = app_err.edit_policy; + enriched.retry = app_err.retry; + enriched.www_authenticate = app_err.www_authenticate.clone(); + #[cfg(feature = "serde_json")] + { + enriched.details = app_err.details.clone(); + } + #[cfg(not(feature = "serde_json"))] + { + enriched.details = app_err.details.clone(); + } + #[cfg(feature = "backtrace")] + let shared_backtrace = app_err.backtrace_shared(); + + #[cfg(feature = "backtrace")] + if let Some(backtrace) = shared_backtrace { + enriched = enriched.with_shared_backtrace(backtrace); + } + + enriched.with_context(app_err) + } + Err(source) => Error::internal(msg.clone()).with_source_arc(Arc::from(source)) + } + }) + } } #[cfg(test)] @@ -63,7 +133,7 @@ mod tests { use crate::app_error::{reset_backtrace_preference, set_backtrace_preference_override}; use crate::{ AppCode, AppErrorKind, - app_error::{Context, FieldValue, MessageEditPolicy}, + app_error::{Context, Error, FieldValue, MessageEditPolicy}, field }; @@ -219,4 +289,42 @@ mod tests { assert!(err.backtrace().is_some()); }); } + + #[test] + fn context_wraps_with_simple_message() { + let result: Result<(), DummyError> = Err(DummyError); + let err = result.context("operation failed").expect_err("err"); + + assert_eq!(err.kind, AppErrorKind::Internal); + assert!(err.source_ref().is_some()); + assert!(err.source_ref().unwrap().is::()); + } + + #[test] + fn context_preserves_app_error_classification() { + let base = Error::bad_request("missing flag") + .with_field(field::str("flag", "beta")) + .with_code(AppCode::Cache) + .redactable(); + + let err = Result::<(), Error>::Err(base) + .context("parsing configuration failed") + .expect_err("err"); + + assert_eq!(err.kind, AppErrorKind::BadRequest); + assert_eq!(err.code, AppCode::Cache); + assert_eq!(err.message.as_deref(), Some("parsing configuration failed")); + assert!(matches!(err.edit_policy, MessageEditPolicy::Redact)); + assert_eq!( + err.metadata().get("flag"), + Some(&FieldValue::Str(Cow::Borrowed("beta"))) + ); + + let source = err + .source_ref() + .and_then(|src| src.downcast_ref::()) + .expect("app error source"); + assert_eq!(source.kind, AppErrorKind::BadRequest); + assert_eq!(source.message.as_deref(), Some("missing flag")); + } }