329 lines
11 KiB
Rust
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());
|
||
|
|
}
|
||
|
|
}
|