Skip to main content

chio_settle/
retry.rs

1//! Bounded exponential retry policy for [`SettlementHook`] failures.
2//!
3//! Binds settlement failures to a documented retry envelope
4//! plus a `settle_dead_letters` table. The policy is a pure function:
5//! it does not own any clock or storage, leaving observability and
6//! persistence to the kernel observer slot and the SQLite store
7//! (see `chio-store-sqlite::dead_letters`).
8//!
9//! Fail-closed: once the bounded number of attempts has been exhausted,
10//! the next decision is [`RetryDecision::DeadLetter`] and the caller
11//! MUST persist a `settle_dead_letters` row instead of replaying.
12//! Permanent outcomes short-circuit the retry envelope on the first
13//! attempt.
14//!
15//! [`SettlementHook`]: crate::hook::SettlementHook
16
17use std::time::Duration;
18
19use serde::{Deserialize, Deserializer, Serialize};
20use thiserror::Error;
21
22use crate::hook::{
23    SettlementFailureClass, SettlementFailureCode, SettlementFailureReason, SettlementSkipReason,
24};
25use crate::outcome_store::SettlementRoutingInput;
26
27/// Schema string emitted on the wire for [`DeadLetterRecord`] frames.
28pub const SETTLE_DEAD_LETTER_SCHEMA: &str = "chio.settle.dead-letter.v1";
29
30fn deserialize_dead_letter_schema<'de, D>(deserializer: D) -> Result<String, D::Error>
31where
32    D: Deserializer<'de>,
33{
34    let schema = String::deserialize(deserializer)?;
35    if schema == SETTLE_DEAD_LETTER_SCHEMA {
36        Ok(schema)
37    } else {
38        Err(serde::de::Error::custom(
39            "unsupported settlement dead-letter schema",
40        ))
41    }
42}
43
44/// Bound on the number of retries before a transient failure is
45/// downgraded to a permanent dead-letter row. The total attempt count
46/// is `max_retries + 1` (the original call plus the retries).
47pub const DEFAULT_MAX_RETRIES: u32 = 5;
48
49/// Initial backoff for the first retry attempt.
50pub const DEFAULT_INITIAL_BACKOFF_MS: u64 = 250;
51
52/// Multiplier applied to the previous backoff to produce the next.
53pub const DEFAULT_BACKOFF_MULTIPLIER: u32 = 2;
54
55/// Hard cap on a single backoff interval (avoids unbounded growth).
56pub const DEFAULT_BACKOFF_CAP_MS: u64 = 60_000;
57
58const MAX_RETRIES: u32 = 32;
59const MAX_BACKOFF_CAP_MS: u64 = 86_400_000;
60const MAX_BACKOFF_MULTIPLIER: u32 = 16;
61
62/// Invalid bounded retry policy.
63#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)]
64pub enum RetryPolicyError {
65    #[error("max_retries exceeds 32: {max_retries}")]
66    MaxRetriesTooHigh { max_retries: u32 },
67    #[error("initial_backoff_ms must be nonzero")]
68    InitialBackoffZero,
69    #[error("backoff_cap_ms must be nonzero")]
70    BackoffCapZero,
71    #[error("initial_backoff_ms {initial_backoff_ms} exceeds backoff_cap_ms {backoff_cap_ms}")]
72    InitialBackoffExceedsCap {
73        initial_backoff_ms: u64,
74        backoff_cap_ms: u64,
75    },
76    #[error("backoff_cap_ms exceeds 86400000: {backoff_cap_ms}")]
77    BackoffCapTooHigh { backoff_cap_ms: u64 },
78    #[error("backoff_multiplier must be in 1..=16: {backoff_multiplier}")]
79    BackoffMultiplierOutOfRange { backoff_multiplier: u32 },
80}
81
82/// Bounded retry envelope for settlement routing.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct RetryPolicy {
86    /// Maximum number of retries before the failure is dead-lettered.
87    /// `0` means the original call is the only attempt.
88    pub max_retries: u32,
89    /// Initial backoff applied between attempt 0 and attempt 1.
90    pub initial_backoff_ms: u64,
91    /// Multiplier on the previous backoff when computing the next.
92    pub backoff_multiplier: u32,
93    /// Hard cap on a single backoff interval, in milliseconds.
94    pub backoff_cap_ms: u64,
95}
96
97impl Default for RetryPolicy {
98    fn default() -> Self {
99        Self {
100            max_retries: DEFAULT_MAX_RETRIES,
101            initial_backoff_ms: DEFAULT_INITIAL_BACKOFF_MS,
102            backoff_multiplier: DEFAULT_BACKOFF_MULTIPLIER,
103            backoff_cap_ms: DEFAULT_BACKOFF_CAP_MS,
104        }
105    }
106}
107
108impl RetryPolicy {
109    /// Validate the bounded retry envelope.
110    pub const fn validate(&self) -> Result<(), RetryPolicyError> {
111        if self.max_retries > MAX_RETRIES {
112            return Err(RetryPolicyError::MaxRetriesTooHigh {
113                max_retries: self.max_retries,
114            });
115        }
116        if self.initial_backoff_ms == 0 {
117            return Err(RetryPolicyError::InitialBackoffZero);
118        }
119        if self.backoff_cap_ms == 0 {
120            return Err(RetryPolicyError::BackoffCapZero);
121        }
122        if self.backoff_cap_ms > MAX_BACKOFF_CAP_MS {
123            return Err(RetryPolicyError::BackoffCapTooHigh {
124                backoff_cap_ms: self.backoff_cap_ms,
125            });
126        }
127        if self.initial_backoff_ms > self.backoff_cap_ms {
128            return Err(RetryPolicyError::InitialBackoffExceedsCap {
129                initial_backoff_ms: self.initial_backoff_ms,
130                backoff_cap_ms: self.backoff_cap_ms,
131            });
132        }
133        if self.backoff_multiplier == 0 || self.backoff_multiplier > MAX_BACKOFF_MULTIPLIER {
134            return Err(RetryPolicyError::BackoffMultiplierOutOfRange {
135                backoff_multiplier: self.backoff_multiplier,
136            });
137        }
138        Ok(())
139    }
140
141    /// Compute the backoff applied before the `attempt`-th retry.
142    /// `attempt = 0` returns the configured initial backoff.
143    /// Caps at [`Self::backoff_cap_ms`].
144    #[must_use]
145    pub fn backoff_for(&self, attempt: u32) -> Duration {
146        let factor = u64::from(self.backoff_multiplier)
147            .max(1)
148            .saturating_pow(attempt);
149        Duration::from_millis(
150            self.initial_backoff_ms
151                .saturating_mul(factor)
152                .min(self.backoff_cap_ms),
153        )
154    }
155}
156
157/// Decision returned by [`classify_attempt`].
158#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum RetryDecision {
160    /// The accepted outcome requires no retry work.
161    Accepted,
162    /// The skipped outcome requires no retry work.
163    Skip {
164        /// Closed skip reason from the routing input.
165        reason: SettlementSkipReason,
166    },
167    /// Replay the hook after the bounded backoff.
168    Retry {
169        /// Persisted attempt number for the next invocation.
170        attempt: u32,
171        /// Delay before the next invocation.
172        backoff: Duration,
173        /// Bounded failure reason preserved for the retry row.
174        reason: SettlementFailureReason,
175    },
176    /// Persist a terminal dead letter without further retries.
177    DeadLetter {
178        /// Bounded terminal failure reason.
179        reason: SettlementFailureReason,
180    },
181}
182
183/// Classify one attempt's outcome under the supplied policy.
184///
185/// `attempt` is the zero-indexed retry counter for the current
186/// observation. Pass `0` on the first failure; on the next failure
187/// pass `1`, etc. The returned [`RetryDecision`] tells the caller
188/// whether to sleep and replay or to land a dead-letter row.
189///
190/// Permanent outcomes short-circuit and typed reasons are preserved.
191#[must_use]
192pub fn classify_attempt(
193    policy: &RetryPolicy,
194    attempt: u32,
195    outcome: &SettlementRoutingInput,
196) -> RetryDecision {
197    match outcome {
198        SettlementRoutingInput::Accepted => RetryDecision::Accepted,
199        SettlementRoutingInput::Skipped { reason } => RetryDecision::Skip { reason: *reason },
200        SettlementRoutingInput::Permanent { reason } => RetryDecision::DeadLetter {
201            reason: reason.clone(),
202        },
203        SettlementRoutingInput::Retryable { reason } => {
204            if reason.effective_class(SettlementFailureClass::Retryable)
205                == SettlementFailureClass::Permanent
206                || attempt >= policy.max_retries
207            {
208                RetryDecision::DeadLetter {
209                    reason: reason.clone(),
210                }
211            } else {
212                RetryDecision::Retry {
213                    attempt: attempt + 1,
214                    backoff: policy.backoff_for(attempt),
215                    reason: reason.clone(),
216                }
217            }
218        }
219    }
220}
221
222/// Permanent record persisted in the `settle_dead_letters` table.
223///
224/// Wire-stable: the canonical-JSON bytes of this struct are the
225/// row contents for offline review. The kernel observer slot
226/// constructs one of these on either a permanent outcome or an
227/// exhausted retry envelope.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
229pub struct DeadLetterRecord {
230    /// Schema tag (`chio.settle.dead-letter.v1`).
231    pub schema: String,
232    /// `id` of the originating receipt.
233    pub receipt_id: String,
234    /// Receipt finalization timestamp at the time of dead-lettering.
235    pub finalized_at: u64,
236    /// Number of attempts that ran before the failure was sealed in.
237    /// Always at least one (the original call).
238    pub attempts: u32,
239    /// Bounded terminal failure reason.
240    pub reason: SettlementFailureReason,
241}
242
243#[derive(Deserialize)]
244#[serde(deny_unknown_fields)]
245struct TypedDeadLetterRecord {
246    #[serde(deserialize_with = "deserialize_dead_letter_schema")]
247    schema: String,
248    receipt_id: String,
249    finalized_at: u64,
250    attempts: u32,
251    reason: SettlementFailureReason,
252}
253
254#[derive(Deserialize)]
255#[serde(deny_unknown_fields)]
256struct LegacyDeadLetterRecord {
257    #[serde(deserialize_with = "deserialize_dead_letter_schema")]
258    schema: String,
259    receipt_id: String,
260    finalized_at: u64,
261    attempts: u32,
262    reason: String,
263    #[serde(default)]
264    pipeline_error: Option<String>,
265}
266
267#[derive(Deserialize)]
268#[serde(untagged)]
269enum DeadLetterRecordWire {
270    Typed(TypedDeadLetterRecord),
271    Legacy(LegacyDeadLetterRecord),
272}
273
274impl<'de> Deserialize<'de> for DeadLetterRecord {
275    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
276    where
277        D: Deserializer<'de>,
278    {
279        match DeadLetterRecordWire::deserialize(deserializer)? {
280            DeadLetterRecordWire::Typed(record) => Ok(Self {
281                schema: record.schema,
282                receipt_id: record.receipt_id,
283                finalized_at: record.finalized_at,
284                attempts: record.attempts,
285                reason: record.reason,
286            }),
287            DeadLetterRecordWire::Legacy(record) => {
288                let detail = record.pipeline_error.as_deref().unwrap_or(&record.reason);
289                Ok(Self {
290                    schema: record.schema,
291                    receipt_id: record.receipt_id,
292                    finalized_at: record.finalized_at,
293                    attempts: record.attempts,
294                    reason: SettlementFailureReason::from_detail(
295                        SettlementFailureCode::Backend,
296                        detail,
297                    ),
298                })
299            }
300        }
301    }
302}
303
304impl DeadLetterRecord {
305    /// Return whether this record uses the schema understood by this build.
306    #[must_use]
307    pub fn has_supported_schema(&self) -> bool {
308        self.schema == SETTLE_DEAD_LETTER_SCHEMA
309    }
310
311    /// Build a new dead-letter record stamped with the canonical schema.
312    #[must_use]
313    pub fn new(
314        receipt_id: impl Into<String>,
315        finalized_at: u64,
316        attempts: u32,
317        reason: SettlementFailureReason,
318    ) -> Self {
319        Self {
320            schema: SETTLE_DEAD_LETTER_SCHEMA.to_string(),
321            receipt_id: receipt_id.into(),
322            finalized_at,
323            attempts: attempts.max(1),
324            reason,
325        }
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    fn failure(detail: &str) -> SettlementFailureReason {
334        SettlementFailureReason::from_detail(SettlementFailureCode::Rpc, detail)
335    }
336
337    fn serialize<T: Serialize>(value: &T) -> String {
338        match serde_json::to_string(value) {
339            Ok(encoded) => encoded,
340            Err(error) => panic!("value must serialize: {error}"),
341        }
342    }
343
344    #[test]
345    fn schema_is_stable() {
346        assert_eq!(SETTLE_DEAD_LETTER_SCHEMA, "chio.settle.dead-letter.v1");
347    }
348
349    #[test]
350    fn string_reason_v1_schema_decodes_to_a_bounded_reason() {
351        let decoded = serde_json::from_value::<DeadLetterRecord>(serde_json::json!({
352            "schema": "chio.settle.dead-letter.v1",
353            "receipt_id": "receipt-1",
354            "finalized_at": 1,
355            "attempts": 1,
356            "reason": "rpc unavailable",
357            "pipeline_error": "settlement pipeline error: rpc unavailable",
358        }));
359        let record = match decoded {
360            Ok(record) => record,
361            Err(error) => panic!("legacy dead-letter record must decode: {error}"),
362        };
363
364        let expected = SettlementFailureReason::from_detail(
365            SettlementFailureCode::Backend,
366            "settlement pipeline error: rpc unavailable",
367        );
368        assert_eq!(record.reason, expected);
369    }
370
371    #[test]
372    fn dead_letter_deserialization_rejects_an_unsupported_schema() {
373        let result = serde_json::from_value::<DeadLetterRecord>(serde_json::json!({
374            "schema": "chio.settle.dead-letter.v99",
375            "receipt_id": "receipt-1",
376            "finalized_at": 1,
377            "attempts": 1,
378            "reason": {
379                "code": "backend",
380                "detail_sha256": vec![0_u8; 32],
381            },
382        }));
383
384        assert!(result.is_err());
385    }
386
387    #[test]
388    fn default_policy_matches_documented_bounds() {
389        let policy = RetryPolicy::default();
390        assert_eq!(policy.max_retries, DEFAULT_MAX_RETRIES);
391        assert_eq!(policy.initial_backoff_ms, DEFAULT_INITIAL_BACKOFF_MS);
392        assert_eq!(policy.backoff_multiplier, DEFAULT_BACKOFF_MULTIPLIER);
393        assert_eq!(policy.backoff_cap_ms, DEFAULT_BACKOFF_CAP_MS);
394    }
395
396    #[test]
397    fn backoff_grows_exponentially_until_cap() {
398        let policy = RetryPolicy {
399            max_retries: 8,
400            initial_backoff_ms: 100,
401            backoff_multiplier: 2,
402            backoff_cap_ms: 1000,
403        };
404        assert_eq!(policy.backoff_for(0), Duration::from_millis(100));
405        assert_eq!(policy.backoff_for(1), Duration::from_millis(200));
406        assert_eq!(policy.backoff_for(2), Duration::from_millis(400));
407        assert_eq!(policy.backoff_for(3), Duration::from_millis(800));
408        assert_eq!(policy.backoff_for(4), Duration::from_millis(1000));
409        assert_eq!(policy.backoff_for(50), Duration::from_millis(1000));
410    }
411
412    #[test]
413    fn permanent_outcomes_skip_the_retry_envelope() {
414        let policy = RetryPolicy::default();
415        let reason = failure("policy denied");
416        let outcome = SettlementRoutingInput::Permanent {
417            reason: reason.clone(),
418        };
419        match classify_attempt(&policy, 0, &outcome) {
420            RetryDecision::DeadLetter { reason: actual } => assert_eq!(actual, reason),
421            other => panic!("expected dead letter, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn skipped_outcomes_pass_through() {
427        let policy = RetryPolicy::default();
428        let outcome = SettlementRoutingInput::Skipped {
429            reason: SettlementSkipReason::ZeroCharge,
430        };
431        assert_eq!(
432            classify_attempt(&policy, 0, &outcome),
433            RetryDecision::Skip {
434                reason: SettlementSkipReason::ZeroCharge,
435            }
436        );
437    }
438
439    #[test]
440    fn accepted_outcomes_pass_through() {
441        let policy = RetryPolicy::default();
442        assert_eq!(
443            classify_attempt(&policy, 0, &SettlementRoutingInput::Accepted),
444            RetryDecision::Accepted
445        );
446    }
447
448    #[test]
449    fn retryable_outcomes_consume_the_envelope_then_dead_letter() {
450        let policy = RetryPolicy {
451            max_retries: 2,
452            initial_backoff_ms: 10,
453            backoff_multiplier: 2,
454            backoff_cap_ms: 100,
455        };
456        let reason = failure("rpc lag");
457        let outcome = SettlementRoutingInput::Retryable {
458            reason: reason.clone(),
459        };
460        match classify_attempt(&policy, 0, &outcome) {
461            RetryDecision::Retry {
462                attempt,
463                backoff,
464                reason: actual,
465            } => {
466                assert_eq!(attempt, 1);
467                assert_eq!(backoff, Duration::from_millis(10));
468                assert_eq!(actual, reason);
469            }
470            other => panic!("expected retry, got {other:?}"),
471        }
472        match classify_attempt(&policy, 1, &outcome) {
473            RetryDecision::Retry { attempt, .. } => assert_eq!(attempt, 2),
474            other => panic!("expected retry, got {other:?}"),
475        }
476        match classify_attempt(&policy, 2, &outcome) {
477            RetryDecision::DeadLetter { reason: actual } => assert_eq!(actual, reason),
478            other => panic!("expected dead letter, got {other:?}"),
479        }
480    }
481
482    #[test]
483    fn dead_letter_record_contains_only_bounded_failure_detail() {
484        let record = DeadLetterRecord::new("rcpt-1", 100, 3, failure("connection refused"));
485        let encoded = serialize(&record);
486
487        assert_eq!(record.attempts, 3);
488        assert_eq!(record.schema, SETTLE_DEAD_LETTER_SCHEMA);
489        assert_eq!(record.reason.code(), SettlementFailureCode::Rpc);
490        assert!(!encoded.contains("connection refused"));
491        assert!(!encoded.contains("pipeline_error"));
492    }
493
494    #[test]
495    fn dead_letter_record_attempts_floor_is_one() {
496        let record = DeadLetterRecord::new("rcpt-x", 0, 0, failure("permanent"));
497        assert_eq!(record.attempts, 1);
498    }
499
500    #[test]
501    fn retry_policy_accepts_every_boundary() {
502        let default = RetryPolicy::default();
503        let valid = [
504            RetryPolicy {
505                max_retries: 0,
506                ..default
507            },
508            RetryPolicy {
509                max_retries: 32,
510                ..default
511            },
512            RetryPolicy {
513                initial_backoff_ms: 1,
514                ..default
515            },
516            RetryPolicy {
517                initial_backoff_ms: 1,
518                backoff_cap_ms: 1,
519                ..default
520            },
521            RetryPolicy {
522                backoff_cap_ms: 86_400_000,
523                ..default
524            },
525            RetryPolicy {
526                backoff_multiplier: 1,
527                ..default
528            },
529            RetryPolicy {
530                backoff_multiplier: 16,
531                ..default
532            },
533        ];
534
535        for policy in valid {
536            assert_eq!(policy.validate(), Ok(()));
537        }
538    }
539
540    #[test]
541    fn retry_policy_rejects_every_out_of_bounds_value() {
542        let default = RetryPolicy::default();
543        let invalid = [
544            (
545                RetryPolicy {
546                    max_retries: 33,
547                    ..default
548                },
549                RetryPolicyError::MaxRetriesTooHigh { max_retries: 33 },
550            ),
551            (
552                RetryPolicy {
553                    initial_backoff_ms: 0,
554                    ..default
555                },
556                RetryPolicyError::InitialBackoffZero,
557            ),
558            (
559                RetryPolicy {
560                    backoff_cap_ms: 0,
561                    ..default
562                },
563                RetryPolicyError::BackoffCapZero,
564            ),
565            (
566                RetryPolicy {
567                    initial_backoff_ms: default.backoff_cap_ms + 1,
568                    ..default
569                },
570                RetryPolicyError::InitialBackoffExceedsCap {
571                    initial_backoff_ms: default.backoff_cap_ms + 1,
572                    backoff_cap_ms: default.backoff_cap_ms,
573                },
574            ),
575            (
576                RetryPolicy {
577                    backoff_cap_ms: 86_400_001,
578                    ..default
579                },
580                RetryPolicyError::BackoffCapTooHigh {
581                    backoff_cap_ms: 86_400_001,
582                },
583            ),
584            (
585                RetryPolicy {
586                    backoff_multiplier: 0,
587                    ..default
588                },
589                RetryPolicyError::BackoffMultiplierOutOfRange {
590                    backoff_multiplier: 0,
591                },
592            ),
593            (
594                RetryPolicy {
595                    backoff_multiplier: 17,
596                    ..default
597                },
598                RetryPolicyError::BackoffMultiplierOutOfRange {
599                    backoff_multiplier: 17,
600                },
601            ),
602        ];
603
604        for (policy, expected) in invalid {
605            assert_eq!(policy.validate(), Err(expected));
606        }
607    }
608
609    #[test]
610    fn typed_retry_reason_survives_exhaustion() {
611        let reason = failure("upstream unavailable");
612        let input = SettlementRoutingInput::Retryable {
613            reason: reason.clone(),
614        };
615        let policy = RetryPolicy {
616            max_retries: 0,
617            ..RetryPolicy::default()
618        };
619
620        assert!(matches!(
621            classify_attempt(&policy, 0, &input),
622            RetryDecision::DeadLetter { reason: actual } if actual == reason
623        ));
624    }
625
626    #[test]
627    fn known_permanent_code_never_enters_the_retry_envelope() {
628        let reason = SettlementFailureReason::from_detail(
629            SettlementFailureCode::InvalidReceiptSignature,
630            "invalid signature",
631        );
632        let input = SettlementRoutingInput::Retryable {
633            reason: reason.clone(),
634        };
635
636        assert_eq!(
637            classify_attempt(&RetryPolicy::default(), 0, &input),
638            RetryDecision::DeadLetter { reason }
639        );
640    }
641
642    #[test]
643    fn backoff_with_unit_multiplier_is_constant_for_any_attempt() {
644        let policy = RetryPolicy {
645            backoff_multiplier: 1,
646            ..RetryPolicy::default()
647        };
648
649        assert_eq!(
650            policy.backoff_for(u32::MAX),
651            Duration::from_millis(policy.initial_backoff_ms)
652        );
653    }
654}