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

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
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.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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
] }
Expand All @@ -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"
] }
Expand Down
4 changes: 2 additions & 2 deletions src/app_error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
44 changes: 39 additions & 5 deletions src/app_error/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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<bool>) {
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<bool>) {
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<bool> {
match OVERRIDE_STATE.load(Ordering::Acquire) {
OVERRIDE_ENABLED => Some(true),
OVERRIDE_DISABLED => Some(false),
_ => None
}
}
}

/// Rich application error preserving domain code, taxonomy and metadata.
Expand Down Expand Up @@ -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.
Expand Down
19 changes: 8 additions & 11 deletions src/app_error/tests.rs
Original file line number Diff line number Diff line change
@@ -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(());
Expand Down Expand Up @@ -270,22 +270,19 @@ fn error_chain_is_preserved() {
}

#[cfg(feature = "backtrace")]
fn with_backtrace_env<F: FnOnce()>(value: Option<&str>, test: F) {
fn with_backtrace_preference<F: FnOnce()>(value: Option<bool>, 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());
});
Expand All @@ -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());
});
Expand Down
8 changes: 1 addition & 7 deletions src/convert/actix.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions src/convert/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
}

Expand Down
23 changes: 11 additions & 12 deletions src/convert/redis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,12 +94,8 @@ fn build_context(err: &RedisError) -> (Context, Option<u64>) {
.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));
Expand All @@ -109,14 +105,17 @@ fn build_context(err: &RedisError) -> (Context, Option<u64>) {
}

#[cfg(feature = "redis")]
const fn retry_after_hint(method: RetryMethod) -> Option<u64> {
const fn retry_method_details(method: RetryMethod) -> (&'static str, Option<u64>) {
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))
}
}

Expand Down
15 changes: 13 additions & 2 deletions src/convert/serde_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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(
Expand Down
29 changes: 24 additions & 5 deletions src/convert/sqlx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
}
}
}
}
Loading
Loading