Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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",
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions README.template.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 3 additions & 2 deletions docs/wiki/error-crate-comparison.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ fn load_configuration(path: &std::path::Path) -> masterror::AppResult<String> {
```

`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`

Expand Down
6 changes: 4 additions & 2 deletions docs/wiki/masterror-application-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions docs/wiki/patterns-and-troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
Expand Down
2 changes: 1 addition & 1 deletion src/app_error/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
50 changes: 50 additions & 0 deletions src/app_error/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<dyn CoreError + Send + Sync + 'static>),
Shared(Arc<dyn CoreError + Send + Sync + 'static>)
}

impl<E> From<E> 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;

Expand Down Expand Up @@ -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<ContextAttachment>) -> Self {
match context.into() {
ContextAttachment::Owned(source) => {
match source.downcast::<Arc<dyn CoreError + Send + Sync + 'static>>() {
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));
Expand Down
52 changes: 51 additions & 1 deletion src/app_error/tests.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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::<IoError>());
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::<AnyhowSource>()
.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");
Expand Down
9 changes: 9 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`].
Expand Down
Loading