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
213impl FailureMapper for LifeloopFailureMapper {
214 fn map_route_error(&self, err: &RouteError) -> (FailureClass, RetryClass) {
215 let fc = failure_class_for_route_error(err);
216 (fc, retry_class_for(fc))
217 }
218}
219
220// ===========================================================================
221// From conversions
222// ===========================================================================
223
224impl From<&RouteError> for FailureClass {
225 fn from(err: &RouteError) -> Self {
226 failure_class_for_route_error(err)
227 }
228}
229
230impl From<&ReceiptError> for FailureClass {
231 fn from(err: &ReceiptError) -> Self {
232 failure_class_for_receipt_error(err)
233 }
234}
235
236impl From<&TransportError> for FailureClass {
237 fn from(err: &TransportError) -> Self {
238 failure_class_for_transport(err)
239 }
240}
241
242// ===========================================================================
243// Validation-layer receipt-eligibility guard
244// ===========================================================================
245
246/// Validation-layer guard: refuse to plan receipt synthesis for a
247/// `receipt.emitted` event.
248///
249/// Complements the emission-time guard in
250/// [`super::receipts::LifeloopReceiptEmitter::synthesize_and_emit`]:
251/// the emit-time guard catches the same misuse when a caller already
252/// holds a [`super::NegotiatedPlan`]; this validation-layer guard
253/// catches it earlier, against a [`RoutingPlan`], so a misuse can be
254/// rejected before negotiation runs.
255///
256/// Returns [`RouteError::InvalidEventEnvelope`] on rejection so the
257/// failure-class mapping is `InvalidRequest` — consistent with how
258/// the same misuse is mapped when caught at deserialize time.
259pub fn validate_receipt_eligible(plan: &RoutingPlan) -> Result<(), RouteError> {
260 if matches!(plan.event, LifecycleEventKind::ReceiptEmitted) {
261 return Err(RouteError::InvalidEventEnvelope {
262 detail: "receipt.emitted is a notification event and must not produce \
263 a lifecycle receipt"
264 .into(),
265 });
266 }
267 Ok(())
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn route_error_maps_to_invalid_request_or_adapter_unavailable() {
276 let cases: Vec<(RouteError, FailureClass)> = vec![
277 (
278 RouteError::SchemaVersionMismatch {
279 expected: "a".into(),
280 found: "b".into(),
281 },
282 FailureClass::InvalidRequest,
283 ),
284 (
285 RouteError::EmptySentinel { field: "x" },
286 FailureClass::InvalidRequest,
287 ),
288 (
289 RouteError::UnknownEventName {
290 received: "bogus".into(),
291 },
292 FailureClass::InvalidRequest,
293 ),
294 (
295 RouteError::UnknownEnumName {
296 field: "integration_mode",
297 received: "weird".into(),
298 },
299 FailureClass::InvalidRequest,
300 ),
301 (
302 RouteError::InvalidFrameContext {
303 detail: "missing".into(),
304 },
305 FailureClass::InvalidRequest,
306 ),
307 (
308 RouteError::InvalidPayloadRef {
309 index: 0,
310 detail: "empty".into(),
311 },
312 FailureClass::InvalidRequest,
313 ),
314 (
315 RouteError::InvalidEventEnvelope { detail: "x".into() },
316 FailureClass::InvalidRequest,
317 ),
318 (
319 RouteError::AdapterIdNotFound {
320 adapter_id: "ghost".into(),
321 },
322 FailureClass::AdapterUnavailable,
323 ),
324 (
325 RouteError::AdapterVersionMismatch {
326 adapter_id: "codex".into(),
327 requested: "0.0.0".into(),
328 registered: "0.1.0".into(),
329 },
330 FailureClass::AdapterUnavailable,
331 ),
332 ];
333 let mapper = LifeloopFailureMapper::new();
334 for (err, expected) in cases {
335 let (fc, rc) = mapper.map_route_error(&err);
336 assert_eq!(fc, expected, "route error -> failure class: {err:?}");
337 assert_eq!(rc, fc.default_retry(), "retry class follows default");
338 // From impl agrees with the trait method.
339 let via_from: FailureClass = (&err).into();
340 assert_eq!(via_from, fc);
341 }
342 }
343
344 #[test]
345 fn receipt_error_mapping() {
346 let mapper = LifeloopFailureMapper::new();
347 assert_eq!(
348 mapper.map_receipt_error(&ReceiptError::ReceiptEmittedNotEmittable),
349 (FailureClass::InvalidRequest, RetryClass::DoNotRetry),
350 );
351 assert_eq!(
352 mapper.map_receipt_error(&ReceiptError::Conflict {
353 idempotency_key: "k".into()
354 }),
355 (FailureClass::StateConflict, RetryClass::RetryAfterReread),
356 );
357 }
358
359 #[test]
360 fn transport_error_mapping_distinguishes_io_timeout_internal() {
361 let mapper = LifeloopFailureMapper::new();
362 assert_eq!(
363 mapper
364 .map_transport_error(&TransportError::Io("EPIPE".into()))
365 .0,
366 FailureClass::TransportError,
367 );
368 assert_eq!(
369 mapper.map_transport_error(&TransportError::Timeout).0,
370 FailureClass::Timeout,
371 );
372 assert_eq!(
373 mapper
374 .map_transport_error(&TransportError::Internal("panic".into()))
375 .0,
376 FailureClass::InternalError,
377 );
378 }
379
380 #[test]
381 fn negotiation_requires_operator_uses_operator_required_pair() {
382 let pair = classes_for_negotiation_outcome(NegotiationOutcome::RequiresOperator, None);
383 assert_eq!(
384 pair,
385 Some((
386 FailureClass::OperatorRequired,
387 RetryClass::RetryAfterOperator
388 ))
389 );
390 }
391
392 #[test]
393 fn negotiation_satisfied_and_degraded_yield_no_blocking_pair() {
394 assert!(classes_for_negotiation_outcome(NegotiationOutcome::Satisfied, None).is_none());
395 assert!(classes_for_negotiation_outcome(NegotiationOutcome::Degraded, None).is_none());
396 }
397}