Skip to main content

lifeloop/router/
failure_mapping.rs

1//! Failure-class mapping (issue #15).
2//!
3//! Fills the [`FailureMapper`] seam declared in `src/router/seams.rs`
4//! (issue #7) and the validation-layer companion to the
5//! `receipt.emitted` guard added at emission time in
6//! `src/router/receipts.rs` (issue #14).
7//!
8//! # Boundary
9//!
10//! Owns:
11//! * [`LifeloopFailureMapper`] — concrete [`FailureMapper`]
12//!   implementation. Pure function, no state.
13//! * [`failure_class_for_route_error`] / [`failure_class_for_receipt_error`]
14//!   / [`failure_class_for_transport`] — free helpers used by the
15//!   mapper and exposed for callers that hold one error shape and
16//!   want a [`FailureClass`] without going through the trait.
17//! * [`retry_class_for`] — a thin wrapper over
18//!   [`crate::FailureClass::default_retry`] kept here so the
19//!   per-failure retry rule is named in one place.
20//! * [`TransportError`] — a small typed enum covering the IO/transport
21//!   shapes a real callback transport would surface.
22//! * [`validate_receipt_eligible`] — validation-layer guard that
23//!   refuses to plan a receipt for a `receipt.emitted` event,
24//!   complementing the emission-time guard in
25//!   [`super::receipts::ReceiptError::ReceiptEmittedNotEmittable`].
26//!
27//! Does **not** own:
28//! * negotiation outcome → status mapping (that lives in
29//!   `src/router/receipts.rs::derive_status`);
30//! * adapter-specific failure semantics. Per-adapter mapping fixtures
31//!   live in `tests/router_failure_mapping.rs` and translate
32//!   adapter-emitted strings to the *shared* [`FailureClass`]
33//!   vocabulary; they do not extend that vocabulary.
34//!
35//! # Mapping rationale
36//!
37//! Every [`super::RouteError`] variant maps to exactly one
38//! [`FailureClass`]:
39//!
40//! | RouteError variant            | FailureClass        |
41//! |-------------------------------|---------------------|
42//! | `SchemaVersionMismatch`       | `InvalidRequest`    |
43//! | `EmptySentinel`               | `InvalidRequest`    |
44//! | `UnknownEventName`            | `InvalidRequest`    |
45//! | `UnknownEnumName`             | `InvalidRequest`    |
46//! | `InvalidFrameContext`         | `InvalidRequest`    |
47//! | `InvalidPayloadRef`           | `InvalidRequest`    |
48//! | `InvalidEventEnvelope`        | `InvalidRequest`    |
49//! | `AdapterIdNotFound`           | `AdapterUnavailable`|
50//! | `AdapterVersionMismatch`      | `AdapterUnavailable`|
51//!
52//! Every [`super::ReceiptError`] variant maps to exactly one
53//! [`FailureClass`]:
54//!
55//! | ReceiptError variant          | FailureClass        |
56//! |-------------------------------|---------------------|
57//! | `ReceiptEmittedNotEmittable`  | `InvalidRequest`    |
58//! | `Conflict`                    | `StateConflict`     |
59//! | `Invalid`                     | `InvalidRequest`    |
60
61use crate::{FailureClass, LifecycleEventKind, NegotiationOutcome, RetryClass};
62
63use super::plan::RoutingPlan;
64use super::receipts::ReceiptError;
65use super::seams::FailureMapper;
66use super::validation::RouteError;
67
68// ===========================================================================
69// TransportError
70// ===========================================================================
71
72/// Coarse shape of a callback-transport failure.
73///
74/// A real callback transport (HTTP, IPC, in-process bridge) surfaces
75/// errors at varying granularity. The mapper consumes this enum so
76/// the trait is portable across transports and so the retry-class
77/// derivation has a stable input vocabulary.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum TransportError {
80    /// Network or pipe-level failure: connection refused, broken
81    /// pipe, peer reset.
82    Io(String),
83    /// The remote did not respond within the configured deadline.
84    Timeout,
85    /// A non-IO crash inside the transport itself (serialization
86    /// panic, internal bug). Distinct from `Io` because the retry
87    /// class differs (`InternalError` -> `RetryAfterReread`).
88    Internal(String),
89}
90
91impl std::fmt::Display for TransportError {
92    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93        match self {
94            Self::Io(detail) => write!(f, "transport io error: {detail}"),
95            Self::Timeout => f.write_str("transport timeout"),
96            Self::Internal(detail) => write!(f, "transport internal error: {detail}"),
97        }
98    }
99}
100
101impl std::error::Error for TransportError {}
102
103// ===========================================================================
104// Free mapping helpers
105// ===========================================================================
106
107/// Map a [`RouteError`] to a [`FailureClass`].
108///
109/// Pure function: same variant always maps to the same class so a
110/// receipt ledger replays consistently.
111pub fn failure_class_for_route_error(err: &RouteError) -> FailureClass {
112    match err {
113        RouteError::SchemaVersionMismatch { .. }
114        | RouteError::EmptySentinel { .. }
115        | RouteError::UnknownEventName { .. }
116        | RouteError::UnknownEnumName { .. }
117        | RouteError::InvalidFrameContext { .. }
118        | RouteError::InvalidPayloadRef { .. }
119        | RouteError::InvalidEventEnvelope { .. } => FailureClass::InvalidRequest,
120        RouteError::AdapterIdNotFound { .. } | RouteError::AdapterVersionMismatch { .. } => {
121            FailureClass::AdapterUnavailable
122        }
123    }
124}
125
126/// Map a [`ReceiptError`] to a [`FailureClass`].
127pub fn failure_class_for_receipt_error(err: &ReceiptError) -> FailureClass {
128    match err {
129        ReceiptError::ReceiptEmittedNotEmittable => FailureClass::InvalidRequest,
130        ReceiptError::Conflict { .. } => FailureClass::StateConflict,
131        ReceiptError::Invalid(_) => FailureClass::InvalidRequest,
132    }
133}
134
135/// Map a [`TransportError`] to a [`FailureClass`].
136pub fn failure_class_for_transport(err: &TransportError) -> FailureClass {
137    match err {
138        TransportError::Io(_) => FailureClass::TransportError,
139        TransportError::Timeout => FailureClass::Timeout,
140        TransportError::Internal(_) => FailureClass::InternalError,
141    }
142}
143
144/// Map a [`NegotiationOutcome`] to a `(failure_class, retry_class)`
145/// pair when the outcome blocks dispatch. Returns `None` for
146/// non-blocking outcomes (`Satisfied`, `Degraded`).
147///
148/// `RequiresOperator` is the canonical operator-required surface:
149/// it always pairs `OperatorRequired` with `RetryAfterOperator` so
150/// the receipt has a deterministic retry hint.
151pub fn classes_for_negotiation_outcome(
152    outcome: NegotiationOutcome,
153    explicit_failure_class: Option<FailureClass>,
154) -> Option<(FailureClass, RetryClass)> {
155    match outcome {
156        NegotiationOutcome::Unsupported => {
157            let fc = explicit_failure_class.unwrap_or(FailureClass::CapabilityUnsupported);
158            Some((fc, fc.default_retry()))
159        }
160        NegotiationOutcome::RequiresOperator => {
161            let fc = FailureClass::OperatorRequired;
162            // OperatorRequired::default_retry() is RetryAfterOperator
163            // by spec; assert the pairing explicitly so anyone
164            // grepping for "operator-required surface" finds the
165            // ground truth here.
166            debug_assert_eq!(fc.default_retry(), RetryClass::RetryAfterOperator);
167            Some((fc, RetryClass::RetryAfterOperator))
168        }
169        NegotiationOutcome::Satisfied | NegotiationOutcome::Degraded => None,
170    }
171}
172
173/// Per-failure default retry-class hint.
174///
175/// Thin wrapper over [`FailureClass::default_retry`] kept here so the
176/// per-class retry rule has a single discoverable name in the router
177/// surface.
178pub fn retry_class_for(failure_class: FailureClass) -> RetryClass {
179    failure_class.default_retry()
180}
181
182// ===========================================================================
183// LifeloopFailureMapper
184// ===========================================================================
185
186/// Concrete [`FailureMapper`] for issue #15.
187///
188/// Stateless and zero-sized — instances exist only so the type
189/// participates in trait dispatch.
190#[derive(Debug, Default, Clone, Copy)]
191pub struct LifeloopFailureMapper;
192
193impl LifeloopFailureMapper {
194    pub fn new() -> Self {
195        Self
196    }
197
198    /// Convenience: map a [`ReceiptError`] to the `(failure, retry)`
199    /// pair a `failed` receipt would carry.
200    pub fn map_receipt_error(&self, err: &ReceiptError) -> (FailureClass, RetryClass) {
201        let fc = failure_class_for_receipt_error(err);
202        (fc, retry_class_for(fc))
203    }
204
205    /// Convenience: map a [`TransportError`] to the `(failure, retry)`
206    /// pair a `failed` receipt would carry.
207    pub fn map_transport_error(&self, err: &TransportError) -> (FailureClass, RetryClass) {
208        let fc = failure_class_for_transport(err);
209        (fc, retry_class_for(fc))
210    }
211
212    /// Convenience: map a generic `dyn std::error::Error` to a
213    /// `(InternalError, RetryAfterReread)` pair. Used when the only
214    /// thing the caller has is a boxed error.
215    pub fn map_unknown_error(&self, _err: &dyn std::error::Error) -> (FailureClass, RetryClass) {
216        let fc = FailureClass::InternalError;
217        (fc, retry_class_for(fc))
218    }
219}
220
221impl FailureMapper for LifeloopFailureMapper {
222    fn map_route_error(&self, err: &RouteError) -> (FailureClass, RetryClass) {
223        let fc = failure_class_for_route_error(err);
224        (fc, retry_class_for(fc))
225    }
226}
227
228// ===========================================================================
229// From conversions
230// ===========================================================================
231
232impl From<&RouteError> for FailureClass {
233    fn from(err: &RouteError) -> Self {
234        failure_class_for_route_error(err)
235    }
236}
237
238impl From<&ReceiptError> for FailureClass {
239    fn from(err: &ReceiptError) -> Self {
240        failure_class_for_receipt_error(err)
241    }
242}
243
244impl From<&TransportError> for FailureClass {
245    fn from(err: &TransportError) -> Self {
246        failure_class_for_transport(err)
247    }
248}
249
250// ===========================================================================
251// Validation-layer receipt-eligibility guard
252// ===========================================================================
253
254/// Validation-layer guard: refuse to plan receipt synthesis for a
255/// `receipt.emitted` event.
256///
257/// Complements the emission-time guard in
258/// [`super::receipts::LifeloopReceiptEmitter::synthesize_and_emit`]:
259/// the emit-time guard catches the same misuse when a caller already
260/// holds a [`super::NegotiatedPlan`]; this validation-layer guard
261/// catches it earlier, against a [`RoutingPlan`], so a misuse can be
262/// rejected before negotiation runs.
263///
264/// Returns [`RouteError::InvalidEventEnvelope`] on rejection so the
265/// failure-class mapping is `InvalidRequest` — consistent with how
266/// the same misuse is mapped when caught at deserialize time.
267pub fn validate_receipt_eligible(plan: &RoutingPlan) -> Result<(), RouteError> {
268    if matches!(plan.event, LifecycleEventKind::ReceiptEmitted) {
269        return Err(RouteError::InvalidEventEnvelope {
270            detail: "receipt.emitted is a notification event and must not produce \
271                     a lifecycle receipt"
272                .into(),
273        });
274    }
275    Ok(())
276}
277
278#[cfg(test)]
279mod tests {
280    use super::*;
281
282    #[test]
283    fn route_error_maps_to_invalid_request_or_adapter_unavailable() {
284        let cases: Vec<(RouteError, FailureClass)> = vec![
285            (
286                RouteError::SchemaVersionMismatch {
287                    expected: "a".into(),
288                    found: "b".into(),
289                },
290                FailureClass::InvalidRequest,
291            ),
292            (
293                RouteError::EmptySentinel { field: "x" },
294                FailureClass::InvalidRequest,
295            ),
296            (
297                RouteError::UnknownEventName {
298                    received: "bogus".into(),
299                },
300                FailureClass::InvalidRequest,
301            ),
302            (
303                RouteError::UnknownEnumName {
304                    field: "integration_mode",
305                    received: "weird".into(),
306                },
307                FailureClass::InvalidRequest,
308            ),
309            (
310                RouteError::InvalidFrameContext {
311                    detail: "missing".into(),
312                },
313                FailureClass::InvalidRequest,
314            ),
315            (
316                RouteError::InvalidPayloadRef {
317                    index: 0,
318                    detail: "empty".into(),
319                },
320                FailureClass::InvalidRequest,
321            ),
322            (
323                RouteError::InvalidEventEnvelope { detail: "x".into() },
324                FailureClass::InvalidRequest,
325            ),
326            (
327                RouteError::AdapterIdNotFound {
328                    adapter_id: "ghost".into(),
329                },
330                FailureClass::AdapterUnavailable,
331            ),
332            (
333                RouteError::AdapterVersionMismatch {
334                    adapter_id: "codex".into(),
335                    requested: "0.0.0".into(),
336                    registered: "0.1.0".into(),
337                },
338                FailureClass::AdapterUnavailable,
339            ),
340        ];
341        let mapper = LifeloopFailureMapper::new();
342        for (err, expected) in cases {
343            let (fc, rc) = mapper.map_route_error(&err);
344            assert_eq!(fc, expected, "route error -> failure class: {err:?}");
345            assert_eq!(rc, fc.default_retry(), "retry class follows default");
346            // From impl agrees with the trait method.
347            let via_from: FailureClass = (&err).into();
348            assert_eq!(via_from, fc);
349        }
350    }
351
352    #[test]
353    fn receipt_error_mapping() {
354        let mapper = LifeloopFailureMapper::new();
355        assert_eq!(
356            mapper.map_receipt_error(&ReceiptError::ReceiptEmittedNotEmittable),
357            (FailureClass::InvalidRequest, RetryClass::DoNotRetry),
358        );
359        assert_eq!(
360            mapper.map_receipt_error(&ReceiptError::Conflict {
361                idempotency_key: "k".into()
362            }),
363            (FailureClass::StateConflict, RetryClass::RetryAfterReread),
364        );
365    }
366
367    #[test]
368    fn transport_error_mapping_distinguishes_io_timeout_internal() {
369        let mapper = LifeloopFailureMapper::new();
370        assert_eq!(
371            mapper
372                .map_transport_error(&TransportError::Io("EPIPE".into()))
373                .0,
374            FailureClass::TransportError,
375        );
376        assert_eq!(
377            mapper.map_transport_error(&TransportError::Timeout).0,
378            FailureClass::Timeout,
379        );
380        assert_eq!(
381            mapper
382                .map_transport_error(&TransportError::Internal("panic".into()))
383                .0,
384            FailureClass::InternalError,
385        );
386    }
387
388    #[test]
389    fn negotiation_requires_operator_uses_operator_required_pair() {
390        let pair = classes_for_negotiation_outcome(NegotiationOutcome::RequiresOperator, None);
391        assert_eq!(
392            pair,
393            Some((
394                FailureClass::OperatorRequired,
395                RetryClass::RetryAfterOperator
396            ))
397        );
398    }
399
400    #[test]
401    fn negotiation_satisfied_and_degraded_yield_no_blocking_pair() {
402        assert!(classes_for_negotiation_outcome(NegotiationOutcome::Satisfied, None).is_none());
403        assert!(classes_for_negotiation_outcome(NegotiationOutcome::Degraded, None).is_none());
404    }
405}