1use 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
27pub 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
44pub const DEFAULT_MAX_RETRIES: u32 = 5;
48
49pub const DEFAULT_INITIAL_BACKOFF_MS: u64 = 250;
51
52pub const DEFAULT_BACKOFF_MULTIPLIER: u32 = 2;
54
55pub 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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct RetryPolicy {
86 pub max_retries: u32,
89 pub initial_backoff_ms: u64,
91 pub backoff_multiplier: u32,
93 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 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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
159pub enum RetryDecision {
160 Accepted,
162 Skip {
164 reason: SettlementSkipReason,
166 },
167 Retry {
169 attempt: u32,
171 backoff: Duration,
173 reason: SettlementFailureReason,
175 },
176 DeadLetter {
178 reason: SettlementFailureReason,
180 },
181}
182
183#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
229pub struct DeadLetterRecord {
230 pub schema: String,
232 pub receipt_id: String,
234 pub finalized_at: u64,
236 pub attempts: u32,
239 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 #[must_use]
307 pub fn has_supported_schema(&self) -> bool {
308 self.schema == SETTLE_DEAD_LETTER_SCHEMA
309 }
310
311 #[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}