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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

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

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 }
Expand Down
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
] }
Expand All @@ -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"
] }
Expand Down
2 changes: 1 addition & 1 deletion masterror-derive/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 2 additions & 0 deletions src/app_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
90 changes: 31 additions & 59 deletions src/app_error/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,57 +34,6 @@ pub enum MessageEditPolicy {
Redact
}

#[cfg(feature = "backtrace")]
#[derive(Debug)]
struct BacktraceSlot {
cell: OnceLock<Option<Backtrace>>
}

#[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 {
Expand All @@ -102,8 +51,6 @@ pub struct ErrorInner {
pub retry: Option<RetryAdvice>,
/// Optional authentication challenge for `WWW-Authenticate`.
pub www_authenticate: Option<String>,
source: Option<Arc<dyn StdError + Send + Sync + 'static>>,
backtrace: BacktraceSlot,
telemetry_dirty: AtomicBool
}

Expand Down Expand Up @@ -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<ErrorInner>
inner: Box<ErrorInner>,
source: Option<Arc<dyn StdError + Send + Sync + 'static>>,
#[cfg(feature = "backtrace")]
backtrace: Option<Backtrace>,
#[cfg(feature = "backtrace")]
captured_backtrace: OnceLock<Option<Backtrace>>
}

impl Deref for Error {
Expand Down Expand Up @@ -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()
}
}

Expand All @@ -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")]
Expand Down
69 changes: 69 additions & 0 deletions src/result_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<u8, DummyError> = Ok(5);
Expand Down Expand Up @@ -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::<LayeredError>());
source = source.source().expect("inner source");
assert!(source.is::<DummyError>());
}

#[derive(Debug, Clone)]
struct SharedError(Arc<InnerError>);

Expand Down Expand Up @@ -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());
});
}
}
4 changes: 3 additions & 1 deletion tests/masterror_macro.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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::<std::io::Error>());

assert_eq!(
MissingFlag::HTTP_MAPPING,
Expand Down
Loading