From c45a55859568d2fdf3a01327336b2616f789e904 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 15:37:59 +0200 Subject: [PATCH 01/11] feat(macros): add FieldRole::Forward and parse #[aerro(forward)] field attr --- crates/aerro-macros/src/attrs.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/aerro-macros/src/attrs.rs b/crates/aerro-macros/src/attrs.rs index 0c295b6..8ce490b 100644 --- a/crates/aerro-macros/src/attrs.rs +++ b/crates/aerro-macros/src/attrs.rs @@ -76,6 +76,7 @@ pub enum FieldRole { Plain, Source, From, + Forward, } #[derive(Debug, Clone)] @@ -173,13 +174,17 @@ fn collect_field_cfg(field: &Field) -> syn::Result { } else if attr.path().is_ident("from") { role = FieldRole::From; } else if attr.path().is_ident("aerro") { - // field-level `#[aerro(redact)]` attr.parse_nested_meta(|meta| { if meta.path.is_ident("redact") { redact = true; Ok(()) + } else if meta.path.is_ident("forward") { + role = FieldRole::Forward; + Ok(()) } else { - Err(meta.error("field-level aerro attribute only supports `redact`")) + Err(meta.error( + "field-level aerro attribute only supports `redact` and `forward`", + )) } })?; } @@ -324,6 +329,25 @@ pub fn parse_variant(v: &Variant) -> syn::Result { .map(collect_field_cfg) .collect::>>()?; + let forward_count = fields + .iter() + .filter(|f| matches!(f.role, FieldRole::Forward)) + .count(); + if forward_count > 0 { + if fields.len() != 1 { + return Err(syn::Error::new_spanned( + v, + "`#[aerro(forward)]` variants must contain exactly one field", + )); + } + if parsed.from_catchall { + return Err(syn::Error::new_spanned( + v, + "`#[aerro(forward)]` cannot be combined with `from`", + )); + } + } + if let Some(ref fmt) = parsed.error { validate_error_fmt(fmt, is_tuple, &fields, v.span())?; } From 788536d144806f18578f59626a1e0065029fd763 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 15:38:20 +0200 Subject: [PATCH 02/11] feat(macros): decode treats Forward variants as opaque (falls back to RemoteError) --- crates/aerro-macros/src/codegen/aerro_impl.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/aerro-macros/src/codegen/aerro_impl.rs b/crates/aerro-macros/src/codegen/aerro_impl.rs index c9bed9f..7f9f40a 100644 --- a/crates/aerro-macros/src/codegen/aerro_impl.rs +++ b/crates/aerro-macros/src/codegen/aerro_impl.rs @@ -207,12 +207,12 @@ fn decode_payload_arm(v: &VariantCfg, type_id: &str) -> TokenStream { .iter() .filter(|f| matches!(f.role, FieldRole::Plain)) .collect(); - let has_source_or_from = v + let has_opaque_field = v .fields .iter() - .any(|f| matches!(f.role, FieldRole::Source | FieldRole::From)); + .any(|f| matches!(f.role, FieldRole::Source | FieldRole::From | FieldRole::Forward)); - if has_source_or_from { + if has_opaque_field { // Cannot reconstruct anyhow/eyre error from wire — fall back to RemoteError. return quote! { #type_id => { From ce47449b63e93fba8fef72bc0674f1e03e1f257a Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 15:39:01 +0200 Subject: [PATCH 03/11] feat(macros): Forward as source() + emit From> for ServiceFailure --- .../src/codegen/thiserror_glue.rs | 29 ++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/aerro-macros/src/codegen/thiserror_glue.rs b/crates/aerro-macros/src/codegen/thiserror_glue.rs index 896878b..627efbc 100644 --- a/crates/aerro-macros/src/codegen/thiserror_glue.rs +++ b/crates/aerro-macros/src/codegen/thiserror_glue.rs @@ -13,6 +13,7 @@ pub fn emit_display_and_error(cfg: &EnumCfg) -> TokenStream { let source_arms = cfg.variants.iter().map(source_arm); let from_impls = cfg.variants.iter().filter_map(|v| from_impl(enum_ident, v)); + let forward_impls = cfg.variants.iter().filter_map(|v| forward_impl(enum_ident, v)); quote! { impl ::core::fmt::Display for #enum_ident { @@ -32,6 +33,7 @@ pub fn emit_display_and_error(cfg: &EnumCfg) -> TokenStream { } #(#from_impls)* + #(#forward_impls)* } } @@ -98,7 +100,7 @@ fn source_arm(v: &VariantCfg) -> TokenStream { let src_idx = v .fields .iter() - .position(|f| matches!(f.role, FieldRole::Source | FieldRole::From)); + .position(|f| matches!(f.role, FieldRole::Source | FieldRole::From | FieldRole::Forward)); if let Some(idx) = src_idx { if v.is_tuple { @@ -170,6 +172,31 @@ fn from_impl(enum_ident: &Ident, v: &VariantCfg) -> Option { }) } +fn forward_impl(enum_ident: &Ident, v: &VariantCfg) -> Option { + let forward_field = v.fields.iter().find(|f| f.role == FieldRole::Forward)?; + let variant = &v.ident; + let ty = &forward_field.ty; + + let ctor = if v.is_tuple { + quote! { #enum_ident::#variant(__inner) } + } else if let Some(name) = &forward_field.ident { + quote! { #enum_ident::#variant { #name: __inner } } + } else { + return None; + }; + + Some(quote! { + impl ::core::convert::From<::aerro::ServiceFailure<#ty>> + for ::aerro::ServiceFailure<#enum_ident> + { + fn from(__sf: ::aerro::ServiceFailure<#ty>) -> Self { + let (__inner, __frames, __trace) = __sf.into_parts(); + ::aerro::ServiceFailure::from_parts(#ctor, __frames, __trace) + } + } + }) +} + // silence unused imports if the file is parsed standalone #[allow(dead_code)] fn _span_anchor() -> Span { From 3ec17e85934c32ef4f7824f58e8803aad81259f3 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 15:47:49 +0200 Subject: [PATCH 04/11] feat: add FromServiceFailure trait, forward() method, display fix, tests --- .../src/codegen/thiserror_glue.rs | 40 +++++++---- crates/aerro/src/failure.rs | 12 ++++ crates/aerro/src/lib.rs | 2 +- .../aerro/src/traits/from_service_failure.rs | 12 ++++ crates/aerro/src/traits/mod.rs | 2 + crates/aerro/tests/forward.rs | 70 +++++++++++++++++++ .../tests/ui/fail/forward_multiple_fields.rs | 12 ++++ .../ui/fail/forward_multiple_fields.stderr | 9 +++ 8 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 crates/aerro/src/traits/from_service_failure.rs create mode 100644 crates/aerro/tests/forward.rs create mode 100644 crates/aerro/tests/ui/fail/forward_multiple_fields.rs create mode 100644 crates/aerro/tests/ui/fail/forward_multiple_fields.stderr diff --git a/crates/aerro-macros/src/codegen/thiserror_glue.rs b/crates/aerro-macros/src/codegen/thiserror_glue.rs index 627efbc..8ee0d69 100644 --- a/crates/aerro-macros/src/codegen/thiserror_glue.rs +++ b/crates/aerro-macros/src/codegen/thiserror_glue.rs @@ -54,25 +54,40 @@ fn display_arm(enum_ident: &Ident, v: &VariantCfg) -> TokenStream { } if v.is_tuple { + // Bind Plain fields for positional format args; use _ for Source/From/Forward + // so they don't become stray unused arguments in write!(). let pat_idents: Vec = v .fields .iter() .enumerate() - .map(|(i, _)| format_ident!("__f{}", i)) + .map(|(i, f)| { + if matches!(f.role, FieldRole::Plain) { + format_ident!("__f{}", i) + } else { + format_ident!("_") + } + }) + .collect(); + let plain_idents: Vec = v + .fields + .iter() + .enumerate() + .filter_map(|(i, f)| { + if matches!(f.role, FieldRole::Plain) { + Some(format_ident!("__f{}", i)) + } else { + None + } + }) .collect(); let pat = quote! { ( #(#pat_idents),* ) }; if has_explicit_fmt { quote! { - Self::#variant #pat => ::core::write!(__f, #fmt_string, #(#pat_idents),*), + Self::#variant #pat => ::core::write!(__f, #fmt_string, #(#plain_idents),*), } } else { - // Default snake_case name — fields go unused. - let _suppress = pat_idents.iter().map(|i| quote! { let _ = #i; }); quote! { - Self::#variant #pat => { - #(#_suppress)* - ::core::write!(__f, #fmt_string) - } + Self::#variant #pat => ::core::write!(__f, #fmt_string), } } } else { @@ -185,11 +200,12 @@ fn forward_impl(enum_ident: &Ident, v: &VariantCfg) -> Option { return None; }; + // Implement the local trait on the local enum type — avoids the orphan rule + // that prevents `impl From> for ServiceFailure` in + // downstream crates. Use `sf.forward::()` to perform the conversion. Some(quote! { - impl ::core::convert::From<::aerro::ServiceFailure<#ty>> - for ::aerro::ServiceFailure<#enum_ident> - { - fn from(__sf: ::aerro::ServiceFailure<#ty>) -> Self { + impl ::aerro::FromServiceFailure<#ty> for #enum_ident { + fn from_failure(__sf: ::aerro::ServiceFailure<#ty>) -> ::aerro::ServiceFailure { let (__inner, __frames, __trace) = __sf.into_parts(); ::aerro::ServiceFailure::from_parts(#ctor, __frames, __trace) } diff --git a/crates/aerro/src/failure.rs b/crates/aerro/src/failure.rs index e66dd59..95926fa 100644 --- a/crates/aerro/src/failure.rs +++ b/crates/aerro/src/failure.rs @@ -9,6 +9,7 @@ use smallvec::SmallVec; use crate::{Aerro, Frame, trace::TraceContext}; +use crate::traits::FromServiceFailure; #[derive(Debug)] pub(crate) struct ServiceFailureInner { @@ -105,6 +106,17 @@ impl From for ServiceFailure { } } +impl ServiceFailure { + /// Convert this failure into a `ServiceFailure` by way of a forwarding + /// variant on `O` that was declared with `#[aerro(forward)]`. + /// + /// Frames and trace context are preserved — no manual [`Frame::local`] push + /// needed at the forwarding boundary. + pub fn forward>(self) -> ServiceFailure { + O::from_failure(self) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/aerro/src/lib.rs b/crates/aerro/src/lib.rs index 71aea2f..3c4a4f6 100644 --- a/crates/aerro/src/lib.rs +++ b/crates/aerro/src/lib.rs @@ -76,7 +76,7 @@ pub mod ext; pub mod wire; pub use ext::{ResultIntoStatusExt, StatusIntoResultExt}; -pub use traits::{IntoStatus, TryFromStatus}; +pub use traits::{FromServiceFailure, IntoStatus, TryFromStatus}; pub use wire::decode::decode; pub use wire::encode::{EncodeOptions, encode}; diff --git a/crates/aerro/src/traits/from_service_failure.rs b/crates/aerro/src/traits/from_service_failure.rs new file mode 100644 index 0000000..c4ef77c --- /dev/null +++ b/crates/aerro/src/traits/from_service_failure.rs @@ -0,0 +1,12 @@ +use crate::{Aerro, ServiceFailure}; + +/// Conversion from a downstream `ServiceFailure` into a `ServiceFailure`, +/// transferring the accumulated frame chain automatically. +/// +/// Implement this trait (or derive it via `#[aerro(forward)]`) on an outer error +/// enum to express that one of its variants wraps errors from a downstream service. +/// The [`From`] impl on [`ServiceFailure`] is provided automatically via a blanket +/// impl whenever this trait is implemented. +pub trait FromServiceFailure: Aerro + Sized { + fn from_failure(sf: ServiceFailure) -> ServiceFailure; +} diff --git a/crates/aerro/src/traits/mod.rs b/crates/aerro/src/traits/mod.rs index c182175..d72057c 100644 --- a/crates/aerro/src/traits/mod.rs +++ b/crates/aerro/src/traits/mod.rs @@ -1,7 +1,9 @@ pub mod aerro; +pub mod from_service_failure; pub mod into_status; pub mod try_from_status; pub use aerro::Aerro; +pub use from_service_failure::FromServiceFailure; pub use into_status::IntoStatus; pub use try_from_status::TryFromStatus; diff --git a/crates/aerro/tests/forward.rs b/crates/aerro/tests/forward.rs new file mode 100644 index 0000000..544e38e --- /dev/null +++ b/crates/aerro/tests/forward.rs @@ -0,0 +1,70 @@ +//! Integration tests for `#[aerro(forward)]` field attribute. +//! Verifies that `.forward()` transfers frames from the inner ServiceFailure to the outer one. + +#![cfg(feature = "macro")] + +use aerro::{Category, Frame, ServiceFailure}; +use tonic::Code; + +#[derive(Debug, aerro::Aerro)] +enum Inner { + #[aerro(category = System, code = Internal, error = "inner.fail")] + Fail, +} + +#[derive(Debug, aerro::Aerro)] +enum Outer { + #[aerro(category = System, code = Internal, error = "outer.wrapped")] + Wrapped(#[aerro(forward)] Inner), +} + +#[test] +fn forward_transfers_frames() { + let mut sf_inner: ServiceFailure = Inner::Fail.into(); + sf_inner.frames_mut().push(Frame::local( + "svc-a", + "rpc-a", + Code::Internal, + "inner msg", + Category::System, + )); + + let sf_outer: ServiceFailure = sf_inner.forward(); + + assert_eq!(sf_outer.frames().len(), 1); + assert_eq!(sf_outer.frames()[0].service, "svc-a"); + assert_eq!(sf_outer.frames()[0].message, "inner msg"); + assert!(matches!(sf_outer.inner(), Outer::Wrapped(_))); +} + +#[test] +fn empty_frames_transfer_cleanly() { + let sf_inner: ServiceFailure = Inner::Fail.into(); + let sf_outer: ServiceFailure = sf_inner.forward(); + assert!(sf_outer.frames().is_empty()); +} + +#[test] +fn multiple_frames_all_transfer() { + let mut sf_inner: ServiceFailure = Inner::Fail.into(); + sf_inner.frames_mut().push(Frame::local( + "svc-a", "rpc-a", Code::Internal, "first", Category::System, + )); + sf_inner.frames_mut().push(Frame::local( + "svc-b", "rpc-b", Code::Internal, "second", Category::System, + )); + + let sf_outer: ServiceFailure = sf_inner.forward(); + + assert_eq!(sf_outer.frames().len(), 2); + assert_eq!(sf_outer.frames()[0].service, "svc-a"); + assert_eq!(sf_outer.frames()[1].service, "svc-b"); +} + +#[test] +fn source_chain_is_accessible() { + use std::error::Error; + let sf: ServiceFailure = ServiceFailure::new(Outer::Wrapped(Inner::Fail)); + let src = sf.inner().source(); + assert!(src.is_some()); +} diff --git a/crates/aerro/tests/ui/fail/forward_multiple_fields.rs b/crates/aerro/tests/ui/fail/forward_multiple_fields.rs new file mode 100644 index 0000000..758cbe7 --- /dev/null +++ b/crates/aerro/tests/ui/fail/forward_multiple_fields.rs @@ -0,0 +1,12 @@ +use aerro; + +#[derive(Debug, aerro::Aerro)] +pub enum Bad { + #[aerro(category = System, code = Internal)] + Broken( + #[aerro(forward)] String, + String, + ), +} + +fn main() {} diff --git a/crates/aerro/tests/ui/fail/forward_multiple_fields.stderr b/crates/aerro/tests/ui/fail/forward_multiple_fields.stderr new file mode 100644 index 0000000..eff147b --- /dev/null +++ b/crates/aerro/tests/ui/fail/forward_multiple_fields.stderr @@ -0,0 +1,9 @@ +error: `#[aerro(forward)]` variants must contain exactly one field + --> tests/ui/fail/forward_multiple_fields.rs:5:5 + | +5 | / #[aerro(category = System, code = Internal)] +6 | | Broken( +7 | | #[aerro(forward)] String, +8 | | String, +9 | | ), + | |_____^ From f2acaae467aff50acd65a25b3b6dabf07cc894b0 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 15:52:16 +0200 Subject: [PATCH 05/11] example: rewrite trace_chain to use #[aerro(forward)] + .forward() ergonomics --- crates/aerro/examples/trace_chain.rs | 99 ++++++++++++++++++---------- 1 file changed, 64 insertions(+), 35 deletions(-) diff --git a/crates/aerro/examples/trace_chain.rs b/crates/aerro/examples/trace_chain.rs index 3132c08..6ba2808 100644 --- a/crates/aerro/examples/trace_chain.rs +++ b/crates/aerro/examples/trace_chain.rs @@ -1,30 +1,55 @@ //! 3-hop trace accumulation: backend (B) → middleware (M) → gateway (G). -//! Each hop appends a frame; the final `RemoteError` shows the full chain. - -// `tonic::Status` is ~176 bytes; we return `Result<_, Status>` here because -// that is the shape tonic gives RPC handlers. The example does the same. +//! +//! Demonstrates how `#[aerro(forward)]` + `.forward()` makes the gateway hop +//! ergonomic: no manual frame push needed to transfer the upstream chain. +//! +//! Constraint: Forward variants are opaque on decode. Therefore only the +//! *innermost* services (B, M) should use typed plain errors; the *outermost* +//! aggregating service (G) uses Forward to collect them. The final client +//! receives a `RemoteError` with the full accumulated frame list. use aerro::wire::encode::EncodeOptions; -use aerro::{Category, Frame, IntoStatus, ServiceFailure, StatusIntoResultExt}; +use aerro::{Aerro, Category, Frame, IntoStatus, ServiceFailure, StatusIntoResultExt}; use tonic::Code; -#[derive(Debug, aerro::Aerro)] +// ── Service error types ────────────────────────────────────────────────────── + +/// Innermost service — plain typed errors, no Forward. +#[derive(Debug, Aerro)] pub enum Pipeline { #[aerro(category = System, code = Internal, error = "backend.unreachable")] Unreachable, } +/// Middle service — plain typed errors, no Forward (so Gateway can decode them). +#[derive(Debug, Aerro)] +pub enum Relay { + #[aerro(category = System, code = Internal, error = "relay.pipeline_failed")] + PipelineFailed, +} + +/// Outermost aggregating service — uses Forward to collect upstream errors. +/// Forward variants decode as RemoteError on the client side, but the full +/// frame chain is preserved. +#[derive(Debug, Aerro)] +pub enum Gateway { + #[aerro(category = System, code = Internal, error = "gateway.relay_failed")] + RelayFailed(#[aerro(forward)] Relay), +} + +// ── Simulated service call chain ───────────────────────────────────────────── + fn backend() -> Result<(), Pipeline> { Err(Pipeline::Unreachable) } #[allow(clippy::result_large_err)] -fn middleware() -> Result<(), tonic::Status> { +fn relay() -> Result<(), tonic::Status> { match backend() { Ok(v) => Ok(v), - Err(e) => { - let mut sf: ServiceFailure = e.into(); - sf.frames_mut().push(Frame::local( + Err(_) => { + let mut sf = ServiceFailure::new(Relay::PipelineFailed); + sf.push_frame(Frame::local( "backend", "ping", Code::Internal, @@ -36,41 +61,45 @@ fn middleware() -> Result<(), tonic::Status> { } } +/// Gateway decodes Relay errors typed (Relay has no Forward variants), +/// then uses `.forward()` — the upstream frames transfer automatically. #[allow(clippy::result_large_err)] fn gateway() -> Result<(), tonic::Status> { - match middleware() { - Ok(v) => Ok(v), - Err(st) => { - let mut sf = st.into_aerro::().expect("typed"); - sf.frames_mut().push(Frame::local( - "middleware", - "forward", - Code::Internal, - "wrapped", - Category::System, - )); - Err(sf.into_status(&EncodeOptions::default())) - } - } -} + let sf: ServiceFailure = relay() + .map_err(|st| st.into_aerro::().expect("typed")) + .unwrap_err(); -fn main() { - let st = gateway().unwrap_err(); - let sf = st.into_aerro::().expect("typed at gateway"); - let mut sf = sf; - sf.frames_mut().push(Frame::local( - "gateway", - "handle", + // .forward() transfers the Relay frames into the Gateway ServiceFailure. + let mut sf: ServiceFailure = sf.forward(); + + // Optionally annotate the forwarding hop itself. + sf.push_frame(Frame::local( + "relay", + "forward", Code::Internal, - "client-side", + "relay.pipeline_failed", Category::System, )); + Err(sf.into_status(&EncodeOptions::default())) +} + +// ── Client ─────────────────────────────────────────────────────────────────── + +fn main() { + let st = gateway().unwrap_err(); + + // Gateway.RelayFailed is a Forward variant — opaque on decode. + // The client receives a RemoteError with the full frame chain. + let re = st + .into_aerro::() + .expect_err("Forward variants decode as RemoteError"); + println!("frames on final hop:"); - for (i, f) in sf.frames().iter().enumerate() { + for (i, f) in re.frames().iter().enumerate() { println!( " {i}: service={} rpc={} code={:?} msg={}", f.service, f.rpc, f.code, f.message ); } - println!("trace_id={:02x?}", sf.trace().trace_id); + println!("trace_id={:02x?}", re.trace().trace_id); } From 6305cf6b82b0b016d44a52777ac8a14bdbb8aef3 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 16:11:09 +0200 Subject: [PATCH 06/11] =?UTF-8?q?example:=20remove=20redundant=20error=3D?= =?UTF-8?q?=20attrs=20=E2=80=94=20show=20they=20are=20optional?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/aerro/examples/basic.rs | 2 +- crates/aerro/examples/trace_chain.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/aerro/examples/basic.rs b/crates/aerro/examples/basic.rs index 11cd0d9..36f66c9 100644 --- a/crates/aerro/examples/basic.rs +++ b/crates/aerro/examples/basic.rs @@ -12,7 +12,7 @@ pub enum CreateUser { )] EmailTaken { email: String }, - #[aerro(category = System, code = Internal, error = "create_user.boom")] + #[aerro(category = System, code = Internal)] Boom, } diff --git a/crates/aerro/examples/trace_chain.rs b/crates/aerro/examples/trace_chain.rs index 6ba2808..fae7434 100644 --- a/crates/aerro/examples/trace_chain.rs +++ b/crates/aerro/examples/trace_chain.rs @@ -24,7 +24,7 @@ pub enum Pipeline { /// Middle service — plain typed errors, no Forward (so Gateway can decode them). #[derive(Debug, Aerro)] pub enum Relay { - #[aerro(category = System, code = Internal, error = "relay.pipeline_failed")] + #[aerro(category = System, code = Internal)] PipelineFailed, } @@ -33,7 +33,7 @@ pub enum Relay { /// frame chain is preserved. #[derive(Debug, Aerro)] pub enum Gateway { - #[aerro(category = System, code = Internal, error = "gateway.relay_failed")] + #[aerro(category = System, code = Internal)] RelayFailed(#[aerro(forward)] Relay), } From 9e6e43f1bb67e0ad0589ce54a68408f54e9baea9 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 16:13:08 +0200 Subject: [PATCH 07/11] example: add OTel tracing example showing trace_id/span_id in action --- crates/aerro/Cargo.toml | 7 +++++ crates/aerro/examples/tracing.rs | 54 ++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 crates/aerro/examples/tracing.rs diff --git a/crates/aerro/Cargo.toml b/crates/aerro/Cargo.toml index c080a65..4615130 100644 --- a/crates/aerro/Cargo.toml +++ b/crates/aerro/Cargo.toml @@ -36,6 +36,9 @@ tokio = { version = "1", features = ["macros", "rt-multi-thread"] } dhat = { workspace = true } criterion = { workspace = true } trybuild = { workspace = true } +opentelemetry_sdk = { version = "0.26", features = ["trace"] } +opentelemetry-stdout = { version = "0.26" } +tracing-subscriber = { version = "0.3", features = ["registry"] } [[bench]] name = "error_path" @@ -53,3 +56,7 @@ required-features = ["macro"] name = "exposure" required-features = ["macro"] +[[example]] +name = "tracing" +required-features = ["macro", "tracing"] + diff --git a/crates/aerro/examples/tracing.rs b/crates/aerro/examples/tracing.rs new file mode 100644 index 0000000..0b052f2 --- /dev/null +++ b/crates/aerro/examples/tracing.rs @@ -0,0 +1,54 @@ +//! Shows that aerro automatically captures the active OTel span's trace ID +//! and span ID when an error is created. The IDs printed by aerro match +//! those shown in the exported span — enabling error-to-trace correlation. +//! +//! Run with: cargo run --example tracing --features macro,tracing + +use aerro::{Aerro, IntoStatus, StatusIntoResultExt}; +use opentelemetry::trace::TracerProvider as _; +use opentelemetry_sdk::trace::TracerProvider as SdkTracerProvider; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[derive(Debug, Aerro)] +pub enum ApiError { + #[aerro(category = Business, code = NotFound)] + UserNotFound, +} + +fn init_tracing() -> SdkTracerProvider { + let exporter = opentelemetry_stdout::SpanExporter::default(); + let provider = SdkTracerProvider::builder() + .with_simple_exporter(exporter) + .build(); + let tracer = provider.tracer("aerro-tracing-example"); + let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer); + tracing_subscriber::registry().with(otel_layer).init(); + provider +} + +fn main() { + let provider = init_tracing(); + + // Scope the span so it is dropped (and exported) before we print or shutdown. + let (trace_id, span_id) = { + let span = tracing::info_span!("handle_get_user"); + let status = { + let _enter = span.enter(); + ApiError::UserNotFound.into_status_default() + }; + let sf = status.into_aerro::().unwrap(); + let t = sf.trace(); + (t.trace_id, t.span_id) + // span dropped here → SimpleSpanProcessor exports it synchronously + }; + + // Flush remaining spans (none here, but good practice). + let _ = provider.shutdown(); + + // Print after shutdown so the exported span JSON appears first. + println!(); + println!("aerro trace_id = {:032x}", u128::from_be_bytes(trace_id)); + println!("aerro span_id = {:016x}", u64::from_be_bytes(span_id)); + println!(); + println!("The trace_id and span_id above should match the span exported above."); +} From bf78af916291e8f19c7be426529297a46af1d30f Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 16:16:22 +0200 Subject: [PATCH 08/11] feat!: unify category+code attrs into code = Category::GrpcCode path syntax Replace the two-key `category = X, code = Y` grammar with a single two-segment path `code = Category::GrpcCode`. The parser splits the path at `::` to derive both category and gRPC code; bare single-segment paths emit a clear error pointing to the expected form. --- crates/aerro-macros/src/attrs.rs | 21 +++++++++++-------- crates/aerro/benches/error_path.rs | 2 +- crates/aerro/examples/basic.rs | 5 ++--- crates/aerro/examples/exposure.rs | 3 +-- crates/aerro/examples/trace_chain.rs | 6 +++--- crates/aerro/examples/tracing.rs | 2 +- crates/aerro/src/lib.rs | 4 ++-- crates/aerro/tests/forward.rs | 4 ++-- crates/aerro/tests/macro_basic.rs | 8 +++---- crates/aerro/tests/polyglot.rs | 4 ++-- crates/aerro/tests/proto_gen.rs | 2 +- crates/aerro/tests/roundtrip.rs | 8 +++---- .../tests/ui/fail/code_missing_category.rs | 9 ++++++++ .../ui/fail/code_missing_category.stderr | 5 +++++ .../tests/ui/fail/forward_multiple_fields.rs | 2 +- .../ui/fail/forward_multiple_fields.stderr | 2 +- crates/aerro/tests/ui/fail/missing_code.rs | 2 +- .../aerro/tests/ui/fail/missing_code.stderr | 4 ++-- crates/aerro/tests/ui/fail/missing_debug.rs | 2 +- .../aerro/tests/ui/fail/missing_debug.stderr | 12 +++++------ crates/aerro/tests/ui/fail/system_public.rs | 2 +- .../aerro/tests/ui/fail/system_public.stderr | 2 +- crates/aerro/tests/ui/fail/unknown_attr.rs | 2 +- .../aerro/tests/ui/fail/unknown_attr.stderr | 6 +++--- crates/aerro/tests/ui/pass/basic.rs | 6 +++--- 25 files changed, 68 insertions(+), 57 deletions(-) create mode 100644 crates/aerro/tests/ui/fail/code_missing_category.rs create mode 100644 crates/aerro/tests/ui/fail/code_missing_category.stderr diff --git a/crates/aerro-macros/src/attrs.rs b/crates/aerro-macros/src/attrs.rs index 8ce490b..65c0470 100644 --- a/crates/aerro-macros/src/attrs.rs +++ b/crates/aerro-macros/src/attrs.rs @@ -135,13 +135,16 @@ fn parse_variant_attr(attr: &Attribute) -> syn::Result { .ok_or_else(|| meta.error("expected an identifier"))? .to_string(); match key.as_str() { - "category" => { - let value: Ident = meta.value()?.parse()?; - out.category = Some(value); - } "code" => { - let value: Ident = meta.value()?.parse()?; - out.code = Some(value); + let path: syn::Path = meta.value()?.parse()?; + let segs: Vec<_> = path.segments.iter().collect(); + if segs.len() != 2 { + return Err(meta.error( + "`code` must be a two-segment path, e.g. `Business::NotFound` or `System::Internal`", + )); + } + out.category = Some(segs[0].ident.clone()); + out.code = Some(segs[1].ident.clone()); } "exposure" => { let value: Ident = meta.value()?.parse()?; @@ -280,7 +283,7 @@ pub fn parse_variant(v: &Variant) -> syn::Result { return Err(syn::Error::new_spanned( v, format!( - "variant `{}` is missing `#[aerro(category = Business, code = AlreadyExists)]`", + "variant `{}` is missing `#[aerro(code = Business::AlreadyExists)]`", v.ident ), )); @@ -296,7 +299,7 @@ pub fn parse_variant(v: &Variant) -> syn::Result { let category_ident = parsed.category.ok_or_else(|| { syn::Error::new_spanned( v, - "missing `category = Business` (one of: Business, System, Validation, Transport)", + "missing `code = Business::NotFound` (two-segment path: Category::GrpcCode)", ) })?; let category = CategoryAttr::from_ident(&category_ident)?; @@ -304,7 +307,7 @@ pub fn parse_variant(v: &Variant) -> syn::Result { let code_ident = parsed.code.ok_or_else(|| { syn::Error::new_spanned( v, - "missing `code = AlreadyExists` (PascalCase tonic::Code variant name)", + "missing `code = Business::NotFound` (two-segment path: Category::GrpcCode)", ) })?; diff --git a/crates/aerro/benches/error_path.rs b/crates/aerro/benches/error_path.rs index df228b4..95b1917 100644 --- a/crates/aerro/benches/error_path.rs +++ b/crates/aerro/benches/error_path.rs @@ -17,7 +17,7 @@ use criterion::{Criterion, black_box, criterion_group, criterion_main}; #[cfg(feature = "macro")] #[derive(Debug, aerro::Aerro)] pub enum Bench { - #[aerro(category = Business, code = AlreadyExists, error = "x={x} y={y}")] + #[aerro(code = Business::AlreadyExists, error = "x={x} y={y}")] Item { x: u64, y: String }, } diff --git a/crates/aerro/examples/basic.rs b/crates/aerro/examples/basic.rs index 36f66c9..f67fb41 100644 --- a/crates/aerro/examples/basic.rs +++ b/crates/aerro/examples/basic.rs @@ -6,13 +6,12 @@ use aerro::{Aerro, IntoStatus, StatusIntoResultExt}; #[derive(Debug, aerro::Aerro)] pub enum CreateUser { #[aerro( - category = Business, - code = AlreadyExists, + code = Business::AlreadyExists, error = "email already taken: {email}" )] EmailTaken { email: String }, - #[aerro(category = System, code = Internal)] + #[aerro(code = System::Internal)] Boom, } diff --git a/crates/aerro/examples/exposure.rs b/crates/aerro/examples/exposure.rs index dbb857a..19f913a 100644 --- a/crates/aerro/examples/exposure.rs +++ b/crates/aerro/examples/exposure.rs @@ -7,8 +7,7 @@ use aerro::{Exposure, IntoStatus}; #[derive(Debug, aerro::Aerro)] pub enum Db { #[aerro( - category = System, - code = Internal, + code = System::Internal, error = "db.unreachable: {host}" )] Unreachable { host: String }, diff --git a/crates/aerro/examples/trace_chain.rs b/crates/aerro/examples/trace_chain.rs index fae7434..fe36799 100644 --- a/crates/aerro/examples/trace_chain.rs +++ b/crates/aerro/examples/trace_chain.rs @@ -17,14 +17,14 @@ use tonic::Code; /// Innermost service — plain typed errors, no Forward. #[derive(Debug, Aerro)] pub enum Pipeline { - #[aerro(category = System, code = Internal, error = "backend.unreachable")] + #[aerro(code = System::Internal, error = "backend.unreachable")] Unreachable, } /// Middle service — plain typed errors, no Forward (so Gateway can decode them). #[derive(Debug, Aerro)] pub enum Relay { - #[aerro(category = System, code = Internal)] + #[aerro(code = System::Internal)] PipelineFailed, } @@ -33,7 +33,7 @@ pub enum Relay { /// frame chain is preserved. #[derive(Debug, Aerro)] pub enum Gateway { - #[aerro(category = System, code = Internal)] + #[aerro(code = System::Internal)] RelayFailed(#[aerro(forward)] Relay), } diff --git a/crates/aerro/examples/tracing.rs b/crates/aerro/examples/tracing.rs index 0b052f2..6fc7eb2 100644 --- a/crates/aerro/examples/tracing.rs +++ b/crates/aerro/examples/tracing.rs @@ -11,7 +11,7 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[derive(Debug, Aerro)] pub enum ApiError { - #[aerro(category = Business, code = NotFound)] + #[aerro(code = Business::NotFound)] UserNotFound, } diff --git a/crates/aerro/src/lib.rs b/crates/aerro/src/lib.rs index 3c4a4f6..1329e1c 100644 --- a/crates/aerro/src/lib.rs +++ b/crates/aerro/src/lib.rs @@ -13,10 +13,10 @@ //! //! #[derive(Debug, aerro::Aerro)] //! pub enum CreateUserError { -//! #[aerro(category = Business, code = AlreadyExists, error = "email already taken: {email}")] +//! #[aerro(code = Business::AlreadyExists, error = "email already taken: {email}")] //! EmailTaken { email: String }, //! -//! #[aerro(category = System, code = Internal, error = "db.unavailable")] +//! #[aerro(code = System::Internal)] //! DbUnavailable, //! } //! diff --git a/crates/aerro/tests/forward.rs b/crates/aerro/tests/forward.rs index 544e38e..226fc0f 100644 --- a/crates/aerro/tests/forward.rs +++ b/crates/aerro/tests/forward.rs @@ -8,13 +8,13 @@ use tonic::Code; #[derive(Debug, aerro::Aerro)] enum Inner { - #[aerro(category = System, code = Internal, error = "inner.fail")] + #[aerro(code = System::Internal, error = "inner.fail")] Fail, } #[derive(Debug, aerro::Aerro)] enum Outer { - #[aerro(category = System, code = Internal, error = "outer.wrapped")] + #[aerro(code = System::Internal, error = "outer.wrapped")] Wrapped(#[aerro(forward)] Inner), } diff --git a/crates/aerro/tests/macro_basic.rs b/crates/aerro/tests/macro_basic.rs index 0fc4be6..3fe90c4 100644 --- a/crates/aerro/tests/macro_basic.rs +++ b/crates/aerro/tests/macro_basic.rs @@ -10,20 +10,18 @@ use tonic::Code; #[derive(Debug, aerro::Aerro)] pub enum CreateUser { #[aerro( - category = Business, - code = AlreadyExists, + code = Business::AlreadyExists, error = "email already taken: {email}" )] EmailTaken { email: String }, #[aerro( - category = Validation, - code = InvalidArgument, + code = Validation::InvalidArgument, error = "invalid name: {0}" )] InvalidName(String), - #[aerro(category = System, code = Internal, error = "create_user.boom")] + #[aerro(code = System::Internal, error = "create_user.boom")] Boom, } diff --git a/crates/aerro/tests/polyglot.rs b/crates/aerro/tests/polyglot.rs index af68391..34a6a3e 100644 --- a/crates/aerro/tests/polyglot.rs +++ b/crates/aerro/tests/polyglot.rs @@ -10,10 +10,10 @@ use tonic::Code; #[derive(Debug, aerro::Aerro)] pub enum Api { - #[aerro(category = Business, code = NotFound, error = "user not found")] + #[aerro(code = Business::NotFound, error = "user not found")] NotFound, - #[aerro(category = System, code = Internal, error = "internal crash")] + #[aerro(code = System::Internal, error = "internal crash")] Boom, } diff --git a/crates/aerro/tests/proto_gen.rs b/crates/aerro/tests/proto_gen.rs index 5e76f3b..23f1ff4 100644 --- a/crates/aerro/tests/proto_gen.rs +++ b/crates/aerro/tests/proto_gen.rs @@ -9,7 +9,7 @@ use tonic::Code; #[derive(Debug, aerro::Aerro)] pub enum Ping { - #[aerro(category = Business, code = AlreadyExists, error = "ping")] + #[aerro(code = Business::AlreadyExists, error = "ping")] Pong, } diff --git a/crates/aerro/tests/roundtrip.rs b/crates/aerro/tests/roundtrip.rs index 74894b2..20c4ef4 100644 --- a/crates/aerro/tests/roundtrip.rs +++ b/crates/aerro/tests/roundtrip.rs @@ -9,16 +9,16 @@ use tonic::Code; #[derive(Debug, aerro::Aerro)] pub enum Suite { - #[aerro(category = Business, code = AlreadyExists, error = "biz: {0}")] + #[aerro(code = Business::AlreadyExists, error = "biz: {0}")] Biz(String), - #[aerro(category = Validation, code = InvalidArgument, error = "val: {0}")] + #[aerro(code = Validation::InvalidArgument, error = "val: {0}")] Val(String), - #[aerro(category = System, code = Internal, error = "sys")] + #[aerro(code = System::Internal, error = "sys")] Sys, - #[aerro(category = Transport, code = Unavailable, error = "trans")] + #[aerro(code = Transport::Unavailable, error = "trans")] Trans, } diff --git a/crates/aerro/tests/ui/fail/code_missing_category.rs b/crates/aerro/tests/ui/fail/code_missing_category.rs new file mode 100644 index 0000000..d6e3c04 --- /dev/null +++ b/crates/aerro/tests/ui/fail/code_missing_category.rs @@ -0,0 +1,9 @@ +use aerro; + +#[derive(aerro::Aerro)] +pub enum E { + #[aerro(code = NotFound)] + Bad, +} + +fn main() {} diff --git a/crates/aerro/tests/ui/fail/code_missing_category.stderr b/crates/aerro/tests/ui/fail/code_missing_category.stderr new file mode 100644 index 0000000..a445f78 --- /dev/null +++ b/crates/aerro/tests/ui/fail/code_missing_category.stderr @@ -0,0 +1,5 @@ +error: `code` must be a two-segment path, e.g. `Business::NotFound` or `System::Internal` + --> tests/ui/fail/code_missing_category.rs:5:13 + | +5 | #[aerro(code = NotFound)] + | ^^^^^^^^^^^^^^^ diff --git a/crates/aerro/tests/ui/fail/forward_multiple_fields.rs b/crates/aerro/tests/ui/fail/forward_multiple_fields.rs index 758cbe7..3e51e2a 100644 --- a/crates/aerro/tests/ui/fail/forward_multiple_fields.rs +++ b/crates/aerro/tests/ui/fail/forward_multiple_fields.rs @@ -2,7 +2,7 @@ use aerro; #[derive(Debug, aerro::Aerro)] pub enum Bad { - #[aerro(category = System, code = Internal)] + #[aerro(code = System::Internal)] Broken( #[aerro(forward)] String, String, diff --git a/crates/aerro/tests/ui/fail/forward_multiple_fields.stderr b/crates/aerro/tests/ui/fail/forward_multiple_fields.stderr index eff147b..379d276 100644 --- a/crates/aerro/tests/ui/fail/forward_multiple_fields.stderr +++ b/crates/aerro/tests/ui/fail/forward_multiple_fields.stderr @@ -1,7 +1,7 @@ error: `#[aerro(forward)]` variants must contain exactly one field --> tests/ui/fail/forward_multiple_fields.rs:5:5 | -5 | / #[aerro(category = System, code = Internal)] +5 | / #[aerro(code = System::Internal)] 6 | | Broken( 7 | | #[aerro(forward)] String, 8 | | String, diff --git a/crates/aerro/tests/ui/fail/missing_code.rs b/crates/aerro/tests/ui/fail/missing_code.rs index f84b560..c77e3d4 100644 --- a/crates/aerro/tests/ui/fail/missing_code.rs +++ b/crates/aerro/tests/ui/fail/missing_code.rs @@ -2,7 +2,7 @@ use aerro; #[derive(aerro::Aerro)] pub enum E { - #[aerro(category = Business)] + #[aerro(exposure = Internal)] NoCode, } diff --git a/crates/aerro/tests/ui/fail/missing_code.stderr b/crates/aerro/tests/ui/fail/missing_code.stderr index 38c5f73..95d6d55 100644 --- a/crates/aerro/tests/ui/fail/missing_code.stderr +++ b/crates/aerro/tests/ui/fail/missing_code.stderr @@ -1,6 +1,6 @@ -error: missing `code = AlreadyExists` (PascalCase tonic::Code variant name) +error: missing `code = Business::NotFound` (two-segment path: Category::GrpcCode) --> tests/ui/fail/missing_code.rs:5:5 | -5 | / #[aerro(category = Business)] +5 | / #[aerro(exposure = Internal)] 6 | | NoCode, | |__________^ diff --git a/crates/aerro/tests/ui/fail/missing_debug.rs b/crates/aerro/tests/ui/fail/missing_debug.rs index 37c53f5..a6de0d3 100644 --- a/crates/aerro/tests/ui/fail/missing_debug.rs +++ b/crates/aerro/tests/ui/fail/missing_debug.rs @@ -1,6 +1,6 @@ #[derive(aerro::Aerro)] pub enum Foo { - #[aerro(category = Business, code = NotFound, error = "not found")] + #[aerro(code = Business::NotFound, error = "not found")] NotFound, } diff --git a/crates/aerro/tests/ui/fail/missing_debug.stderr b/crates/aerro/tests/ui/fail/missing_debug.stderr index e758148..d3ca7a2 100644 --- a/crates/aerro/tests/ui/fail/missing_debug.stderr +++ b/crates/aerro/tests/ui/fail/missing_debug.stderr @@ -7,6 +7,9 @@ error[E0277]: `Foo` doesn't implement `Debug` = note: add `#[derive(Debug)]` to `Foo` or manually `impl Debug for Foo` note: required by a bound in `std::error::Error` --> $RUST/core/src/error.rs + | + | pub trait Error: Debug + Display { + | ^^^^^ required by this bound in `Error` help: consider annotating `Foo` with `#[derive(Debug)]` | 2 + #[derive(Debug)] @@ -20,19 +23,14 @@ error[E0277]: `Foo` doesn't implement `Debug` | ^^^ the trait `Debug` is not implemented for `Foo` | = note: add `#[derive(Debug)]` to `Foo` or manually `impl Debug for Foo` -help: the trait `std::error::Error` is not implemented for `Foo` - but trait `Error` is implemented for it - --> tests/ui/fail/missing_debug.rs:1:10 - | -1 | #[derive(aerro::Aerro)] - | ^^^^^^^^^^^^ + = help: the trait `std::error::Error` is not implemented for `Foo` + but trait `Error` is implemented for it = note: required for `Foo` to implement `std::error::Error` note: required by a bound in `Aerro` --> src/traits/aerro.rs | | pub trait Aerro: std::error::Error + Send + Sync + 'static { | ^^^^^^^^^^^^^^^^^ required by this bound in `Aerro` - = note: this error originates in the derive macro `aerro::Aerro` (in Nightly builds, run with -Z macro-backtrace for more info) help: consider annotating `Foo` with `#[derive(Debug)]` | 2 + #[derive(Debug)] diff --git a/crates/aerro/tests/ui/fail/system_public.rs b/crates/aerro/tests/ui/fail/system_public.rs index 2c5e3b8..49a39b0 100644 --- a/crates/aerro/tests/ui/fail/system_public.rs +++ b/crates/aerro/tests/ui/fail/system_public.rs @@ -2,7 +2,7 @@ use aerro; #[derive(aerro::Aerro)] pub enum E { - #[aerro(category = System, code = Internal, exposure = Public)] + #[aerro(code = System::Internal, exposure = Public)] Bad, } diff --git a/crates/aerro/tests/ui/fail/system_public.stderr b/crates/aerro/tests/ui/fail/system_public.stderr index 418c943..08b16dd 100644 --- a/crates/aerro/tests/ui/fail/system_public.stderr +++ b/crates/aerro/tests/ui/fail/system_public.stderr @@ -1,6 +1,6 @@ error: variants with `category = System` cannot be `exposure = Public` — server defects must never be public-by-default --> tests/ui/fail/system_public.rs:5:5 | -5 | / #[aerro(category = System, code = Internal, exposure = Public)] +5 | / #[aerro(code = System::Internal, exposure = Public)] 6 | | Bad, | |_______^ diff --git a/crates/aerro/tests/ui/fail/unknown_attr.rs b/crates/aerro/tests/ui/fail/unknown_attr.rs index 0620190..7f8c85b 100644 --- a/crates/aerro/tests/ui/fail/unknown_attr.rs +++ b/crates/aerro/tests/ui/fail/unknown_attr.rs @@ -2,7 +2,7 @@ use aerro; #[derive(aerro::Aerro)] pub enum E { - #[aerro(category = Business, code = NotFound, nonsense = "x")] + #[aerro(code = Business::NotFound, nonsense = "x")] Bad, } diff --git a/crates/aerro/tests/ui/fail/unknown_attr.stderr b/crates/aerro/tests/ui/fail/unknown_attr.stderr index f239456..f8e64fb 100644 --- a/crates/aerro/tests/ui/fail/unknown_attr.stderr +++ b/crates/aerro/tests/ui/fail/unknown_attr.stderr @@ -1,5 +1,5 @@ error: unknown aerro attribute `nonsense` - --> tests/ui/fail/unknown_attr.rs:5:51 + --> tests/ui/fail/unknown_attr.rs:5:40 | -5 | #[aerro(category = Business, code = NotFound, nonsense = "x")] - | ^^^^^^^^ +5 | #[aerro(code = Business::NotFound, nonsense = "x")] + | ^^^^^^^^ diff --git a/crates/aerro/tests/ui/pass/basic.rs b/crates/aerro/tests/ui/pass/basic.rs index 420b98c..ee24819 100644 --- a/crates/aerro/tests/ui/pass/basic.rs +++ b/crates/aerro/tests/ui/pass/basic.rs @@ -2,13 +2,13 @@ use aerro; #[derive(Debug, aerro::Aerro)] pub enum E { - #[aerro(category = Business, code = NotFound, error = "x not found")] + #[aerro(code = Business::NotFound, error = "x not found")] NotFound, - #[aerro(category = Validation, code = InvalidArgument)] + #[aerro(code = Validation::InvalidArgument)] Bad(String), - #[aerro(category = System, code = Internal)] + #[aerro(code = System::Internal)] Boom, } From eb648fbbf0d42eb62350d588c8a9cefa6650fc64 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 16:32:43 +0200 Subject: [PATCH 09/11] better encore ergonomics --- crates/aerro/benches/error_path.rs | 8 +-- crates/aerro/examples/basic.rs | 25 ++++--- crates/aerro/examples/exposure.rs | 8 +-- crates/aerro/examples/trace_chain.rs | 39 +++++----- crates/aerro/src/convert.rs | 83 ++++++++++++++++++++++ crates/aerro/src/ext.rs | 54 -------------- crates/aerro/src/lib.rs | 19 ++--- crates/aerro/src/traits/into_status.rs | 37 ---------- crates/aerro/src/traits/mod.rs | 4 -- crates/aerro/src/traits/try_from_status.rs | 22 ------ crates/aerro/tests/macro_basic.rs | 15 ++-- crates/aerro/tests/polyglot.rs | 12 ++-- crates/aerro/tests/roundtrip.rs | 26 +++---- 13 files changed, 161 insertions(+), 191 deletions(-) create mode 100644 crates/aerro/src/convert.rs delete mode 100644 crates/aerro/src/traits/into_status.rs delete mode 100644 crates/aerro/src/traits/try_from_status.rs diff --git a/crates/aerro/benches/error_path.rs b/crates/aerro/benches/error_path.rs index 95b1917..91954cb 100644 --- a/crates/aerro/benches/error_path.rs +++ b/crates/aerro/benches/error_path.rs @@ -10,7 +10,7 @@ fn main() { #[cfg(feature = "macro")] use aerro::wire::encode::EncodeOptions; #[cfg(feature = "macro")] -use aerro::{IntoStatus, StatusIntoResultExt}; +use aerro::{AerroEncode, ServiceFailure}; #[cfg(feature = "macro")] use criterion::{Criterion, black_box, criterion_group, criterion_main}; @@ -32,7 +32,7 @@ fn bench_encode(c: &mut Criterion) { x: 42, y: "hello-world".into(), }; - black_box(v.into_status(black_box(&opts))); + black_box(v.encode(black_box(&opts))); }); }); @@ -47,7 +47,7 @@ fn bench_decode(c: &mut Criterion) { x: 42, y: "hello-world".into(), } - .into_status(&opts); + .encode(&opts); group.bench_function("aerro_bincode", |b| { b.iter(|| { @@ -56,7 +56,7 @@ fn bench_decode(c: &mut Criterion) { encoded_status.message(), bytes::Bytes::copy_from_slice(encoded_status.details()), ); - black_box(st.into_aerro::().unwrap()); + black_box(ServiceFailure::::try_from(st).unwrap()); }); }); diff --git a/crates/aerro/examples/basic.rs b/crates/aerro/examples/basic.rs index f67fb41..1e6039a 100644 --- a/crates/aerro/examples/basic.rs +++ b/crates/aerro/examples/basic.rs @@ -1,10 +1,10 @@ //! One enum, one round-trip across the wire — the simplest possible aerro usage. use aerro::wire::encode::EncodeOptions; -use aerro::{Aerro, IntoStatus, StatusIntoResultExt}; +use aerro::{Aerro, AerroEncode, ServiceFailure}; #[derive(Debug, aerro::Aerro)] -pub enum CreateUser { +pub enum CreateUserError { #[aerro( code = Business::AlreadyExists, error = "email already taken: {email}" @@ -17,10 +17,10 @@ pub enum CreateUser { fn main() { // Server side: a typed failure (explicit options). - let err = CreateUser::EmailTaken { + let err = CreateUserError::EmailTaken { email: "alice@example.com".into(), }; - let status = err.into_status(&EncodeOptions::default()); + let status = err.encode(&EncodeOptions::default()); println!( "server emitted: code={:?} message={:?}", status.code(), @@ -28,9 +28,9 @@ fn main() { ); println!("details() length: {} bytes", status.details().len()); - // Server side: same thing with the convenience shorthand. - let err2 = CreateUser::Boom; - let status2 = err2.into_status_default(); + // Server side: same thing with default options. + let err2 = CreateUserError::Boom; + let status2 = err2.encode(&EncodeOptions::default()); println!( "server emitted (default): code={:?} message={:?}", status2.code(), @@ -38,11 +38,14 @@ fn main() { ); // Client side: recover the typed variant. - let recovered = status.into_aerro::().unwrap(); + let recovered = ServiceFailure::::try_from(status).unwrap(); match recovered.into_inner() { - CreateUser::EmailTaken { email } => println!("client recovered: email={email}"), - CreateUser::Boom => unreachable!(), + CreateUserError::EmailTaken { email } => println!("client recovered: email={email}"), + CreateUserError::Boom => unreachable!(), } - println!("type_ids known to CreateUser: {:?}", CreateUser::TYPE_IDS); + println!( + "type_ids known to CreateUserError: {:?}", + CreateUserError::TYPE_IDS + ); } diff --git a/crates/aerro/examples/exposure.rs b/crates/aerro/examples/exposure.rs index 19f913a..33c925c 100644 --- a/crates/aerro/examples/exposure.rs +++ b/crates/aerro/examples/exposure.rs @@ -2,10 +2,10 @@ //! ships to its audience. use aerro::wire::encode::EncodeOptions; -use aerro::{Exposure, IntoStatus}; +use aerro::{AerroEncode, Exposure}; #[derive(Debug, aerro::Aerro)] -pub enum Db { +pub enum DbError { #[aerro( code = System::Internal, error = "db.unreachable: {host}" @@ -14,10 +14,10 @@ pub enum Db { } fn show(label: &str, exposure: Exposure) { - let err = Db::Unreachable { + let err = DbError::Unreachable { host: "prod-shard-42.internal".into(), }; - let st = err.into_status(&EncodeOptions { + let st = err.encode(&EncodeOptions { exposure, max_frames: 16, }); diff --git a/crates/aerro/examples/trace_chain.rs b/crates/aerro/examples/trace_chain.rs index fe36799..7c051fb 100644 --- a/crates/aerro/examples/trace_chain.rs +++ b/crates/aerro/examples/trace_chain.rs @@ -9,38 +9,38 @@ //! receives a `RemoteError` with the full accumulated frame list. use aerro::wire::encode::EncodeOptions; -use aerro::{Aerro, Category, Frame, IntoStatus, ServiceFailure, StatusIntoResultExt}; +use aerro::{Aerro, Category, Frame, ServiceFailure}; use tonic::Code; // ── Service error types ────────────────────────────────────────────────────── /// Innermost service — plain typed errors, no Forward. #[derive(Debug, Aerro)] -pub enum Pipeline { +pub enum PipelineError { #[aerro(code = System::Internal, error = "backend.unreachable")] Unreachable, } -/// Middle service — plain typed errors, no Forward (so Gateway can decode them). +/// Middle service — plain typed errors, no Forward (so GatewayError can decode them). #[derive(Debug, Aerro)] -pub enum Relay { +pub enum RelayError { #[aerro(code = System::Internal)] - PipelineFailed, + PipelineErrorFailed, } /// Outermost aggregating service — uses Forward to collect upstream errors. /// Forward variants decode as RemoteError on the client side, but the full /// frame chain is preserved. #[derive(Debug, Aerro)] -pub enum Gateway { +pub enum GatewayError { #[aerro(code = System::Internal)] - RelayFailed(#[aerro(forward)] Relay), + RelayErrorFailed(#[aerro(forward)] RelayError), } // ── Simulated service call chain ───────────────────────────────────────────── -fn backend() -> Result<(), Pipeline> { - Err(Pipeline::Unreachable) +fn backend() -> Result<(), PipelineError> { + Err(PipelineError::Unreachable) } #[allow(clippy::result_large_err)] @@ -48,7 +48,7 @@ fn relay() -> Result<(), tonic::Status> { match backend() { Ok(v) => Ok(v), Err(_) => { - let mut sf = ServiceFailure::new(Relay::PipelineFailed); + let mut sf = ServiceFailure::new(RelayError::PipelineErrorFailed); sf.push_frame(Frame::local( "backend", "ping", @@ -56,21 +56,21 @@ fn relay() -> Result<(), tonic::Status> { "backend.unreachable", Category::System, )); - Err(sf.into_status(&EncodeOptions::default())) + Err(sf.encode(&EncodeOptions::default())) } } } -/// Gateway decodes Relay errors typed (Relay has no Forward variants), +/// GatewayError decodes RelayError errors typed (RelayError has no Forward variants), /// then uses `.forward()` — the upstream frames transfer automatically. #[allow(clippy::result_large_err)] fn gateway() -> Result<(), tonic::Status> { - let sf: ServiceFailure = relay() - .map_err(|st| st.into_aerro::().expect("typed")) + let sf: ServiceFailure = relay() + .map_err(|st| ServiceFailure::::try_from(st).expect("typed")) .unwrap_err(); - // .forward() transfers the Relay frames into the Gateway ServiceFailure. - let mut sf: ServiceFailure = sf.forward(); + // .forward() transfers the RelayError frames into the GatewayError ServiceFailure. + let mut sf: ServiceFailure = sf.forward(); // Optionally annotate the forwarding hop itself. sf.push_frame(Frame::local( @@ -80,7 +80,7 @@ fn gateway() -> Result<(), tonic::Status> { "relay.pipeline_failed", Category::System, )); - Err(sf.into_status(&EncodeOptions::default())) + Err(sf.encode(&EncodeOptions::default())) } // ── Client ─────────────────────────────────────────────────────────────────── @@ -88,10 +88,9 @@ fn gateway() -> Result<(), tonic::Status> { fn main() { let st = gateway().unwrap_err(); - // Gateway.RelayFailed is a Forward variant — opaque on decode. + // GatewayError.RelayErrorFailed is a Forward variant — opaque on decode. // The client receives a RemoteError with the full frame chain. - let re = st - .into_aerro::() + let re = ServiceFailure::::try_from(st) .expect_err("Forward variants decode as RemoteError"); println!("frames on final hop:"); diff --git a/crates/aerro/src/convert.rs b/crates/aerro/src/convert.rs new file mode 100644 index 0000000..184dda5 --- /dev/null +++ b/crates/aerro/src/convert.rs @@ -0,0 +1,83 @@ +//! Standard conversion traits between `ServiceFailure` and `tonic::Status`. + +use tonic::Status; + +use crate::wire::encode::EncodeOptions; +use crate::{Aerro, RemoteError, ServiceFailure}; + +impl TryFrom for ServiceFailure { + type Error = RemoteError; + + fn try_from(status: Status) -> Result { + crate::wire::decode::decode::(status) + } +} + +impl From> for Status { + fn from(sf: ServiceFailure) -> Self { + crate::wire::encode::encode(&sf, &EncodeOptions::default()) + } +} + +/// Extension trait that adds `.encode()` to every `E: Aerro`, mirroring the +/// method already available on [`ServiceFailure`]. +pub trait AerroEncode: Aerro + Sized { + fn encode(self, opts: &EncodeOptions) -> Status { + ServiceFailure::new(self).encode(opts) + } +} + +impl AerroEncode for E {} + +impl ServiceFailure { + pub fn encode(self, opts: &EncodeOptions) -> Status { + crate::wire::encode::encode(&self, opts) + } +} + +#[cfg(test)] +mod tests { + use super::AerroEncode; + use crate::test_support::Boom; + use crate::wire::encode::EncodeOptions; + use crate::{RemoteError, ServiceFailure}; + use std::convert::TryFrom; + + #[test] + fn error_encode_method_roundtrips() { + let status = Boom { x: 99 }.encode(&EncodeOptions::default()); + let recovered = ServiceFailure::::try_from(status).unwrap(); + assert_eq!(recovered.inner().x, 99); + } + + #[test] + fn try_from_status_recovers_typed_failure() { + let sf = ServiceFailure::new(Boom { x: 1 }); + let status: tonic::Status = sf.encode(&EncodeOptions::default()); + let recovered = ServiceFailure::::try_from(status).unwrap(); + assert_eq!(recovered.inner().x, 1); + } + + #[test] + fn try_from_status_returns_remote_error_for_unknown_type() { + let status = tonic::Status::internal("raw error"); + let result = ServiceFailure::::try_from(status); + assert!(matches!(result, Err(RemoteError { .. }))); + } + + #[test] + fn service_failure_into_status_and_back_via_standard_traits() { + let sf = ServiceFailure::new(Boom { x: 2 }); + let status: tonic::Status = sf.into(); + let recovered: ServiceFailure = status.try_into().unwrap(); + assert_eq!(recovered.inner().x, 2); + } + + #[test] + fn encode_with_opts_roundtrips() { + let sf = ServiceFailure::new(Boom { x: 3 }); + let status = sf.encode(&EncodeOptions::default()); + let recovered: ServiceFailure = status.try_into().unwrap(); + assert_eq!(recovered.inner().x, 3); + } +} diff --git a/crates/aerro/src/ext.rs b/crates/aerro/src/ext.rs index 94cc47a..b29681e 100644 --- a/crates/aerro/src/ext.rs +++ b/crates/aerro/src/ext.rs @@ -1,55 +1 @@ //! Convenience extension traits for sites that want neither layer nor macro. - -use tonic::Status; - -use crate::wire::encode::EncodeOptions; -use crate::{Aerro, IntoStatus, RemoteError, ServiceFailure, TryFromStatus}; - -/// Convenience method to encode a `Result` into a `Result` -/// without manually calling [`IntoStatus`]. -pub trait ResultIntoStatusExt { - // `tonic::Status` is ~176 bytes; we return it because tonic's RPC surface - // requires it. The lint can't be honored here without breaking interop. - #[allow(clippy::result_large_err)] - /// Encode the `Err` variant into a `tonic::Status`; pass `Ok` through unchanged. - fn into_status_ext(self, opts: &EncodeOptions) -> Result; -} - -impl ResultIntoStatusExt for Result { - #[allow(clippy::result_large_err)] - fn into_status_ext(self, opts: &EncodeOptions) -> Result { - self.map_err(|e| e.into_status(opts)) - } -} - -/// Extension trait on `tonic::Status` to attempt typed error recovery. -pub trait StatusIntoResultExt { - /// Try to decode the status into a [`ServiceFailure`](ServiceFailure), - /// falling back to a [`RemoteError`] if the type ID is unknown. - fn into_aerro(self) -> Result, RemoteError>; -} - -impl StatusIntoResultExt for Status { - fn into_aerro(self) -> Result, RemoteError> { - >::try_from_status(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::test_support::Boom; - - #[test] - fn result_map_to_status() { - let r: Result<(), Boom> = Err(Boom { x: 1 }); - assert!(r.into_status_ext(&EncodeOptions::default()).is_err()); - } - - #[test] - fn status_into_aerro_recovers() { - let st = Boom { x: 2 }.into_status(&EncodeOptions::default()); - let sf = st.into_aerro::().unwrap(); - assert_eq!(sf.inner().x, 2); - } -} diff --git a/crates/aerro/src/lib.rs b/crates/aerro/src/lib.rs index 1329e1c..77ae5bf 100644 --- a/crates/aerro/src/lib.rs +++ b/crates/aerro/src/lib.rs @@ -1,15 +1,15 @@ //! Cross-service gRPC errors for Rust. //! //! `aerro` gives every error a **typed identity**, a **bounded call trace**, and -//! a **structured wire encoding**. Derive [`Aerro`] on an error enum, call -//! [`IntoStatus`] to encode it into a `tonic::Status`, and [`TryFromStatus`] -//! on the client to recover the original variant — with the full chain of service +//! a **structured wire encoding**. Derive [`Aerro`] on an error enum, encode it +//! into a `tonic::Status` via `From`/`Into`, and recover the original variant on +//! the client side with `TryFrom`/`TryInto` — with the full chain of service //! hops attached. //! //! # Quick Example //! -//! ```rust -//! use aerro::{Aerro, IntoStatus, StatusIntoResultExt}; +//! ```rust,ignore +//! use aerro::{Aerro, ServiceFailure}; //! //! #[derive(Debug, aerro::Aerro)] //! pub enum CreateUserError { @@ -22,10 +22,10 @@ //! //! // Server side //! let err = CreateUserError::EmailTaken { email: "alice@example.com".into() }; -//! let status = err.into_status_default(); +//! let status: tonic::Status = ServiceFailure::from(err).into(); //! //! // Client side — recover the typed variant -//! let recovered = status.into_aerro::().unwrap(); +//! let recovered = ServiceFailure::::try_from(status).unwrap(); //! ``` //! //! # Feature Flags @@ -72,11 +72,12 @@ pub use traits::Aerro; #[cfg(feature = "macro")] pub use aerro_macros::Aerro; +pub mod convert; pub mod ext; pub mod wire; -pub use ext::{ResultIntoStatusExt, StatusIntoResultExt}; -pub use traits::{FromServiceFailure, IntoStatus, TryFromStatus}; +pub use convert::AerroEncode; +pub use traits::FromServiceFailure; pub use wire::decode::decode; pub use wire::encode::{EncodeOptions, encode}; diff --git a/crates/aerro/src/traits/into_status.rs b/crates/aerro/src/traits/into_status.rs deleted file mode 100644 index 54c2dcc..0000000 --- a/crates/aerro/src/traits/into_status.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! `IntoStatus` — convert anything implementing `Aerro` into a `tonic::Status`. - -use tonic::Status; - -use crate::wire::encode::{EncodeOptions, encode}; -use crate::{Aerro, ServiceFailure}; - -/// Convert a typed error or [`ServiceFailure`] into a `tonic::Status` for -/// transmission over gRPC. -/// -/// Encoding applies exposure redaction and embeds the aerro envelope in the -/// status `details()` field. See [`EncodeOptions`] to control the egress -/// exposure tier and frame cap. -pub trait IntoStatus { - /// Encode `self` into a `tonic::Status` using the given options. - fn into_status(self, opts: &EncodeOptions) -> Status; - - /// Encode `self` into a `tonic::Status` using [`EncodeOptions::default()`]. - fn into_status_default(self) -> Status - where - Self: Sized, - { - self.into_status(&EncodeOptions::default()) - } -} - -impl IntoStatus for E { - fn into_status(self, opts: &EncodeOptions) -> Status { - encode(&ServiceFailure::new(self), opts) - } -} - -impl IntoStatus for ServiceFailure { - fn into_status(self, opts: &EncodeOptions) -> Status { - encode(&self, opts) - } -} diff --git a/crates/aerro/src/traits/mod.rs b/crates/aerro/src/traits/mod.rs index d72057c..c25f2e1 100644 --- a/crates/aerro/src/traits/mod.rs +++ b/crates/aerro/src/traits/mod.rs @@ -1,9 +1,5 @@ pub mod aerro; pub mod from_service_failure; -pub mod into_status; -pub mod try_from_status; pub use aerro::Aerro; pub use from_service_failure::FromServiceFailure; -pub use into_status::IntoStatus; -pub use try_from_status::TryFromStatus; diff --git a/crates/aerro/src/traits/try_from_status.rs b/crates/aerro/src/traits/try_from_status.rs deleted file mode 100644 index fc14872..0000000 --- a/crates/aerro/src/traits/try_from_status.rs +++ /dev/null @@ -1,22 +0,0 @@ -//! `TryFromStatus` — recover a typed failure from a `tonic::Status`. - -use tonic::Status; - -use crate::wire::decode::decode; -use crate::{Aerro, RemoteError, ServiceFailure}; - -/// Attempt to decode a `tonic::Status` back into a typed -/// [`ServiceFailure`](crate::ServiceFailure). -/// -/// Returns `Err(RemoteError)` if the embedded type ID is not in `E::TYPE_IDS` -/// (the error came from an unknown service or error type). -pub trait TryFromStatus: Sized { - /// Decode a status, recovering the original `E` variant when the type ID matches. - fn try_from_status(status: Status) -> Result, RemoteError>; -} - -impl TryFromStatus for ServiceFailure { - fn try_from_status(status: Status) -> Result, RemoteError> { - decode::(status) - } -} diff --git a/crates/aerro/tests/macro_basic.rs b/crates/aerro/tests/macro_basic.rs index 3fe90c4..d66f450 100644 --- a/crates/aerro/tests/macro_basic.rs +++ b/crates/aerro/tests/macro_basic.rs @@ -4,7 +4,7 @@ #![cfg(feature = "macro")] use aerro::wire::encode::EncodeOptions; -use aerro::{Aerro, Category, Exposure, IntoStatus, ServiceFailure, StatusIntoResultExt}; +use aerro::{Aerro, AerroEncode, Category, Exposure, ServiceFailure}; use tonic::Code; #[derive(Debug, aerro::Aerro)] @@ -85,8 +85,8 @@ fn struct_variant_roundtrips_via_wire() { let st = CreateUser::EmailTaken { email: "alice@x".into(), } - .into_status(&EncodeOptions::default()); - let sf: ServiceFailure = st.into_aerro::().unwrap(); + .encode(&EncodeOptions::default()); + let sf: ServiceFailure = ServiceFailure::try_from(st).unwrap(); match sf.into_inner() { CreateUser::EmailTaken { email } => assert_eq!(email, "alice@x"), _ => panic!("wrong variant"), @@ -95,8 +95,9 @@ fn struct_variant_roundtrips_via_wire() { #[test] fn tuple_variant_roundtrips_via_wire() { - let st = CreateUser::InvalidName("bob".into()).into_status(&EncodeOptions::default()); - let sf: ServiceFailure = st.into_aerro::().unwrap(); + let st = + CreateUser::InvalidName("bob".into()).encode(&EncodeOptions::default()); + let sf: ServiceFailure = ServiceFailure::try_from(st).unwrap(); match sf.into_inner() { CreateUser::InvalidName(s) => assert_eq!(s, "bob"), _ => panic!("wrong variant"), @@ -105,7 +106,7 @@ fn tuple_variant_roundtrips_via_wire() { #[test] fn unit_variant_roundtrips_via_wire() { - let st = CreateUser::Boom.into_status(&EncodeOptions::default()); - let sf: ServiceFailure = st.into_aerro::().unwrap(); + let st = CreateUser::Boom.encode(&EncodeOptions::default()); + let sf: ServiceFailure = ServiceFailure::try_from(st).unwrap(); assert!(matches!(sf.inner(), CreateUser::Boom)); } diff --git a/crates/aerro/tests/polyglot.rs b/crates/aerro/tests/polyglot.rs index 34a6a3e..2188307 100644 --- a/crates/aerro/tests/polyglot.rs +++ b/crates/aerro/tests/polyglot.rs @@ -4,8 +4,8 @@ #![cfg(feature = "macro")] -use aerro::IntoStatus; use aerro::wire::encode::EncodeOptions; +use aerro::{AerroEncode, Exposure}; use tonic::Code; #[derive(Debug, aerro::Aerro)] @@ -19,8 +19,8 @@ pub enum Api { #[test] fn bare_tonic_consumer_sees_correct_code_and_message_internal() { - let st = Api::NotFound.into_status(&EncodeOptions { - exposure: aerro::Exposure::Internal, + let st = Api::NotFound.encode(&EncodeOptions { + exposure: Exposure::Internal, max_frames: 16, }); // A consumer that knows nothing about aerro only inspects code + message. @@ -30,8 +30,8 @@ fn bare_tonic_consumer_sees_correct_code_and_message_internal() { #[test] fn bare_tonic_consumer_sees_redacted_message_at_public_for_system() { - let st = Api::Boom.into_status(&EncodeOptions { - exposure: aerro::Exposure::Public, + let st = Api::Boom.encode(&EncodeOptions { + exposure: Exposure::Public, max_frames: 16, }); assert_eq!(st.code(), Code::Internal); @@ -40,7 +40,7 @@ fn bare_tonic_consumer_sees_redacted_message_at_public_for_system() { #[test] fn details_bytes_are_additive_not_required() { - let st = Api::NotFound.into_status(&EncodeOptions::default()); + let st = Api::NotFound.encode(&EncodeOptions::default()); // The details() carry the aerro envelope, but the consumer is free to // ignore them — code + message alone are well-defined. assert!(!st.details().is_empty()); diff --git a/crates/aerro/tests/roundtrip.rs b/crates/aerro/tests/roundtrip.rs index 20c4ef4..54096fa 100644 --- a/crates/aerro/tests/roundtrip.rs +++ b/crates/aerro/tests/roundtrip.rs @@ -4,7 +4,7 @@ #![cfg(feature = "macro")] use aerro::wire::encode::EncodeOptions; -use aerro::{Aerro, Category, Exposure, IntoStatus, StatusIntoResultExt}; +use aerro::{Aerro, AerroEncode, Category, Exposure, ServiceFailure}; use tonic::Code; #[derive(Debug, aerro::Aerro)] @@ -31,8 +31,8 @@ fn opts(exposure: Exposure) -> EncodeOptions { #[test] fn business_internal_roundtrips_payload() { - let st = Suite::Biz("dup".into()).into_status(&opts(Exposure::Internal)); - let sf = st.into_aerro::().unwrap(); + let st = Suite::Biz("dup".into()).encode(&opts(Exposure::Internal)); + let sf = ServiceFailure::::try_from(st).unwrap(); match sf.into_inner() { Suite::Biz(s) => assert_eq!(s, "dup"), _ => panic!(), @@ -41,33 +41,33 @@ fn business_internal_roundtrips_payload() { #[test] fn validation_public_roundtrips_payload_and_keeps_message() { - let st = Suite::Val("bad".into()).into_status(&opts(Exposure::Public)); + let st = Suite::Val("bad".into()).encode(&opts(Exposure::Public)); assert_eq!(st.code(), Code::InvalidArgument); assert_eq!(st.message(), "val: bad"); // Validation is safe at Public. - let sf = st.into_aerro::().unwrap(); + let sf = ServiceFailure::::try_from(st).unwrap(); assert!(matches!(sf.inner(), Suite::Val(_))); } #[test] fn system_public_redacts_message_but_payload_still_decodes() { - let st = Suite::Sys.into_status(&opts(Exposure::Public)); + let st = Suite::Sys.encode(&opts(Exposure::Public)); assert_eq!(st.code(), Code::Internal); assert_eq!(st.message(), "internal error"); // The envelope still carries the type_id and decodes typed: - let sf = st.into_aerro::().unwrap(); + let sf = ServiceFailure::::try_from(st).unwrap(); assert_eq!(sf.inner().category(), Category::System); } #[test] fn transport_trusted_keeps_message() { - let st = Suite::Trans.into_status(&opts(Exposure::Trusted)); + let st = Suite::Trans.encode(&opts(Exposure::Trusted)); assert_eq!(st.code(), Code::Unavailable); assert_eq!(st.message(), "trans"); } #[test] fn public_drops_frames_internal_keeps_them() { - use aerro::{Frame, ServiceFailure}; + use aerro::Frame; let mut sf: ServiceFailure = Suite::Sys.into(); sf.frames_mut().push(Frame::local( "svc", @@ -77,12 +77,12 @@ fn public_drops_frames_internal_keeps_them() { Category::System, )); - let st_pub = sf.clone_for_test().into_status(&opts(Exposure::Public)); - let pub_decoded = st_pub.into_aerro::().unwrap(); + let st_pub = sf.clone_for_test().encode(&opts(Exposure::Public)); + let pub_decoded = ServiceFailure::::try_from(st_pub).unwrap(); assert!(pub_decoded.frames().is_empty(), "Public must drop frames"); - let st_int = sf.into_status(&opts(Exposure::Internal)); - let int_decoded = st_int.into_aerro::().unwrap(); + let st_int = sf.encode(&opts(Exposure::Internal)); + let int_decoded = ServiceFailure::::try_from(st_int).unwrap(); assert_eq!(int_decoded.frames().len(), 1, "Internal must keep frames"); } From ea70d1f3d80b74f53527db9db66a6aec3360cf9b Mon Sep 17 00:00:00 2001 From: ae2rs Date: Tue, 26 May 2026 16:54:42 +0200 Subject: [PATCH 10/11] encore uses default and add encore_with_opts --- crates/aerro/benches/error_path.rs | 4 ++-- crates/aerro/examples/basic.rs | 5 ++--- crates/aerro/examples/exposure.rs | 2 +- crates/aerro/examples/trace_chain.rs | 5 ++--- crates/aerro/examples/tracing.rs | 6 +++--- crates/aerro/src/convert.rs | 25 ++++++++++++++++--------- crates/aerro/tests/macro_basic.rs | 7 +++---- crates/aerro/tests/polyglot.rs | 6 +++--- crates/aerro/tests/roundtrip.rs | 12 ++++++------ 9 files changed, 38 insertions(+), 34 deletions(-) diff --git a/crates/aerro/benches/error_path.rs b/crates/aerro/benches/error_path.rs index 91954cb..d24dc9c 100644 --- a/crates/aerro/benches/error_path.rs +++ b/crates/aerro/benches/error_path.rs @@ -32,7 +32,7 @@ fn bench_encode(c: &mut Criterion) { x: 42, y: "hello-world".into(), }; - black_box(v.encode(black_box(&opts))); + black_box(v.encode_with_opts(black_box(&opts))); }); }); @@ -47,7 +47,7 @@ fn bench_decode(c: &mut Criterion) { x: 42, y: "hello-world".into(), } - .encode(&opts); + .encode_with_opts(&opts); group.bench_function("aerro_bincode", |b| { b.iter(|| { diff --git a/crates/aerro/examples/basic.rs b/crates/aerro/examples/basic.rs index 1e6039a..5b078bb 100644 --- a/crates/aerro/examples/basic.rs +++ b/crates/aerro/examples/basic.rs @@ -1,6 +1,5 @@ //! One enum, one round-trip across the wire — the simplest possible aerro usage. -use aerro::wire::encode::EncodeOptions; use aerro::{Aerro, AerroEncode, ServiceFailure}; #[derive(Debug, aerro::Aerro)] @@ -20,7 +19,7 @@ fn main() { let err = CreateUserError::EmailTaken { email: "alice@example.com".into(), }; - let status = err.encode(&EncodeOptions::default()); + let status = err.encode(); println!( "server emitted: code={:?} message={:?}", status.code(), @@ -30,7 +29,7 @@ fn main() { // Server side: same thing with default options. let err2 = CreateUserError::Boom; - let status2 = err2.encode(&EncodeOptions::default()); + let status2 = err2.encode(); println!( "server emitted (default): code={:?} message={:?}", status2.code(), diff --git a/crates/aerro/examples/exposure.rs b/crates/aerro/examples/exposure.rs index 33c925c..8a9f36d 100644 --- a/crates/aerro/examples/exposure.rs +++ b/crates/aerro/examples/exposure.rs @@ -17,7 +17,7 @@ fn show(label: &str, exposure: Exposure) { let err = DbError::Unreachable { host: "prod-shard-42.internal".into(), }; - let st = err.encode(&EncodeOptions { + let st = err.encode_with_opts(&EncodeOptions { exposure, max_frames: 16, }); diff --git a/crates/aerro/examples/trace_chain.rs b/crates/aerro/examples/trace_chain.rs index 7c051fb..f76e3c4 100644 --- a/crates/aerro/examples/trace_chain.rs +++ b/crates/aerro/examples/trace_chain.rs @@ -8,7 +8,6 @@ //! aggregating service (G) uses Forward to collect them. The final client //! receives a `RemoteError` with the full accumulated frame list. -use aerro::wire::encode::EncodeOptions; use aerro::{Aerro, Category, Frame, ServiceFailure}; use tonic::Code; @@ -56,7 +55,7 @@ fn relay() -> Result<(), tonic::Status> { "backend.unreachable", Category::System, )); - Err(sf.encode(&EncodeOptions::default())) + Err(sf.encode()) } } } @@ -80,7 +79,7 @@ fn gateway() -> Result<(), tonic::Status> { "relay.pipeline_failed", Category::System, )); - Err(sf.encode(&EncodeOptions::default())) + Err(sf.encode()) } // ── Client ─────────────────────────────────────────────────────────────────── diff --git a/crates/aerro/examples/tracing.rs b/crates/aerro/examples/tracing.rs index 6fc7eb2..0f6f668 100644 --- a/crates/aerro/examples/tracing.rs +++ b/crates/aerro/examples/tracing.rs @@ -4,7 +4,7 @@ //! //! Run with: cargo run --example tracing --features macro,tracing -use aerro::{Aerro, IntoStatus, StatusIntoResultExt}; +use aerro::{Aerro, AerroEncode, ServiceFailure}; use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::TracerProvider as SdkTracerProvider; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; @@ -34,9 +34,9 @@ fn main() { let span = tracing::info_span!("handle_get_user"); let status = { let _enter = span.enter(); - ApiError::UserNotFound.into_status_default() + ApiError::UserNotFound.encode() }; - let sf = status.into_aerro::().unwrap(); + let sf = ServiceFailure::::try_from(status).unwrap(); let t = sf.trace(); (t.trace_id, t.span_id) // span dropped here → SimpleSpanProcessor exports it synchronously diff --git a/crates/aerro/src/convert.rs b/crates/aerro/src/convert.rs index 184dda5..1cfa0bb 100644 --- a/crates/aerro/src/convert.rs +++ b/crates/aerro/src/convert.rs @@ -19,18 +19,26 @@ impl From> for Status { } } -/// Extension trait that adds `.encode()` to every `E: Aerro`, mirroring the -/// method already available on [`ServiceFailure`]. +/// Extension trait that adds `.encode()` and `.encode_with_opts()` to every +/// `E: Aerro`, mirroring the methods already available on [`ServiceFailure`]. pub trait AerroEncode: Aerro + Sized { - fn encode(self, opts: &EncodeOptions) -> Status { - ServiceFailure::new(self).encode(opts) + fn encode(self) -> Status { + self.encode_with_opts(&EncodeOptions::default()) + } + + fn encode_with_opts(self, opts: &EncodeOptions) -> Status { + ServiceFailure::new(self).encode_with_opts(opts) } } impl AerroEncode for E {} impl ServiceFailure { - pub fn encode(self, opts: &EncodeOptions) -> Status { + pub fn encode(self) -> Status { + self.encode_with_opts(&EncodeOptions::default()) + } + + pub fn encode_with_opts(self, opts: &EncodeOptions) -> Status { crate::wire::encode::encode(&self, opts) } } @@ -39,13 +47,12 @@ impl ServiceFailure { mod tests { use super::AerroEncode; use crate::test_support::Boom; - use crate::wire::encode::EncodeOptions; use crate::{RemoteError, ServiceFailure}; use std::convert::TryFrom; #[test] fn error_encode_method_roundtrips() { - let status = Boom { x: 99 }.encode(&EncodeOptions::default()); + let status = Boom { x: 99 }.encode(); let recovered = ServiceFailure::::try_from(status).unwrap(); assert_eq!(recovered.inner().x, 99); } @@ -53,7 +60,7 @@ mod tests { #[test] fn try_from_status_recovers_typed_failure() { let sf = ServiceFailure::new(Boom { x: 1 }); - let status: tonic::Status = sf.encode(&EncodeOptions::default()); + let status: tonic::Status = sf.encode(); let recovered = ServiceFailure::::try_from(status).unwrap(); assert_eq!(recovered.inner().x, 1); } @@ -76,7 +83,7 @@ mod tests { #[test] fn encode_with_opts_roundtrips() { let sf = ServiceFailure::new(Boom { x: 3 }); - let status = sf.encode(&EncodeOptions::default()); + let status = sf.encode(); let recovered: ServiceFailure = status.try_into().unwrap(); assert_eq!(recovered.inner().x, 3); } diff --git a/crates/aerro/tests/macro_basic.rs b/crates/aerro/tests/macro_basic.rs index d66f450..19c3e71 100644 --- a/crates/aerro/tests/macro_basic.rs +++ b/crates/aerro/tests/macro_basic.rs @@ -3,7 +3,6 @@ #![cfg(feature = "macro")] -use aerro::wire::encode::EncodeOptions; use aerro::{Aerro, AerroEncode, Category, Exposure, ServiceFailure}; use tonic::Code; @@ -85,7 +84,7 @@ fn struct_variant_roundtrips_via_wire() { let st = CreateUser::EmailTaken { email: "alice@x".into(), } - .encode(&EncodeOptions::default()); + .encode(); let sf: ServiceFailure = ServiceFailure::try_from(st).unwrap(); match sf.into_inner() { CreateUser::EmailTaken { email } => assert_eq!(email, "alice@x"), @@ -96,7 +95,7 @@ fn struct_variant_roundtrips_via_wire() { #[test] fn tuple_variant_roundtrips_via_wire() { let st = - CreateUser::InvalidName("bob".into()).encode(&EncodeOptions::default()); + CreateUser::InvalidName("bob".into()).encode(); let sf: ServiceFailure = ServiceFailure::try_from(st).unwrap(); match sf.into_inner() { CreateUser::InvalidName(s) => assert_eq!(s, "bob"), @@ -106,7 +105,7 @@ fn tuple_variant_roundtrips_via_wire() { #[test] fn unit_variant_roundtrips_via_wire() { - let st = CreateUser::Boom.encode(&EncodeOptions::default()); + let st = CreateUser::Boom.encode(); let sf: ServiceFailure = ServiceFailure::try_from(st).unwrap(); assert!(matches!(sf.inner(), CreateUser::Boom)); } diff --git a/crates/aerro/tests/polyglot.rs b/crates/aerro/tests/polyglot.rs index 2188307..43bd0bc 100644 --- a/crates/aerro/tests/polyglot.rs +++ b/crates/aerro/tests/polyglot.rs @@ -19,7 +19,7 @@ pub enum Api { #[test] fn bare_tonic_consumer_sees_correct_code_and_message_internal() { - let st = Api::NotFound.encode(&EncodeOptions { + let st = Api::NotFound.encode_with_opts(&EncodeOptions { exposure: Exposure::Internal, max_frames: 16, }); @@ -30,7 +30,7 @@ fn bare_tonic_consumer_sees_correct_code_and_message_internal() { #[test] fn bare_tonic_consumer_sees_redacted_message_at_public_for_system() { - let st = Api::Boom.encode(&EncodeOptions { + let st = Api::Boom.encode_with_opts(&EncodeOptions { exposure: Exposure::Public, max_frames: 16, }); @@ -40,7 +40,7 @@ fn bare_tonic_consumer_sees_redacted_message_at_public_for_system() { #[test] fn details_bytes_are_additive_not_required() { - let st = Api::NotFound.encode(&EncodeOptions::default()); + let st = Api::NotFound.encode(); // The details() carry the aerro envelope, but the consumer is free to // ignore them — code + message alone are well-defined. assert!(!st.details().is_empty()); diff --git a/crates/aerro/tests/roundtrip.rs b/crates/aerro/tests/roundtrip.rs index 54096fa..37c1ce7 100644 --- a/crates/aerro/tests/roundtrip.rs +++ b/crates/aerro/tests/roundtrip.rs @@ -31,7 +31,7 @@ fn opts(exposure: Exposure) -> EncodeOptions { #[test] fn business_internal_roundtrips_payload() { - let st = Suite::Biz("dup".into()).encode(&opts(Exposure::Internal)); + let st = Suite::Biz("dup".into()).encode_with_opts(&opts(Exposure::Internal)); let sf = ServiceFailure::::try_from(st).unwrap(); match sf.into_inner() { Suite::Biz(s) => assert_eq!(s, "dup"), @@ -41,7 +41,7 @@ fn business_internal_roundtrips_payload() { #[test] fn validation_public_roundtrips_payload_and_keeps_message() { - let st = Suite::Val("bad".into()).encode(&opts(Exposure::Public)); + let st = Suite::Val("bad".into()).encode_with_opts(&opts(Exposure::Public)); assert_eq!(st.code(), Code::InvalidArgument); assert_eq!(st.message(), "val: bad"); // Validation is safe at Public. let sf = ServiceFailure::::try_from(st).unwrap(); @@ -50,7 +50,7 @@ fn validation_public_roundtrips_payload_and_keeps_message() { #[test] fn system_public_redacts_message_but_payload_still_decodes() { - let st = Suite::Sys.encode(&opts(Exposure::Public)); + let st = Suite::Sys.encode_with_opts(&opts(Exposure::Public)); assert_eq!(st.code(), Code::Internal); assert_eq!(st.message(), "internal error"); // The envelope still carries the type_id and decodes typed: @@ -60,7 +60,7 @@ fn system_public_redacts_message_but_payload_still_decodes() { #[test] fn transport_trusted_keeps_message() { - let st = Suite::Trans.encode(&opts(Exposure::Trusted)); + let st = Suite::Trans.encode_with_opts(&opts(Exposure::Trusted)); assert_eq!(st.code(), Code::Unavailable); assert_eq!(st.message(), "trans"); } @@ -77,11 +77,11 @@ fn public_drops_frames_internal_keeps_them() { Category::System, )); - let st_pub = sf.clone_for_test().encode(&opts(Exposure::Public)); + let st_pub = sf.clone_for_test().encode_with_opts(&opts(Exposure::Public)); let pub_decoded = ServiceFailure::::try_from(st_pub).unwrap(); assert!(pub_decoded.frames().is_empty(), "Public must drop frames"); - let st_int = sf.encode(&opts(Exposure::Internal)); + let st_int = sf.encode_with_opts(&opts(Exposure::Internal)); let int_decoded = ServiceFailure::::try_from(st_int).unwrap(); assert_eq!(int_decoded.frames().len(), 1, "Internal must keep frames"); } From 19b70e05e5b2d7d85d21429ff366efb69c7a3de0 Mon Sep 17 00:00:00 2001 From: ae2rs Date: Wed, 27 May 2026 14:54:11 +0200 Subject: [PATCH 11/11] fmt --- crates/aerro-macros/src/attrs.rs | 5 ++--- crates/aerro-macros/src/codegen/aerro_impl.rs | 10 ++++++---- crates/aerro-macros/src/codegen/thiserror_glue.rs | 15 ++++++++++----- crates/aerro/src/failure.rs | 2 +- crates/aerro/tests/forward.rs | 12 ++++++++++-- crates/aerro/tests/macro_basic.rs | 3 +-- crates/aerro/tests/roundtrip.rs | 4 +++- 7 files changed, 33 insertions(+), 18 deletions(-) diff --git a/crates/aerro-macros/src/attrs.rs b/crates/aerro-macros/src/attrs.rs index 65c0470..764aba9 100644 --- a/crates/aerro-macros/src/attrs.rs +++ b/crates/aerro-macros/src/attrs.rs @@ -185,9 +185,8 @@ fn collect_field_cfg(field: &Field) -> syn::Result { role = FieldRole::Forward; Ok(()) } else { - Err(meta.error( - "field-level aerro attribute only supports `redact` and `forward`", - )) + Err(meta + .error("field-level aerro attribute only supports `redact` and `forward`")) } })?; } diff --git a/crates/aerro-macros/src/codegen/aerro_impl.rs b/crates/aerro-macros/src/codegen/aerro_impl.rs index 7f9f40a..878257d 100644 --- a/crates/aerro-macros/src/codegen/aerro_impl.rs +++ b/crates/aerro-macros/src/codegen/aerro_impl.rs @@ -207,10 +207,12 @@ fn decode_payload_arm(v: &VariantCfg, type_id: &str) -> TokenStream { .iter() .filter(|f| matches!(f.role, FieldRole::Plain)) .collect(); - let has_opaque_field = v - .fields - .iter() - .any(|f| matches!(f.role, FieldRole::Source | FieldRole::From | FieldRole::Forward)); + let has_opaque_field = v.fields.iter().any(|f| { + matches!( + f.role, + FieldRole::Source | FieldRole::From | FieldRole::Forward + ) + }); if has_opaque_field { // Cannot reconstruct anyhow/eyre error from wire — fall back to RemoteError. diff --git a/crates/aerro-macros/src/codegen/thiserror_glue.rs b/crates/aerro-macros/src/codegen/thiserror_glue.rs index 8ee0d69..17a68b9 100644 --- a/crates/aerro-macros/src/codegen/thiserror_glue.rs +++ b/crates/aerro-macros/src/codegen/thiserror_glue.rs @@ -13,7 +13,10 @@ pub fn emit_display_and_error(cfg: &EnumCfg) -> TokenStream { let source_arms = cfg.variants.iter().map(source_arm); let from_impls = cfg.variants.iter().filter_map(|v| from_impl(enum_ident, v)); - let forward_impls = cfg.variants.iter().filter_map(|v| forward_impl(enum_ident, v)); + let forward_impls = cfg + .variants + .iter() + .filter_map(|v| forward_impl(enum_ident, v)); quote! { impl ::core::fmt::Display for #enum_ident { @@ -112,10 +115,12 @@ fn source_arm(v: &VariantCfg) -> TokenStream { return quote! { Self::#variant => ::core::option::Option::None, }; } - let src_idx = v - .fields - .iter() - .position(|f| matches!(f.role, FieldRole::Source | FieldRole::From | FieldRole::Forward)); + let src_idx = v.fields.iter().position(|f| { + matches!( + f.role, + FieldRole::Source | FieldRole::From | FieldRole::Forward + ) + }); if let Some(idx) = src_idx { if v.is_tuple { diff --git a/crates/aerro/src/failure.rs b/crates/aerro/src/failure.rs index 95926fa..b44fbd9 100644 --- a/crates/aerro/src/failure.rs +++ b/crates/aerro/src/failure.rs @@ -8,8 +8,8 @@ use smallvec::SmallVec; -use crate::{Aerro, Frame, trace::TraceContext}; use crate::traits::FromServiceFailure; +use crate::{Aerro, Frame, trace::TraceContext}; #[derive(Debug)] pub(crate) struct ServiceFailureInner { diff --git a/crates/aerro/tests/forward.rs b/crates/aerro/tests/forward.rs index 226fc0f..24ccba9 100644 --- a/crates/aerro/tests/forward.rs +++ b/crates/aerro/tests/forward.rs @@ -48,10 +48,18 @@ fn empty_frames_transfer_cleanly() { fn multiple_frames_all_transfer() { let mut sf_inner: ServiceFailure = Inner::Fail.into(); sf_inner.frames_mut().push(Frame::local( - "svc-a", "rpc-a", Code::Internal, "first", Category::System, + "svc-a", + "rpc-a", + Code::Internal, + "first", + Category::System, )); sf_inner.frames_mut().push(Frame::local( - "svc-b", "rpc-b", Code::Internal, "second", Category::System, + "svc-b", + "rpc-b", + Code::Internal, + "second", + Category::System, )); let sf_outer: ServiceFailure = sf_inner.forward(); diff --git a/crates/aerro/tests/macro_basic.rs b/crates/aerro/tests/macro_basic.rs index 19c3e71..7cd739d 100644 --- a/crates/aerro/tests/macro_basic.rs +++ b/crates/aerro/tests/macro_basic.rs @@ -94,8 +94,7 @@ fn struct_variant_roundtrips_via_wire() { #[test] fn tuple_variant_roundtrips_via_wire() { - let st = - CreateUser::InvalidName("bob".into()).encode(); + let st = CreateUser::InvalidName("bob".into()).encode(); let sf: ServiceFailure = ServiceFailure::try_from(st).unwrap(); match sf.into_inner() { CreateUser::InvalidName(s) => assert_eq!(s, "bob"), diff --git a/crates/aerro/tests/roundtrip.rs b/crates/aerro/tests/roundtrip.rs index 37c1ce7..34a6802 100644 --- a/crates/aerro/tests/roundtrip.rs +++ b/crates/aerro/tests/roundtrip.rs @@ -77,7 +77,9 @@ fn public_drops_frames_internal_keeps_them() { Category::System, )); - let st_pub = sf.clone_for_test().encode_with_opts(&opts(Exposure::Public)); + let st_pub = sf + .clone_for_test() + .encode_with_opts(&opts(Exposure::Public)); let pub_decoded = ServiceFailure::::try_from(st_pub).unwrap(); assert!(pub_decoded.frames().is_empty(), "Public must drop frames");