diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 731e16b..1864f1b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,9 +25,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: dtolnay/rust-toolchain@stable with: components: clippy @@ -45,9 +42,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: @@ -61,9 +55,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: dtolnay/rust-toolchain@1.85 - uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 106985d..53e42de 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,9 +11,6 @@ jobs: contents: write steps: - uses: actions/checkout@v4 - - uses: arduino/setup-protoc@v3 - with: - repo-token: ${{ secrets.GITHUB_TOKEN }} - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 with: diff --git a/Cargo.toml b/Cargo.toml index 97acab2..ef19961 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = ["crates/aerro", "crates/aerro-macros"] # (Reserved for workspace-wide clippy overrides; currently none.) [workspace.package] -version = "0.8.0" +version = "0.9.0" edition = "2024" rust-version = "1.85" license = "MIT OR Apache-2.0" @@ -15,9 +15,7 @@ homepage = "https://github.com/ae2rs/aerro" documentation = "https://docs.rs/aerro" [workspace.dependencies] -tonic = { version = "0.13", default-features = false, features = ["codegen", "prost", "transport"] } -prost = "0.13" -tonic-build = { version = "0.13", default-features = false, features = ["prost"] } +tonic = { version = "0.13", default-features = false, features = ["codegen", "transport"] } bincode = { version = "2.0", default-features = false, features = ["std", "derive"] } smallvec = { version = "1.13", features = ["const_generics", "union"] } thiserror = "2" diff --git a/crates/aerro/Cargo.toml b/crates/aerro/Cargo.toml index 2d82407..10a2ad5 100644 --- a/crates/aerro/Cargo.toml +++ b/crates/aerro/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "aerro" description = "Cross-service gRPC error framework for Rust." -keywords = ["grpc", "tonic", "prost", "error", "trace"] +keywords = ["grpc", "tonic", "bincode", "error", "trace"] categories = ["network-programming", "rust-patterns"] readme = "../../README.md" version.workspace = true @@ -18,9 +18,8 @@ macro = ["dep:aerro-macros"] tracing = ["dep:tracing", "dep:tracing-opentelemetry", "dep:opentelemetry"] [dependencies] -aerro-macros = { path = "../aerro-macros", version = "0.8.0", optional = true } +aerro-macros = { path = "../aerro-macros", version = "0.9.0", optional = true } tonic = { workspace = true } -prost = { workspace = true } bytes = { workspace = true } bincode = { workspace = true } smallvec = { workspace = true } @@ -32,9 +31,6 @@ opentelemetry = { workspace = true, optional = true } [lints] workspace = true -[build-dependencies] -tonic-build = { workspace = true } - [dev-dependencies] tokio = { version = "1", features = ["macros", "rt-multi-thread"] } dhat = { workspace = true } diff --git a/crates/aerro/benches/error_path.rs b/crates/aerro/benches/error_path.rs index 130f4b0..df228b4 100644 --- a/crates/aerro/benches/error_path.rs +++ b/crates/aerro/benches/error_path.rs @@ -26,7 +26,7 @@ fn bench_encode(c: &mut Criterion) { let mut group = c.benchmark_group("encode"); let opts = EncodeOptions::default(); - group.bench_function("aerro_prost", |b| { + group.bench_function("aerro_bincode", |b| { b.iter(|| { let v = Bench::Item { x: 42, @@ -43,18 +43,18 @@ fn bench_encode(c: &mut Criterion) { fn bench_decode(c: &mut Criterion) { let mut group = c.benchmark_group("decode"); let opts = EncodeOptions::default(); - let prost_status = Bench::Item { + let encoded_status = Bench::Item { x: 42, y: "hello-world".into(), } .into_status(&opts); - group.bench_function("aerro_prost", |b| { + group.bench_function("aerro_bincode", |b| { b.iter(|| { let st = tonic::Status::with_details( - prost_status.code(), - prost_status.message(), - bytes::Bytes::copy_from_slice(prost_status.details()), + encoded_status.code(), + encoded_status.message(), + bytes::Bytes::copy_from_slice(encoded_status.details()), ); black_box(st.into_aerro::().unwrap()); }); diff --git a/crates/aerro/build.rs b/crates/aerro/build.rs deleted file mode 100644 index 03acb84..0000000 --- a/crates/aerro/build.rs +++ /dev/null @@ -1,8 +0,0 @@ -fn main() { - tonic_build::configure() - .build_client(false) - .build_server(false) - .compile_protos(&["proto/aerro.v1.proto"], &["proto"]) - .expect("compile aerro.v1.proto"); - println!("cargo:rerun-if-changed=proto/aerro.v1.proto"); -} diff --git a/crates/aerro/proto/aerro.v1.proto b/crates/aerro/proto/aerro.v1.proto deleted file mode 100644 index e41ffe0..0000000 --- a/crates/aerro/proto/aerro.v1.proto +++ /dev/null @@ -1,29 +0,0 @@ -syntax = "proto3"; -package aerro.v1; - -message Envelope { - Category category = 1; - string type_id = 2; - bytes trace_id = 3; - bytes span_id = 4; - repeated Frame frames = 5; - bytes payload = 6; - uint32 version = 7; -} - -message Frame { - string service = 1; - string rpc = 2; - uint32 code = 3; - string message = 4; - string location = 5; - Category category = 6; -} - -enum Category { - CATEGORY_UNSPECIFIED = 0; - CATEGORY_BUSINESS = 1; - CATEGORY_SYSTEM = 2; - CATEGORY_VALIDATION = 3; - CATEGORY_TRANSPORT = 4; -} diff --git a/crates/aerro/src/error.rs b/crates/aerro/src/error.rs index 8f4318d..8a02bdc 100644 --- a/crates/aerro/src/error.rs +++ b/crates/aerro/src/error.rs @@ -10,8 +10,8 @@ pub enum DecodeError { UnknownTypeId(String), #[error("bincode decode: {0}")] Payload(String), - #[error("prost decode: {0}")] - Prost(String), + #[error("wire decode: {0}")] + Wire(String), } #[derive(Debug, thiserror::Error)] diff --git a/crates/aerro/src/wire/decode.rs b/crates/aerro/src/wire/decode.rs index 5beb6c4..af46ba5 100644 --- a/crates/aerro/src/wire/decode.rs +++ b/crates/aerro/src/wire/decode.rs @@ -2,14 +2,12 @@ //! [`encode`](crate::wire::encode::encode). use bytes::Bytes; -use prost::Message; use smallvec::SmallVec; use tonic::{Code, Status}; use crate::{Aerro, Category, Frame, RemoteError, ServiceFailure, trace::TraceContext}; -use super::envelope::from_proto; -use super::raw; +use super::envelope::{ENVELOPE_VERSION, WireEnvelope, WireFrame}; /// Decode a `tonic::Status` into a [`ServiceFailure`](crate::ServiceFailure). /// @@ -21,10 +19,15 @@ pub fn decode(status: Status) -> Result, RemoteError if details.is_empty() { return Err(transport_remote_error(&status)); } - let env = match raw::Envelope::decode(details) { - Ok(e) => e, - Err(_) => return Err(transport_remote_error(&status)), - }; + let (env, _): (WireEnvelope, _) = + match bincode::decode_from_slice(details, bincode::config::standard()) { + Ok(pair) => pair, + Err(_) => return Err(transport_remote_error(&status)), + }; + + if env.version != ENVELOPE_VERSION { + return Err(transport_remote_error(&status)); + } if !E::TYPE_IDS.contains(&env.type_id.as_str()) { return Err(into_remote_error(env, &status)); @@ -39,9 +42,8 @@ pub fn decode(status: Status) -> Result, RemoteError Ok(ServiceFailure::from_parts(inner, frames, trace)) } -fn into_remote_error(env: raw::Envelope, status: &Status) -> RemoteError { - let category = - from_proto(raw::Category::try_from(env.category).unwrap_or(raw::Category::System)); +fn into_remote_error(env: WireEnvelope, status: &Status) -> RemoteError { + let category = Category::try_from(env.category).unwrap_or(Category::System); let trace = decode_trace(&env.trace_id, &env.span_id); let frames = decode_frames(&env.frames); RemoteError::from_parts(crate::remote::RemoteErrorInner { @@ -51,7 +53,7 @@ fn into_remote_error(env: raw::Envelope, status: &Status) -> RemoteError { trace, outer_code: status.code(), outer_message: status.message().to_string(), - payload_bytes: Bytes::from(env.payload.to_vec()), + payload_bytes: Bytes::from(env.payload), }) } @@ -67,10 +69,10 @@ fn transport_remote_error(status: &Status) -> RemoteError { }) } -fn decode_frames(frames: &[raw::Frame]) -> SmallVec<[Frame; 4]> { +fn decode_frames(frames: &[WireFrame]) -> SmallVec<[Frame; 4]> { let mut out = SmallVec::new(); for f in frames { - let cat = from_proto(raw::Category::try_from(f.category).unwrap_or(raw::Category::System)); + let cat = Category::try_from(f.category).unwrap_or(Category::System); let code = code_from_u32(f.code); let loc = if f.location.is_empty() { None @@ -90,18 +92,13 @@ fn decode_frames(frames: &[raw::Frame]) -> SmallVec<[Frame; 4]> { } fn code_from_u32(c: u32) -> Code { - // tonic::Code is `#[repr(i32)]` over the gRPC code numbers 0..=16. Code::from(c as i32) } -fn decode_trace(trace_id: &[u8], span_id: &[u8]) -> TraceContext { +fn decode_trace(trace_id: &[u8; 16], span_id: &[u8; 8]) -> TraceContext { let mut t = TraceContext::default(); - if trace_id.len() == 16 { - t.trace_id.copy_from_slice(trace_id); - } - if span_id.len() == 8 { - t.span_id.copy_from_slice(span_id); - } + t.trace_id.copy_from_slice(trace_id); + t.span_id.copy_from_slice(span_id); t } diff --git a/crates/aerro/src/wire/encode.rs b/crates/aerro/src/wire/encode.rs index 036f622..c9ed12c 100644 --- a/crates/aerro/src/wire/encode.rs +++ b/crates/aerro/src/wire/encode.rs @@ -1,12 +1,10 @@ //! `ServiceFailure` → `tonic::Status` encoding — see spec §6, §9. -use prost::Message; use tonic::{Code, Status}; use crate::{Aerro, Category, Exposure, Frame, ServiceFailure}; -use super::envelope::{ENVELOPE_VERSION, to_proto}; -use super::raw; +use super::envelope::{ENVELOPE_VERSION, WireEnvelope, WireFrame}; /// Encoder configuration. #[derive(Debug, Copy, Clone)] @@ -29,11 +27,8 @@ impl Default for EncodeOptions { } /// Encode a typed failure into a `tonic::Status` whose `details()` carries -/// the prost-encoded aerro envelope. +/// the bincode-encoded aerro envelope. pub fn encode(sf: &ServiceFailure, opts: &EncodeOptions) -> Status { - // Stripping is keyed on the *route* exposure (the operator's contract with - // its callers). The variant's declared exposure is informational in v1 — the - // route is what governs what leaves the process. let route = opts.exposure; let outer_code = sf.inner().code(); @@ -50,16 +45,17 @@ pub fn encode(sf: &ServiceFailure, opts: &EncodeOptions) -> Status elide_to_cap(sf.frames(), opts.max_frames) }; - let env = raw::Envelope { - category: to_proto(sf.inner().category()) as i32, + let trace = sf.trace(); + let env = WireEnvelope { + version: ENVELOPE_VERSION, + category: u8::from(sf.inner().category()), type_id: Aerro::type_id(sf.inner()).to_string(), - trace_id: sf.trace().trace_id.to_vec(), - span_id: sf.trace().span_id.to_vec(), + trace_id: trace.trace_id, + span_id: trace.span_id, frames: wire_frames, payload, - version: ENVELOPE_VERSION, }; - let bytes = env.encode_to_vec(); + let bytes = bincode::encode_to_vec(&env, bincode::config::standard()).expect("bincode encode"); Status::with_details(outer_code, outer_msg, bytes.into()) } @@ -72,7 +68,7 @@ fn redact_message(inner: &E, route: Exposure) -> String { } } -fn elide_to_cap(frames: &[Frame], cap: u8) -> Vec { +fn elide_to_cap(frames: &[Frame], cap: u8) -> Vec { let cap = cap.max(1) as usize; if frames.len() <= cap { return frames.iter().map(to_wire_frame).collect(); @@ -82,13 +78,13 @@ fn elide_to_cap(frames: &[Frame], cap: u8) -> Vec { let n_elided = frames.len() - keep_front - keep_back; let mut out = Vec::with_capacity(cap); out.extend(frames[..keep_front].iter().map(to_wire_frame)); - out.push(raw::Frame { + out.push(WireFrame { service: "...".into(), rpc: "elided".into(), code: 0, message: format!("{} frames elided", n_elided), location: String::new(), - category: to_proto(frames[keep_front].category) as i32, + category: u8::from(frames[keep_front].category), }); if keep_back > 0 { out.extend(frames[frames.len() - keep_back..].iter().map(to_wire_frame)); @@ -96,8 +92,8 @@ fn elide_to_cap(frames: &[Frame], cap: u8) -> Vec { out } -fn to_wire_frame(f: &Frame) -> raw::Frame { - raw::Frame { +fn to_wire_frame(f: &Frame) -> WireFrame { + WireFrame { service: f.service.to_string(), rpc: f.rpc.to_string(), code: f.code as i32 as u32, @@ -106,7 +102,7 @@ fn to_wire_frame(f: &Frame) -> raw::Frame { .location .map(|l| format!("{}:{}", l.file(), l.line())) .unwrap_or_default(), - category: to_proto(f.category) as i32, + category: u8::from(f.category), } } @@ -114,6 +110,7 @@ fn to_wire_frame(f: &Frame) -> raw::Frame { mod tests { use super::*; use crate::test_support::Boom; + use crate::wire::envelope::WireEnvelope; use tonic::Code; #[test] @@ -157,7 +154,8 @@ mod tests { max_frames: 8, }, ); - let env = raw::Envelope::decode(st.details()).unwrap(); + let (env, _): (WireEnvelope, _) = + bincode::decode_from_slice(st.details(), bincode::config::standard()).unwrap(); assert_eq!(env.frames.len(), 8); assert!(env.frames.iter().any(|f| f.rpc == "elided")); } @@ -179,7 +177,8 @@ mod tests { max_frames: 16, }, ); - let env = raw::Envelope::decode(st.details()).unwrap(); + let (env, _): (WireEnvelope, _) = + bincode::decode_from_slice(st.details(), bincode::config::standard()).unwrap(); assert!(env.frames.is_empty()); } } diff --git a/crates/aerro/src/wire/envelope.rs b/crates/aerro/src/wire/envelope.rs index c74e29a..eaefb51 100644 --- a/crates/aerro/src/wire/envelope.rs +++ b/crates/aerro/src/wire/envelope.rs @@ -1,29 +1,53 @@ -//! Helpers around the generated proto types. +//! Pure-bincode wire envelope — replaces the prost/protobuf encoding (v2). use crate::Category; -use super::raw; +pub(crate) const ENVELOPE_VERSION: u32 = 2; -pub fn to_proto(c: Category) -> raw::Category { - match c { - Category::Business => raw::Category::Business, - Category::System => raw::Category::System, - Category::Validation => raw::Category::Validation, - Category::Transport => raw::Category::Transport, - } +#[derive(Debug, bincode::Encode, bincode::Decode)] +pub(crate) struct WireEnvelope { + pub version: u32, + pub category: u8, + pub type_id: String, + pub trace_id: [u8; 16], + pub span_id: [u8; 8], + pub frames: Vec, + pub payload: Vec, +} + +#[derive(Debug, bincode::Encode, bincode::Decode)] +pub(crate) struct WireFrame { + pub service: String, + pub rpc: String, + pub code: u32, + pub message: String, + pub location: String, + pub category: u8, } -pub fn from_proto(c: raw::Category) -> Category { - match c { - raw::Category::Business => Category::Business, - raw::Category::System => Category::System, - raw::Category::Validation => Category::Validation, - raw::Category::Transport => Category::Transport, - raw::Category::Unspecified => Category::System, +impl From for u8 { + fn from(c: Category) -> u8 { + match c { + Category::Business => 1, + Category::System => 2, + Category::Validation => 3, + Category::Transport => 4, + } } } -pub const ENVELOPE_VERSION: u32 = 1; +impl TryFrom for Category { + type Error = (); + fn try_from(v: u8) -> Result { + match v { + 1 => Ok(Category::Business), + 2 => Ok(Category::System), + 3 => Ok(Category::Validation), + 4 => Ok(Category::Transport), + _ => Err(()), + } + } +} #[cfg(test)] mod tests { @@ -37,7 +61,8 @@ mod tests { Category::Validation, Category::Transport, ] { - assert_eq!(from_proto(to_proto(c)), c); + let byte: u8 = c.into(); + assert_eq!(Category::try_from(byte).unwrap(), c); } } } diff --git a/crates/aerro/src/wire/mod.rs b/crates/aerro/src/wire/mod.rs index a960557..620e79e 100644 --- a/crates/aerro/src/wire/mod.rs +++ b/crates/aerro/src/wire/mod.rs @@ -1,8 +1,4 @@ -//! Wire envelope (prost) + encode/decode glue. - -pub mod raw { - include!(concat!(env!("OUT_DIR"), "/aerro.v1.rs")); -} +//! Wire envelope (bincode) + encode/decode glue. pub mod decode; pub mod encode; diff --git a/crates/aerro/tests/proto_gen.rs b/crates/aerro/tests/proto_gen.rs index 008079e..5e76f3b 100644 --- a/crates/aerro/tests/proto_gen.rs +++ b/crates/aerro/tests/proto_gen.rs @@ -1,16 +1,24 @@ +//! Verify the bincode wire envelope round-trips at the byte level. + +#![cfg(feature = "macro")] + +use aerro::wire::decode::decode; +use aerro::wire::encode::{EncodeOptions, encode}; +use aerro::{Aerro, Category, ServiceFailure}; +use tonic::Code; + +#[derive(Debug, aerro::Aerro)] +pub enum Ping { + #[aerro(category = Business, code = AlreadyExists, error = "ping")] + Pong, +} + #[test] -fn envelope_roundtrips_via_prost() { - use prost::Message; - let env = aerro::wire::raw::Envelope { - category: aerro::wire::raw::Category::Business as i32, - type_id: "create_user.email_taken".into(), - trace_id: vec![0xAA; 16], - span_id: vec![0xBB; 8], - frames: vec![], - payload: vec![1, 2, 3], - version: 1, - }; - let bytes = env.encode_to_vec(); - let back = aerro::wire::raw::Envelope::decode(&*bytes).unwrap(); - assert_eq!(back.type_id, env.type_id); +fn envelope_roundtrips_via_bincode() { + let sf: ServiceFailure = Ping::Pong.into(); + let st = encode(&sf, &EncodeOptions::default()); + assert!(!st.details().is_empty(), "envelope must be present"); + let back = decode::(st).expect("roundtrip"); + assert_eq!(back.inner().category(), Category::Business); + assert_eq!(back.inner().code(), Code::AlreadyExists); }