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}