use crate::{FailureClass, LifecycleEventKind, NegotiationOutcome, RetryClass};
use super::plan::RoutingPlan;
use super::receipts::ReceiptError;
use super::seams::FailureMapper;
use super::validation::RouteError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TransportError {
Io(String),
Timeout,
Internal(String),
}
impl std::fmt::Display for TransportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(detail) => write!(f, "transport io error: {detail}"),
Self::Timeout => f.write_str("transport timeout"),
Self::Internal(detail) => write!(f, "transport internal error: {detail}"),
}
}
}
impl std::error::Error for TransportError {}
pub fn failure_class_for_route_error(err: &RouteError) -> FailureClass {
match err {
RouteError::SchemaVersionMismatch { .. }
| RouteError::EmptySentinel { .. }
| RouteError::UnknownEventName { .. }
| RouteError::UnknownEnumName { .. }
| RouteError::InvalidFrameContext { .. }
| RouteError::InvalidPayloadRef { .. }
| RouteError::InvalidEventEnvelope { .. } => FailureClass::InvalidRequest,
RouteError::AdapterIdNotFound { .. } | RouteError::AdapterVersionMismatch { .. } => {
FailureClass::AdapterUnavailable
}
}
}
pub fn failure_class_for_receipt_error(err: &ReceiptError) -> FailureClass {
match err {
ReceiptError::ReceiptEmittedNotEmittable => FailureClass::InvalidRequest,
ReceiptError::Conflict { .. } => FailureClass::StateConflict,
ReceiptError::Invalid(_) => FailureClass::InvalidRequest,
}
}
pub fn failure_class_for_transport(err: &TransportError) -> FailureClass {
match err {
TransportError::Io(_) => FailureClass::TransportError,
TransportError::Timeout => FailureClass::Timeout,
TransportError::Internal(_) => FailureClass::InternalError,
}
}
pub fn classes_for_negotiation_outcome(
outcome: NegotiationOutcome,
explicit_failure_class: Option<FailureClass>,
) -> Option<(FailureClass, RetryClass)> {
match outcome {
NegotiationOutcome::Unsupported => {
let fc = explicit_failure_class.unwrap_or(FailureClass::CapabilityUnsupported);
Some((fc, fc.default_retry()))
}
NegotiationOutcome::RequiresOperator => {
let fc = FailureClass::OperatorRequired;
debug_assert_eq!(fc.default_retry(), RetryClass::RetryAfterOperator);
Some((fc, RetryClass::RetryAfterOperator))
}
NegotiationOutcome::Satisfied | NegotiationOutcome::Degraded => None,
}
}
pub fn retry_class_for(failure_class: FailureClass) -> RetryClass {
failure_class.default_retry()
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LifeloopFailureMapper;
impl LifeloopFailureMapper {
pub fn new() -> Self {
Self
}
pub fn map_receipt_error(&self, err: &ReceiptError) -> (FailureClass, RetryClass) {
let fc = failure_class_for_receipt_error(err);
(fc, retry_class_for(fc))
}
pub fn map_transport_error(&self, err: &TransportError) -> (FailureClass, RetryClass) {
let fc = failure_class_for_transport(err);
(fc, retry_class_for(fc))
}
pub fn map_unknown_error(&self, _err: &dyn std::error::Error) -> (FailureClass, RetryClass) {
let fc = FailureClass::InternalError;
(fc, retry_class_for(fc))
}
}
impl FailureMapper for LifeloopFailureMapper {
fn map_route_error(&self, err: &RouteError) -> (FailureClass, RetryClass) {
let fc = failure_class_for_route_error(err);
(fc, retry_class_for(fc))
}
}
impl From<&RouteError> for FailureClass {
fn from(err: &RouteError) -> Self {
failure_class_for_route_error(err)
}
}
impl From<&ReceiptError> for FailureClass {
fn from(err: &ReceiptError) -> Self {
failure_class_for_receipt_error(err)
}
}
impl From<&TransportError> for FailureClass {
fn from(err: &TransportError) -> Self {
failure_class_for_transport(err)
}
}
pub fn validate_receipt_eligible(plan: &RoutingPlan) -> Result<(), RouteError> {
if matches!(plan.event, LifecycleEventKind::ReceiptEmitted) {
return Err(RouteError::InvalidEventEnvelope {
detail: "receipt.emitted is a notification event and must not produce \
a lifecycle receipt"
.into(),
});
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn route_error_maps_to_invalid_request_or_adapter_unavailable() {
let cases: Vec<(RouteError, FailureClass)> = vec![
(
RouteError::SchemaVersionMismatch {
expected: "a".into(),
found: "b".into(),
},
FailureClass::InvalidRequest,
),
(
RouteError::EmptySentinel { field: "x" },
FailureClass::InvalidRequest,
),
(
RouteError::UnknownEventName {
received: "bogus".into(),
},
FailureClass::InvalidRequest,
),
(
RouteError::UnknownEnumName {
field: "integration_mode",
received: "weird".into(),
},
FailureClass::InvalidRequest,
),
(
RouteError::InvalidFrameContext {
detail: "missing".into(),
},
FailureClass::InvalidRequest,
),
(
RouteError::InvalidPayloadRef {
index: 0,
detail: "empty".into(),
},
FailureClass::InvalidRequest,
),
(
RouteError::InvalidEventEnvelope { detail: "x".into() },
FailureClass::InvalidRequest,
),
(
RouteError::AdapterIdNotFound {
adapter_id: "ghost".into(),
},
FailureClass::AdapterUnavailable,
),
(
RouteError::AdapterVersionMismatch {
adapter_id: "codex".into(),
requested: "0.0.0".into(),
registered: "0.1.0".into(),
},
FailureClass::AdapterUnavailable,
),
];
let mapper = LifeloopFailureMapper::new();
for (err, expected) in cases {
let (fc, rc) = mapper.map_route_error(&err);
assert_eq!(fc, expected, "route error -> failure class: {err:?}");
assert_eq!(rc, fc.default_retry(), "retry class follows default");
let via_from: FailureClass = (&err).into();
assert_eq!(via_from, fc);
}
}
#[test]
fn receipt_error_mapping() {
let mapper = LifeloopFailureMapper::new();
assert_eq!(
mapper.map_receipt_error(&ReceiptError::ReceiptEmittedNotEmittable),
(FailureClass::InvalidRequest, RetryClass::DoNotRetry),
);
assert_eq!(
mapper.map_receipt_error(&ReceiptError::Conflict {
idempotency_key: "k".into()
}),
(FailureClass::StateConflict, RetryClass::RetryAfterReread),
);
}
#[test]
fn transport_error_mapping_distinguishes_io_timeout_internal() {
let mapper = LifeloopFailureMapper::new();
assert_eq!(
mapper
.map_transport_error(&TransportError::Io("EPIPE".into()))
.0,
FailureClass::TransportError,
);
assert_eq!(
mapper.map_transport_error(&TransportError::Timeout).0,
FailureClass::Timeout,
);
assert_eq!(
mapper
.map_transport_error(&TransportError::Internal("panic".into()))
.0,
FailureClass::InternalError,
);
}
#[test]
fn negotiation_requires_operator_uses_operator_required_pair() {
let pair = classes_for_negotiation_outcome(NegotiationOutcome::RequiresOperator, None);
assert_eq!(
pair,
Some((
FailureClass::OperatorRequired,
RetryClass::RetryAfterOperator
))
);
}
#[test]
fn negotiation_satisfied_and_degraded_yield_no_blocking_pair() {
assert!(classes_for_negotiation_outcome(NegotiationOutcome::Satisfied, None).is_none());
assert!(classes_for_negotiation_outcome(NegotiationOutcome::Degraded, None).is_none());
}
}