use std::collections::BTreeMap; use remora_domain::{FailureClass, FailureDisposition, RuntimeFailureClass}; use serde::{Deserialize, Serialize}; use crate::error::{StreamError, StreamSide}; /// Allowlisted scalar for public failure details after typed construction. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(untagged)] pub enum SafeDetailValue { String(String), Integer(i64), Boolean(bool), Null, } /// Closed set of public reason codes that may appear in stream failure details. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum PublicReason { NoProgress, } impl PublicReason { #[must_use] pub const fn as_str(self) -> &'static str { match self { Self::NoProgress => "no_progress", } } } /// Typed public details for a stream-classified failure. /// /// Only fixed enum labels and integers are accepted. Free-form strings are not /// representable, so credentials, URLs, and exception text cannot enter the /// public projection through this type. Keys are further filtered per class. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct PublicDetails { side: Option, reason: Option, elapsed_ms: Option, byte_offset: Option, member_index: Option, } impl PublicDetails { #[must_use] pub fn new() -> Self { Self::default() } #[must_use] pub fn with_side(mut self, side: StreamSide) -> Self { self.side = Some(side); self } #[must_use] pub fn with_reason(mut self, reason: PublicReason) -> Self { self.reason = Some(reason); self } #[must_use] pub fn with_elapsed_ms(mut self, elapsed_ms: u64) -> Self { self.elapsed_ms = Some(elapsed_ms); self } #[must_use] pub fn with_byte_offset(mut self, byte_offset: u64) -> Self { self.byte_offset = Some(byte_offset); self } #[must_use] pub fn with_member_index(mut self, member_index: u64) -> Self { self.member_index = Some(member_index); self } fn render(self, class: FailureClass) -> BTreeMap { let mut out = BTreeMap::new(); if key_allowed(class, "side") && let Some(side) = self.side { out.insert( "side".into(), SafeDetailValue::String(side_label(side).into()), ); } if key_allowed(class, "reason") && let Some(reason) = self.reason { out.insert( "reason".into(), SafeDetailValue::String(reason.as_str().into()), ); } if key_allowed(class, "elapsed_ms") && let Some(elapsed_ms) = self.elapsed_ms { out.insert( "elapsed_ms".into(), SafeDetailValue::Integer(i64::try_from(elapsed_ms).unwrap_or(i64::MAX)), ); } if key_allowed(class, "byte_offset") && let Some(byte_offset) = self.byte_offset { out.insert( "byte_offset".into(), SafeDetailValue::Integer(i64::try_from(byte_offset).unwrap_or(i64::MAX)), ); } if key_allowed(class, "member_index") && let Some(member_index) = self.member_index { out.insert( "member_index".into(), SafeDetailValue::Integer(i64::try_from(member_index).unwrap_or(i64::MAX)), ); } out } } /// Attempt-reportable or protocol-visible structured failure after redaction. #[derive(Clone, Debug, Eq, PartialEq)] pub struct StructuredFailure { class: FailureClass, message: String, safe_details: BTreeMap, } impl StructuredFailure { #[must_use] pub fn class(&self) -> FailureClass { self.class } #[must_use] pub fn message(&self) -> &str { &self.message } #[must_use] pub fn safe_details(&self) -> &BTreeMap { &self.safe_details } #[must_use] pub fn disposition(&self) -> Option { match self.class { FailureClass::Authorization | FailureClass::SourceNotFound | FailureClass::SourceChanged | FailureClass::Validation | FailureClass::Cancelled => Some(FailureDisposition::Permanent), FailureClass::TransientSource | FailureClass::TransientTarget | FailureClass::Integrity | FailureClass::Capacity | FailureClass::Internal | FailureClass::LostAttempt => Some(FailureDisposition::Retryable), } } #[must_use] pub fn runtime_class(&self) -> Option { match self.class { FailureClass::Authorization => Some(RuntimeFailureClass::Authorization), FailureClass::SourceNotFound => Some(RuntimeFailureClass::SourceNotFound), FailureClass::SourceChanged => Some(RuntimeFailureClass::SourceChanged), FailureClass::TransientSource => Some(RuntimeFailureClass::TransientSource), FailureClass::TransientTarget => Some(RuntimeFailureClass::TransientTarget), FailureClass::Integrity => Some(RuntimeFailureClass::Integrity), FailureClass::Capacity => Some(RuntimeFailureClass::Capacity), FailureClass::Internal => Some(RuntimeFailureClass::Internal), FailureClass::Validation | FailureClass::Cancelled | FailureClass::LostAttempt => None, } } /// Build a public failure with the fixed class message and typed details. /// /// Caller-supplied free-form messages are not accepted. Detail keys are /// filtered by class allowlist; values are only enum labels or integers. #[must_use] pub fn public(class: FailureClass, details: PublicDetails) -> Self { Self { class, message: public_message_for_class(class).into(), safe_details: details.render(class), } } /// Redacted internal failure for unrecognized or unsafe errors. #[must_use] pub fn redacted_internal() -> Self { Self::public(FailureClass::Internal, PublicDetails::new()) } } /// Map a stream error to a public structured failure. /// /// Credentials, upstream bodies, raw URLs, and internal exception text are /// excluded from the public message and details. #[must_use] pub fn classify_stream_error(error: &StreamError) -> StructuredFailure { match error { StreamError::Cancelled => { StructuredFailure::public(FailureClass::Cancelled, PublicDetails::new()) } StreamError::Authorization { side } => StructuredFailure::public( FailureClass::Authorization, PublicDetails::new().with_side(*side), ), StreamError::SourceNotFound => { StructuredFailure::public(FailureClass::SourceNotFound, PublicDetails::new()) } StreamError::SourceChanged => { StructuredFailure::public(FailureClass::SourceChanged, PublicDetails::new()) } StreamError::Transient { side, .. } => { let class = match side { StreamSide::Source => FailureClass::TransientSource, StreamSide::Target => FailureClass::TransientTarget, }; StructuredFailure::public(class, PublicDetails::new().with_side(*side)) } StreamError::NoProgress { side, elapsed_ms } => { let class = match side { StreamSide::Source => FailureClass::TransientSource, StreamSide::Target => FailureClass::TransientTarget, }; StructuredFailure::public( class, PublicDetails::new() .with_side(*side) .with_reason(PublicReason::NoProgress) .with_elapsed_ms(*elapsed_ms), ) } StreamError::Integrity { .. } => { StructuredFailure::public(FailureClass::Integrity, PublicDetails::new()) } StreamError::Capacity { side } => StructuredFailure::public( FailureClass::Capacity, PublicDetails::new().with_side(*side), ), StreamError::Internal { .. } => StructuredFailure::redacted_internal(), } } #[must_use] pub fn public_message_for_class(class: FailureClass) -> &'static str { match class { FailureClass::Validation => "The request is invalid.", FailureClass::Authorization => "Authorization failed for this transfer.", FailureClass::SourceNotFound => "The source was not found.", FailureClass::SourceChanged => "The source changed during transfer.", FailureClass::TransientSource => "A temporary source error interrupted the transfer.", FailureClass::TransientTarget => "A temporary target error interrupted the transfer.", FailureClass::Integrity => "Transfer integrity validation failed.", FailureClass::Capacity => "Capacity limits prevented the transfer from continuing.", FailureClass::Internal => "An internal error interrupted the transfer.", FailureClass::Cancelled => "The transfer was cancelled.", FailureClass::LostAttempt => "The attempt was lost and may be retried.", } } fn side_label(side: StreamSide) -> &'static str { match side { StreamSide::Source => "source", StreamSide::Target => "target", } } fn key_allowed(class: FailureClass, key: &str) -> bool { match class { FailureClass::TransientSource | FailureClass::TransientTarget => matches!( key, "side" | "reason" | "elapsed_ms" | "byte_offset" | "member_index" ), FailureClass::Authorization | FailureClass::Capacity => key == "side", FailureClass::Integrity => matches!(key, "byte_offset" | "member_index"), FailureClass::Validation | FailureClass::SourceNotFound | FailureClass::SourceChanged | FailureClass::Internal | FailureClass::Cancelled | FailureClass::LostAttempt => false, } } #[cfg(test)] mod tests { use super::*; #[test] fn public_constructor_uses_fixed_message_only() { let failure = StructuredFailure::public( FailureClass::TransientSource, PublicDetails::new().with_reason(PublicReason::NoProgress), ); assert_eq!( failure.message(), public_message_for_class(FailureClass::TransientSource) ); assert!(!failure.message().contains("api")); assert_eq!( failure.safe_details().get("reason"), Some(&SafeDetailValue::String("no_progress".into())) ); } #[test] fn per_class_allowlist_drops_disallowed_keys() { let failure = StructuredFailure::public( FailureClass::Internal, PublicDetails::new() .with_side(StreamSide::Source) .with_reason(PublicReason::NoProgress) .with_elapsed_ms(12), ); assert!(failure.safe_details().is_empty()); } }