remora/crates/remora-stream/src/failure.rs

329 lines
11 KiB
Rust

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<StreamSide>,
reason: Option<PublicReason>,
elapsed_ms: Option<u64>,
byte_offset: Option<u64>,
member_index: Option<u64>,
}
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<String, SafeDetailValue> {
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<String, SafeDetailValue>,
}
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<String, SafeDetailValue> {
&self.safe_details
}
#[must_use]
pub fn disposition(&self) -> Option<FailureDisposition> {
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<RuntimeFailureClass> {
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());
}
}