1use alloc::collections::BTreeSet;
2use alloc::string::{String, ToString};
3use alloc::sync::Arc;
4use alloc::vec::Vec;
5use bytes::Bytes;
6use core::future::Future;
7use core::time::Duration;
8use portable_atomic::{AtomicU64, Ordering};
9
10use crate::pipe::SendPipe;
11use crate::pipe::capabilities::{Clock, Idempotent, Replayable, Retryable};
12use crate::pipe::resilience::{Backoff, Jitter, RetryAction, RetryController};
13use crate::pipe::retry_rules::RetryRules;
14
15use crate::pipe::clock::TimeClock;
16use crate::pipe::handler::PipeHandle;
17use crate::pipe::labeled::Labeled;
18use crate::pipe::request::{Request, Response};
19use crate::pipe::telemetry_surface::{Labels, TelemetryHandle};
20use proxima_core::ProximaError;
21
22#[cfg(feature = "std")]
25use crate::pipe::handler::ThreadLocalPipeHandle;
26#[cfg(feature = "std")]
27use crate::pipe::primitives::Pipe;
28#[cfg(feature = "std")]
29use bon::Builder;
30#[cfg(feature = "std")]
31use conflaguration::{Settings, Validate, ValidationMessage};
32#[cfg(feature = "std")]
33use serde::{Deserialize, Serialize};
34#[cfg(feature = "std")]
35use serde_json::Value;
36#[cfg(feature = "std")]
37use std::pin::Pin;
38#[cfg(feature = "std")]
42use crate::pipe::handler::into_handle;
43#[cfg(feature = "std")]
44use crate::pipe::pipe_factory::PipeFactory;
45#[cfg(feature = "std")]
46use crate::transport::Replay;
47
48const DEFAULT_REPLAY_CAP_BYTES: usize = 4 * 1024 * 1024;
52
53const DEFAULT_MAX_ATTEMPTS: u32 = 3;
54const DEFAULT_BASE_DELAY_MS: u64 = 50;
55const DEFAULT_MAX_DELAY_MS: u64 = 2_000;
56const DEFAULT_BUDGET_CAPACITY: u64 = 100;
57const DEFAULT_BUDGET_REFILL_PER_SUCCESS: u64 = 10;
58const RETRY_COST: u64 = 100;
59
60const METRIC_ATTEMPTS: &str = "proxima.retry.attempts_total";
61const METRIC_SUCCEEDED: &str = "proxima.retry.succeeded_total";
62const METRIC_BUDGET_EXHAUSTED: &str = "proxima.retry.budget_exhausted_total";
63const METRIC_REPLAY_CAP_EXCEEDED: &str = "proxima.retry.replay_cap_exceeded_total";
64
65pub struct RetryBudget {
68 capacity: u64,
69 tokens: AtomicU64,
70 refill_per_success: u64,
71}
72
73impl RetryBudget {
74 #[must_use]
75 pub fn new(capacity: u64, refill_per_success: u64) -> Self {
76 Self {
77 capacity,
78 tokens: AtomicU64::new(capacity),
79 refill_per_success,
80 }
81 }
82
83 #[must_use]
84 pub fn tokens(&self) -> u64 {
85 self.tokens.load(Ordering::Relaxed)
86 }
87
88 pub fn try_consume(&self, cost: u64) -> bool {
89 loop {
90 let current = self.tokens.load(Ordering::Acquire);
91 if current < cost {
92 return false;
93 }
94 if self
95 .tokens
96 .compare_exchange_weak(
97 current,
98 current - cost,
99 Ordering::Release,
100 Ordering::Acquire,
101 )
102 .is_ok()
103 {
104 return true;
105 }
106 }
107 }
108
109 pub fn refill(&self) {
110 let cap = self.capacity;
111 let amount = self.refill_per_success;
112 let _ = self
113 .tokens
114 .fetch_update(Ordering::Release, Ordering::Acquire, |current| {
115 Some((current + amount).min(cap))
116 });
117 }
118}
119
120impl Default for RetryBudget {
121 fn default() -> Self {
122 Self::new(DEFAULT_BUDGET_CAPACITY, DEFAULT_BUDGET_REFILL_PER_SUCCESS)
123 }
124}
125
126pub struct Retry<Inner = PipeHandle, Clk = TimeClock> {
136 pub inner: Inner,
137 pub rules: RetryRules,
138 pub max_attempts: u32,
139 pub base_delay: Duration,
140 pub max_delay: Duration,
141 pub budget: Option<Arc<RetryBudget>>,
142 pub replay_cap_bytes: usize,
143 clock: Clk,
144}
145
146impl<Inner> Retry<Inner, TimeClock> {
151 #[must_use]
152 pub fn new(inner: Inner) -> Self {
153 Self::with_clock(inner, TimeClock)
154 }
155}
156
157impl<Inner, Clk> Retry<Inner, Clk> {
158 #[must_use]
162 pub fn with_clock(inner: Inner, clock: Clk) -> Self {
163 Self {
164 inner,
165 rules: RetryRules::default(),
166 max_attempts: DEFAULT_MAX_ATTEMPTS,
167 base_delay: Duration::from_millis(DEFAULT_BASE_DELAY_MS),
168 max_delay: Duration::from_millis(DEFAULT_MAX_DELAY_MS),
169 budget: None,
170 replay_cap_bytes: DEFAULT_REPLAY_CAP_BYTES,
171 clock,
172 }
173 }
174
175 #[must_use]
176 pub fn with_max_attempts(mut self, attempts: u32) -> Self {
177 self.max_attempts = attempts.max(1);
178 self
179 }
180
181 #[must_use]
182 pub fn with_base_delay(mut self, delay: Duration) -> Self {
183 self.base_delay = delay;
184 self
185 }
186
187 #[must_use]
188 pub fn with_max_delay(mut self, delay: Duration) -> Self {
189 self.max_delay = delay;
190 self
191 }
192
193 #[must_use]
194 pub fn with_budget(mut self, budget: Arc<RetryBudget>) -> Self {
195 self.budget = Some(budget);
196 self
197 }
198
199 #[must_use]
200 pub fn with_predicate(mut self, predicate: RetryPredicate) -> Self {
201 match predicate {
202 RetryPredicate::OnStatus(set) => self.rules.retry_on_status.extend(set),
203 RetryPredicate::OnAnyError => self.rules.retry_on_error = true,
204 RetryPredicate::OnIdempotentOnly => self.rules.idempotent_only = true,
205 }
206 self
207 }
208
209 #[must_use]
210 pub fn with_replay_cap_bytes(mut self, cap: usize) -> Self {
211 self.replay_cap_bytes = cap;
212 self
213 }
214
215 fn controller(&self) -> RetryController {
219 RetryController {
220 rules: self.rules.clone(),
221 backoff: Backoff::Exponential {
222 initial: self.base_delay,
223 factor: 2,
224 max: self.max_delay,
225 },
226 jitter: Jitter::Full,
227 max_attempts: self.max_attempts,
228 deadline: None,
229 }
230 }
231}
232
233async fn run_retry<In, Out, Err, Call, Fut, Clk>(
234 args: RunArgs<In, Clk>,
235 call_attempt: Call,
236) -> Result<Out, Err>
237where
238 In: Replayable + Idempotent + Labeled,
239 Out: Retryable,
240 Err: From<ProximaError>,
241 Call: Fn(In) -> Fut,
242 Fut: Future<Output = Result<Out, Err>>,
243 Clk: Clock,
244{
245 let RunArgs {
246 controller,
247 clock,
248 input,
249 budget,
250 replay_cap_bytes,
251 } = args;
252 let telemetry = input.telemetry();
253 let base_labels = input.labels();
254 let allow = input.is_idempotent() || !controller.rules.idempotent_only;
255 let (first_input, source) = input.fork(replay_cap_bytes);
256
257 telemetry.counter_inc(
258 METRIC_ATTEMPTS,
259 &with_extra(&base_labels, "outcome", "started"),
260 1,
261 );
262 let mut last_outcome = call_attempt(first_input).await;
263
264 if !allow {
265 return finalize(last_outcome, &telemetry, &base_labels);
266 }
267
268 let mut attempt = 0u32;
269 let mut prev_delay = Duration::ZERO;
270 loop {
271 let now_nanos = clock.now_nanos();
272 let rand = attempt_rand(attempt);
273 let after = match controller.on_outcome(attempt, &last_outcome, now_nanos, rand, prev_delay)
274 {
275 RetryAction::Done | RetryAction::Exhausted => break,
276 RetryAction::Retry { after } => after,
277 };
278 if let Some(budget) = budget.as_ref()
279 && !budget.try_consume(RETRY_COST)
280 {
281 telemetry.counter_inc(
282 METRIC_BUDGET_EXHAUSTED,
283 &with_extra(&base_labels, "reason", "budget"),
284 1,
285 );
286 break;
287 }
288 if !after.is_zero() {
289 clock.delay(after).await;
290 }
291 prev_delay = after;
292 attempt += 1;
293
294 let input = match In::replay(&source) {
295 Ok(input) => input,
296 Err(error) => {
297 telemetry.counter_inc(METRIC_REPLAY_CAP_EXCEEDED, &base_labels, 1);
298 return Err(error.into());
299 }
300 };
301 telemetry.counter_inc(
302 METRIC_ATTEMPTS,
303 &with_extra(&base_labels, "outcome", "started"),
304 1,
305 );
306 last_outcome = call_attempt(input).await;
307 }
308 finalize(last_outcome, &telemetry, &base_labels)
309}
310
311struct RunArgs<In, Clk> {
312 controller: RetryController,
313 clock: Clk,
314 input: In,
315 budget: Option<Arc<RetryBudget>>,
316 replay_cap_bytes: usize,
317}
318
319fn finalize<Out: Retryable, Err>(
320 outcome: Result<Out, Err>,
321 telemetry: &TelemetryHandle,
322 base_labels: &Labels,
323) -> Result<Out, Err> {
324 if matches!(&outcome, Ok(out) if out.is_success()) {
325 telemetry.counter_inc(METRIC_SUCCEEDED, base_labels, 1);
326 }
327 outcome
328}
329
330impl<Inner, Clk> SendPipe for Retry<Inner, Clk>
331where
332 Inner: SendPipe + Clone + Send + Sync + 'static,
333 Inner::In: Replayable + Idempotent + Labeled + Send + 'static,
334 Inner::Out: Retryable + Send + 'static,
335 Inner::Err: From<ProximaError> + core::fmt::Debug + Send + 'static,
336 Clk: Clock + Clone + Send + Sync + 'static,
337 Clk::Delay: Send,
338{
339 type In = Inner::In;
340 type Out = Inner::Out;
341 type Err = Inner::Err;
342
343 fn call(
344 &self,
345 input: Inner::In,
346 ) -> impl Future<Output = Result<Inner::Out, Inner::Err>> + Send {
347 let inner = self.inner.clone();
348 run_retry(
349 RunArgs {
350 controller: self.controller(),
351 clock: self.clock.clone(),
352 input,
353 budget: self.budget.clone(),
354 replay_cap_bytes: self.replay_cap_bytes,
355 },
356 move |attempt_input| {
357 let inner = inner.clone();
358 async move { SendPipe::call(&inner, attempt_input).await }
359 },
360 )
361 }
362}
363
364#[cfg(feature = "std")]
365impl<Clk> Pipe for Retry<ThreadLocalPipeHandle, Clk>
366where
367 ThreadLocalPipeHandle: Pipe<In = Request<Bytes>, Out = Response<Bytes>, Err = ProximaError>,
368 Clk: Clock + Clone + Send + Sync + 'static,
369{
370 type In = Request<Bytes>;
371 type Out = Response<Bytes>;
372 type Err = ProximaError;
373
374 fn call(
375 &self,
376 input: Request<Bytes>,
377 ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> {
378 let inner = self.inner.clone();
379 run_retry(
380 RunArgs {
381 controller: self.controller(),
382 clock: self.clock.clone(),
383 input,
384 budget: self.budget.clone(),
385 replay_cap_bytes: self.replay_cap_bytes,
386 },
387 move |attempt_input| {
388 let inner = inner.clone();
389 async move { Pipe::call(&inner, attempt_input).await }
390 },
391 )
392 }
393}
394
395#[cfg(feature = "std")]
399fn attempt_rand(_attempt: u32) -> u64 {
400 fastrand::u64(..)
401}
402
403#[cfg(not(feature = "std"))]
407fn attempt_rand(attempt: u32) -> u64 {
408 fastrand::Rng::with_seed(u64::from(attempt)).u64(..)
409}
410
411fn with_extra(base: &Labels, key: &str, value: &str) -> Labels {
412 let mut pairs: Vec<(String, String)> = base.entries().to_vec();
413 pairs.push((key.to_string(), value.to_string()));
414 let pair_refs: Vec<(&str, &str)> = pairs
415 .iter()
416 .map(|(name, value)| (name.as_str(), value.as_str()))
417 .collect();
418 Labels::from_pairs(&pair_refs)
419}
420
421#[derive(Debug, Clone)]
424pub enum RetryPredicate {
425 OnStatus(BTreeSet<u16>),
426 OnAnyError,
427 OnIdempotentOnly,
428}
429
430#[cfg(feature = "std")]
431pub struct HttpReplay {
432 method: crate::pipe::method::Method,
433 path: bytes::Bytes,
434 query: crate::pipe::header_list::HeaderList,
435 metadata: crate::pipe::header_list::HeaderList,
436 context: crate::pipe::request::RequestContext,
437 tee: Replay<bytes::Bytes>,
438}
439
440#[cfg(feature = "std")]
441impl Replayable for Request<Bytes> {
442 type Source = HttpReplay;
443
444 fn fork(self, replay_cap_bytes: usize) -> (Request<Bytes>, HttpReplay) {
445 let Request::<Bytes> {
446 method,
447 path,
448 query,
449 metadata,
450 payload,
451 stream,
452 context,
453 } = self;
454 let (tee, primary_body) =
455 Replay::wrap_bytes(source_stream(payload, stream), replay_cap_bytes);
456 let first = build_request(&method, &path, &query, &metadata, &context, primary_body);
457 (
458 first,
459 HttpReplay {
460 method,
461 path,
462 query,
463 metadata,
464 context,
465 tee,
466 },
467 )
468 }
469
470 fn replay(source: &HttpReplay) -> Result<Request<Bytes>, ProximaError> {
471 let body = source.tee.replay()?;
472 Ok(build_request(
473 &source.method,
474 &source.path,
475 &source.query,
476 &source.metadata,
477 &source.context,
478 body,
479 ))
480 }
481}
482
483impl Idempotent for Request<Bytes> {
484 fn is_idempotent(&self) -> bool {
485 is_idempotent_method(&self.method)
486 }
487}
488
489impl Retryable for Response<Bytes> {
490 fn retry_status(&self) -> Option<u16> {
491 Some(self.status)
492 }
493
494 fn is_success(&self) -> bool {
495 (200..400).contains(&self.status)
496 }
497}
498
499const RESPONSE_REDELIVERY_IDEMPOTENT_DEFAULT: bool = false;
500
501#[cfg(feature = "std")]
502pub struct HttpResponseReplay {
503 status: u16,
504 metadata: crate::pipe::header_list::HeaderList,
505 tee: Replay<bytes::Bytes>,
506}
507
508#[cfg(feature = "std")]
509impl Replayable for Response<Bytes> {
510 type Source = HttpResponseReplay;
511
512 fn fork(self, replay_cap_bytes: usize) -> (Response<Bytes>, HttpResponseReplay) {
513 let status = self.status;
514 let metadata = self.metadata.clone();
515 let (tee, primary_body) = Replay::wrap_bytes(self.into_chunk_stream(), replay_cap_bytes);
516 let first = build_response(status, &metadata, primary_body);
517 (
518 first,
519 HttpResponseReplay {
520 status,
521 metadata,
522 tee,
523 },
524 )
525 }
526
527 fn replay(source: &HttpResponseReplay) -> Result<Response<Bytes>, ProximaError> {
528 let body = source.tee.replay()?;
529 Ok(build_response(source.status, &source.metadata, body))
530 }
531}
532
533impl Idempotent for Response<Bytes> {
534 fn is_idempotent(&self) -> bool {
535 RESPONSE_REDELIVERY_IDEMPOTENT_DEFAULT
536 }
537}
538
539#[derive(Debug, Clone, PartialEq, Eq)]
540pub enum DeliveryOutcome {
541 Ack,
542 Nak,
543 WriteError,
544}
545
546impl Retryable for DeliveryOutcome {
547 fn retry_status(&self) -> Option<u16> {
548 None
549 }
550
551 fn is_success(&self) -> bool {
552 matches!(self, DeliveryOutcome::Ack)
553 }
554}
555
556#[cfg(feature = "std")]
557fn build_response(
558 status: u16,
559 metadata: &crate::pipe::header_list::HeaderList,
560 body: crate::pipe::body::ChunkStream,
561) -> Response<Bytes> {
562 Response {
563 status,
564 metadata: metadata.clone(),
565 payload: bytes::Bytes::new(),
566 stream: Some(crate::pipe::body::ResponseStream::from_chunk_stream(body)),
567 #[cfg(feature = "std")]
568 upgrade: None,
569 }
570}
571
572#[cfg(feature = "std")]
573#[cfg(feature = "std")]
577#[derive(Debug, Clone, Copy, Builder, Serialize, Deserialize)]
578#[builder(derive(Clone, Debug))]
579pub struct BudgetConfig {
580 #[serde(default = "default_budget_capacity")]
581 #[builder(default = default_budget_capacity())]
582 pub capacity: u64,
583 #[serde(default = "default_budget_refill")]
584 #[builder(default = default_budget_refill())]
585 pub refill_per_success: u64,
586}
587
588#[cfg(feature = "std")]
589fn default_budget_capacity() -> u64 {
590 DEFAULT_BUDGET_CAPACITY
591}
592
593#[cfg(feature = "std")]
594fn default_budget_refill() -> u64 {
595 DEFAULT_BUDGET_REFILL_PER_SUCCESS
596}
597
598#[cfg(feature = "std")]
600#[derive(Debug, Clone, Builder, Deserialize, Serialize, Settings)]
601#[settings(prefix = "PROXIMA_RETRY")]
602#[builder(derive(Clone, Debug))]
603pub struct RetryConfig {
604 #[setting(default = 3)]
606 #[serde(default = "default_max_attempts")]
607 #[builder(default = default_max_attempts())]
608 pub max_attempts: u32,
609
610 #[setting(default = 50)]
612 #[serde(default = "default_base_delay_ms")]
613 #[builder(default = default_base_delay_ms())]
614 pub base_delay_ms: u64,
615
616 #[setting(default = 2000)]
618 #[serde(default = "default_max_delay_ms")]
619 #[builder(default = default_max_delay_ms())]
620 pub max_delay_ms: u64,
621
622 #[setting(default = 4194304)]
624 #[serde(default = "default_replay_cap_bytes")]
625 #[builder(default = default_replay_cap_bytes())]
626 pub replay_cap_bytes: usize,
627
628 #[setting(skip)]
630 #[serde(default)]
631 #[builder(default)]
632 pub retry_on_status: Vec<u16>,
633
634 #[setting(default)]
636 #[serde(default)]
637 #[builder(default)]
638 pub retry_on_error: bool,
639
640 #[setting(default)]
642 #[serde(default)]
643 #[builder(default)]
644 pub idempotent_only: bool,
645
646 #[setting(skip)]
648 #[serde(default)]
649 pub budget: Option<BudgetConfig>,
650}
651
652#[cfg(feature = "std")]
653fn default_max_attempts() -> u32 {
654 DEFAULT_MAX_ATTEMPTS
655}
656
657#[cfg(feature = "std")]
658fn default_base_delay_ms() -> u64 {
659 DEFAULT_BASE_DELAY_MS
660}
661
662#[cfg(feature = "std")]
663fn default_max_delay_ms() -> u64 {
664 DEFAULT_MAX_DELAY_MS
665}
666
667#[cfg(feature = "std")]
668fn default_replay_cap_bytes() -> usize {
669 DEFAULT_REPLAY_CAP_BYTES
670}
671
672#[cfg(feature = "std")]
673impl Validate for RetryConfig {
674 fn validate(&self) -> conflaguration::Result<()> {
675 let mut errors = Vec::new();
676 if self.max_attempts == 0 {
677 errors.push(ValidationMessage::new("max_attempts", "must be >= 1"));
678 }
679 if errors.is_empty() {
680 Ok(())
681 } else {
682 Err(conflaguration::Error::Validation { errors })
683 }
684 }
685}
686
687#[cfg(feature = "std")]
688impl RetryConfig {
689 pub fn from_config(
692 self,
693 inner: PipeHandle,
694 ) -> Result<Retry<PipeHandle, TimeClock>, ProximaError> {
695 self.validate()
696 .map_err(|err| ProximaError::Config(format!("{err}")))?;
697 let mut retry = Retry::new(inner);
698 retry.max_attempts = self.max_attempts.max(1);
699 retry.base_delay = Duration::from_millis(self.base_delay_ms);
700 retry.max_delay = Duration::from_millis(self.max_delay_ms);
701 retry.replay_cap_bytes = self.replay_cap_bytes;
702 for status in self.retry_on_status {
703 retry.rules.retry_on_status.insert(status);
704 }
705 retry.rules.retry_on_error = self.retry_on_error;
706 retry.rules.idempotent_only = self.idempotent_only;
707 if let Some(budget) = self.budget {
708 retry.budget = Some(Arc::new(RetryBudget::new(
709 budget.capacity,
710 budget.refill_per_success,
711 )));
712 }
713 Ok(retry)
714 }
715}
716
717#[cfg(feature = "std")]
718impl Retry<PipeHandle, TimeClock> {
719 pub fn from_spec(inner: PipeHandle, value: &Value) -> Result<Self, ProximaError> {
720 let config: RetryConfig = serde_json::from_value(value.clone())
721 .map_err(|err| ProximaError::Config(format!("retry config: {err}")))?;
722 config.from_config(inner)
723 }
724}
725
726#[cfg(feature = "std")]
727pub struct RetryFactory;
728
729#[cfg(feature = "std")]
730impl PipeFactory for RetryFactory {
731 fn name(&self) -> &str {
732 "retry"
733 }
734
735 fn build(
736 &self,
737 spec: &Value,
738 inner: Option<PipeHandle>,
739 ) -> Pin<Box<dyn Future<Output = Result<PipeHandle, ProximaError>> + Send + '_>> {
740 let spec = spec.clone();
741 Box::pin(async move {
742 let inner =
743 inner.ok_or_else(|| ProximaError::Config("retry requires an inner pipe".into()))?;
744 let retry = Retry::from_spec(inner, &spec)?;
745 Ok(into_handle(retry))
746 })
747 }
748}
749
750#[cfg(feature = "std")]
751fn source_stream(
752 payload: bytes::Bytes,
753 stream: Option<crate::pipe::body::RequestStream>,
754) -> crate::pipe::body::ChunkStream {
755 match stream {
756 Some(stream) => stream.into_chunk_stream(),
757 None => Box::pin(futures::stream::once(async move { Ok(payload) })),
758 }
759}
760
761#[cfg(feature = "std")]
762fn build_request(
763 method: &crate::pipe::method::Method,
764 path: &bytes::Bytes,
765 query: &crate::pipe::header_list::HeaderList,
766 metadata: &crate::pipe::header_list::HeaderList,
767 context: &crate::pipe::request::RequestContext,
768 body: crate::pipe::body::ChunkStream,
769) -> Request<Bytes> {
770 Request {
771 method: method.clone(),
772 path: bytes::Bytes::clone(path),
773 query: query.clone(),
774 metadata: metadata.clone(),
775 payload: bytes::Bytes::new(),
776 stream: Some(crate::pipe::body::RequestStream::from_chunk_stream(body)),
777 context: context.clone(),
778 }
779}
780
781fn is_idempotent_method(method: &crate::pipe::method::Method) -> bool {
782 use crate::pipe::method::Method;
783 matches!(
784 method,
785 Method::Get | Method::Head | Method::Put | Method::Delete | Method::Options
786 )
787}
788
789#[cfg(all(test, not(loom)))]
794#[allow(
795 clippy::unwrap_used,
796 clippy::expect_used,
797 clippy::field_reassign_with_default,
798 clippy::type_complexity,
799 clippy::useless_vec,
800 clippy::needless_range_loop,
801 clippy::default_constructed_unit_structs
802)]
803mod tests {
804 use std::collections::HashMap;
805 use std::future::Future;
806 use std::sync::Mutex;
807 use std::sync::atomic::{AtomicU32, Ordering};
808
809 use super::*;
810 use crate::pipe::handler::into_handle;
811 use crate::pipe::telemetry_surface::{Telemetry, TelemetryHandle};
812 use bytes::Bytes;
813
814 #[derive(Default)]
815 struct Metrics {
816 counters: Mutex<HashMap<(String, Vec<(String, String)>), u64>>,
817 }
818
819 impl Telemetry for Metrics {
820 fn counter_inc(&self, metric: &str, labels: &Labels, by: u64) {
821 let key = (metric.to_string(), labels.entries().to_vec());
822 *self.counters.lock().unwrap().entry(key).or_insert(0) += by;
823 }
824
825 fn gauge_set(&self, _: &str, _: &Labels, _: i64) {}
826
827 fn histogram_record(&self, _: &str, _: &Labels, _: f64) {}
828 }
829
830 impl Metrics {
831 fn counter(&self, metric: &str, labels: &Labels) -> Option<u64> {
832 let key = (metric.to_string(), labels.entries().to_vec());
833 self.counters.lock().unwrap().get(&key).copied()
834 }
835 }
836
837 struct FailUntil {
838 threshold: u32,
839 observed: AtomicU32,
840 failure_status: u16,
841 }
842
843 impl SendPipe for FailUntil {
844 type In = Request<Bytes>;
845 type Out = Response<Bytes>;
846 type Err = ProximaError;
847
848 fn call(
849 &self,
850 _request: Request<Bytes>,
851 ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
852 let attempt = self.observed.fetch_add(1, Ordering::SeqCst) + 1;
853 let threshold = self.threshold;
854 let status = self.failure_status;
855 async move {
856 if attempt < threshold {
857 Ok(Response::new(status))
858 } else {
859 Ok(Response::ok("ok"))
860 }
861 }
862 }
863 }
864
865 fn stub_inner() -> PipeHandle {
866 into_handle(FailUntil {
867 threshold: 1,
868 observed: AtomicU32::new(0),
869 failure_status: 503,
870 })
871 }
872
873 #[test]
876 fn parity_fluent_builder_and_config_value_match() {
877 let from_value: RetryConfig = serde_json::from_value(serde_json::json!({
878 "max_attempts": 5,
879 "base_delay_ms": 25,
880 "max_delay_ms": 1000,
881 "replay_cap_bytes": 8192,
882 "retry_on_status": [502, 503],
883 "retry_on_error": true,
884 "idempotent_only": true,
885 "budget": {"capacity": 50, "refill_per_success": 5},
886 }))
887 .expect("from_value");
888 let from_value = from_value
889 .from_config(stub_inner())
890 .expect("from_config value");
891
892 let from_builder = RetryConfig::builder()
893 .max_attempts(5)
894 .base_delay_ms(25)
895 .max_delay_ms(1000)
896 .replay_cap_bytes(8192)
897 .retry_on_status(alloc::vec![502, 503])
898 .retry_on_error(true)
899 .idempotent_only(true)
900 .budget(
901 BudgetConfig::builder()
902 .capacity(50)
903 .refill_per_success(5)
904 .build(),
905 )
906 .build()
907 .from_config(stub_inner())
908 .expect("from_config builder");
909
910 assert_eq!(from_value.max_attempts, from_builder.max_attempts);
911 assert_eq!(from_value.base_delay, from_builder.base_delay);
912 assert_eq!(from_value.max_delay, from_builder.max_delay);
913 assert_eq!(from_value.replay_cap_bytes, from_builder.replay_cap_bytes);
914 assert_eq!(
915 from_value.rules.retry_on_status,
916 from_builder.rules.retry_on_status
917 );
918 assert_eq!(
919 from_value.rules.retry_on_error,
920 from_builder.rules.retry_on_error
921 );
922 assert_eq!(
923 from_value.rules.idempotent_only,
924 from_builder.rules.idempotent_only
925 );
926 assert_eq!(from_value.budget.is_some(), from_builder.budget.is_some());
927 }
928
929 fn build_request(method: &str) -> Request<Bytes> {
930 Request::builder()
931 .method(method)
932 .path("/v1/x")
933 .body("payload")
934 .build()
935 .expect("builder")
936 }
937
938 fn build_request_with_metrics(method: &str) -> (Request<Bytes>, Arc<Metrics>) {
939 let metrics: Arc<Metrics> = Arc::new(Metrics::default());
940 let telemetry: TelemetryHandle = metrics.clone();
941 let request = Request::builder()
942 .method(method)
943 .path("/v1/x")
944 .body("payload")
945 .telemetry(telemetry)
946 .build()
947 .expect("builder");
948 (request, metrics)
949 }
950
951 #[proxima::test]
952 async fn retry_succeeds_after_retried_503() {
953 let svc = FailUntil {
954 threshold: 3,
955 observed: AtomicU32::new(0),
956 failure_status: 503,
957 };
958 let stack = Retry::new(into_handle(svc))
959 .with_max_attempts(5)
960 .with_base_delay(Duration::ZERO)
961 .with_max_delay(Duration::ZERO);
962 let (request, metrics) = build_request_with_metrics("GET");
963 let response = SendPipe::call(&stack, request).await.expect("call");
964 assert_eq!(response.status, 200);
965 let success = metrics
966 .counter(METRIC_SUCCEEDED, &Labels::empty())
967 .expect("succeeded counter");
968 assert_eq!(success, 1);
969 }
970
971 #[proxima::test]
972 async fn retry_replays_request_body_on_each_attempt() {
973 struct EchoBody {
974 attempts: Arc<AtomicU32>,
975 }
976 impl SendPipe for EchoBody {
977 type In = Request<Bytes>;
978 type Out = Response<Bytes>;
979 type Err = ProximaError;
980
981 fn call(
982 &self,
983 request: Request<Bytes>,
984 ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
985 let attempts = self.attempts.clone();
986 async move {
987 let attempt = attempts.fetch_add(1, Ordering::SeqCst);
988 let (_, body) = request.body_bytes().await?;
989 if attempt < 1 {
990 Ok(Response::new(503).with_body(body))
991 } else {
992 Ok(Response::ok(body))
993 }
994 }
995 }
996 }
997
998 let attempts = Arc::new(AtomicU32::new(0));
999 let svc = EchoBody {
1000 attempts: attempts.clone(),
1001 };
1002 let stack = Retry::new(into_handle(svc))
1003 .with_max_attempts(3)
1004 .with_base_delay(Duration::ZERO)
1005 .with_max_delay(Duration::ZERO);
1006 let response = SendPipe::call(&stack, build_request("PUT"))
1007 .await
1008 .expect("call");
1009 assert_eq!(response.status, 200);
1010 let body = response.collect_body().await.expect("collect");
1011 assert_eq!(&body[..], b"payload");
1012 assert_eq!(attempts.load(Ordering::SeqCst), 2);
1013 }
1014
1015 #[proxima::test]
1016 async fn retry_skipped_when_method_not_idempotent_and_idempotent_only() {
1017 let svc = FailUntil {
1018 threshold: 3,
1019 observed: AtomicU32::new(0),
1020 failure_status: 503,
1021 };
1022 let stack = Retry::new(into_handle(svc))
1023 .with_max_attempts(5)
1024 .with_base_delay(Duration::ZERO)
1025 .with_max_delay(Duration::ZERO)
1026 .with_predicate(RetryPredicate::OnIdempotentOnly);
1027 let response = SendPipe::call(&stack, build_request("POST"))
1028 .await
1029 .expect("call");
1030 assert_eq!(response.status, 503, "non-idempotent must not retry");
1031 }
1032
1033 #[proxima::test]
1034 async fn retry_budget_exhausted_returns_last_response() {
1035 let svc = FailUntil {
1036 threshold: 100,
1037 observed: AtomicU32::new(0),
1038 failure_status: 503,
1039 };
1040 let budget = Arc::new(RetryBudget::new(150, 0));
1041 let stack = Retry::new(into_handle(svc))
1042 .with_max_attempts(10)
1043 .with_base_delay(Duration::ZERO)
1044 .with_max_delay(Duration::ZERO)
1045 .with_budget(budget.clone());
1046 let (request, metrics) = build_request_with_metrics("GET");
1047 let response = SendPipe::call(&stack, request).await.expect("call");
1048 assert_eq!(response.status, 503);
1049 let exhausted = metrics
1050 .counter(
1051 METRIC_BUDGET_EXHAUSTED,
1052 &Labels::from_pairs(&[("reason", "budget")]),
1053 )
1054 .expect("budget exhausted counter");
1055 assert!(exhausted >= 1);
1056 }
1057
1058 #[proxima::test]
1059 async fn replay_cap_exceeded_surfaces_typed_error() {
1060 struct ConsumingFailUntil {
1061 attempts: AtomicU32,
1062 }
1063 impl SendPipe for ConsumingFailUntil {
1064 type In = Request<Bytes>;
1065 type Out = Response<Bytes>;
1066 type Err = ProximaError;
1067
1068 fn call(
1069 &self,
1070 request: Request<Bytes>,
1071 ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
1072 let attempt = self.attempts.fetch_add(1, Ordering::SeqCst);
1073 async move {
1074 let (_, _) = request.body_bytes().await?;
1075 if attempt < 5 {
1076 Ok(Response::new(503))
1077 } else {
1078 Ok(Response::ok("ok"))
1079 }
1080 }
1081 }
1082 }
1083
1084 let svc = ConsumingFailUntil {
1085 attempts: AtomicU32::new(0),
1086 };
1087 let stack = Retry::new(into_handle(svc))
1088 .with_max_attempts(3)
1089 .with_base_delay(Duration::ZERO)
1090 .with_max_delay(Duration::ZERO)
1091 .with_replay_cap_bytes(2);
1092 let outcome = SendPipe::call(&stack, build_request("GET")).await;
1093 assert!(matches!(outcome, Err(ProximaError::Body(_))));
1094 }
1095
1096 #[derive(Clone, PartialEq, Debug)]
1097 struct CountEvent(u64);
1098
1099 impl Replayable for CountEvent {
1100 type Source = CountEvent;
1101 fn fork(self, _replay_cap_bytes: usize) -> (CountEvent, CountEvent) {
1102 (self.clone(), self)
1103 }
1104 fn replay(source: &CountEvent) -> Result<CountEvent, ProximaError> {
1105 Ok(source.clone())
1106 }
1107 }
1108 impl Idempotent for CountEvent {
1109 fn is_idempotent(&self) -> bool {
1110 true
1111 }
1112 }
1113 impl Labeled for CountEvent {
1114 fn telemetry(&self) -> TelemetryHandle {
1115 Arc::new(Metrics::default())
1116 }
1117 fn labels(&self) -> Labels {
1118 Labels::empty()
1119 }
1120 }
1121 impl Retryable for CountEvent {
1122 fn retry_status(&self) -> Option<u16> {
1123 None
1124 }
1125 fn is_success(&self) -> bool {
1126 true
1127 }
1128 }
1129
1130 #[derive(Clone)]
1131 struct FlakyEventSink {
1132 fail_until: u32,
1133 observed: Arc<AtomicU32>,
1134 }
1135
1136 impl SendPipe for FlakyEventSink {
1137 type In = CountEvent;
1138 type Out = CountEvent;
1139 type Err = ProximaError;
1140
1141 fn call(
1142 &self,
1143 input: CountEvent,
1144 ) -> impl Future<Output = Result<CountEvent, ProximaError>> + Send {
1145 let attempt = self.observed.fetch_add(1, Ordering::SeqCst) + 1;
1146 let fail_until = self.fail_until;
1147 async move {
1148 if attempt < fail_until {
1149 Err(ProximaError::Config("sink not ready".into()))
1150 } else {
1151 Ok(CountEvent(input.0 * 2))
1152 }
1153 }
1154 }
1155 }
1156
1157 #[proxima::test]
1158 async fn retry_is_generic_over_a_non_http_event_type() {
1159 let sink = FlakyEventSink {
1160 fail_until: 3,
1161 observed: Arc::new(AtomicU32::new(0)),
1162 };
1163 let stack = Retry::new(sink)
1164 .with_max_attempts(5)
1165 .with_base_delay(Duration::ZERO)
1166 .with_max_delay(Duration::ZERO);
1167 let out = SendPipe::call(&stack, CountEvent(21))
1168 .await
1169 .expect("event call");
1170 assert_eq!(
1171 out,
1172 CountEvent(42),
1173 "retried an event pipe to success with zero HTTP involvement, no policy type"
1174 );
1175 }
1176
1177 #[derive(Clone)]
1178 struct FlakyClientDelivery {
1179 nak_until: u32,
1180 observed: Arc<AtomicU32>,
1181 last_body: Arc<Mutex<Option<Vec<u8>>>>,
1182 }
1183
1184 impl SendPipe for FlakyClientDelivery {
1185 type In = Response<Bytes>;
1186 type Out = DeliveryOutcome;
1187 type Err = ProximaError;
1188
1189 fn call(
1190 &self,
1191 response: Response<Bytes>,
1192 ) -> impl Future<Output = Result<DeliveryOutcome, ProximaError>> + Send {
1193 let attempt = self.observed.fetch_add(1, Ordering::SeqCst) + 1;
1194 let nak_until = self.nak_until;
1195 let last_body = self.last_body.clone();
1196 async move {
1197 let body = response.collect_body().await?;
1198 if attempt < nak_until {
1199 Err(ProximaError::Config("client nak".into()))
1200 } else {
1201 *last_body.lock().unwrap() = Some(body.to_vec());
1202 Ok(DeliveryOutcome::Ack)
1203 }
1204 }
1205 }
1206 }
1207
1208 #[proxima::test]
1209 async fn retry_redelivers_a_response_to_a_flaky_client() {
1210 let observed = Arc::new(AtomicU32::new(0));
1211 let last_body = Arc::new(Mutex::new(None));
1212 let sink = FlakyClientDelivery {
1213 nak_until: 3,
1214 observed: observed.clone(),
1215 last_body: last_body.clone(),
1216 };
1217 let stack = Retry::new(sink)
1218 .with_max_attempts(5)
1219 .with_base_delay(Duration::ZERO)
1220 .with_max_delay(Duration::ZERO);
1221 let response = Response::ok("deliver-me");
1222 let outcome = SendPipe::call(&stack, response)
1223 .await
1224 .expect("delivery call");
1225 assert_eq!(
1226 outcome,
1227 DeliveryOutcome::Ack,
1228 "the SAME Retry<Inner> drove the delivery to a successful ack"
1229 );
1230 assert_eq!(observed.load(Ordering::SeqCst), 3, "two naks then an ack");
1231 assert_eq!(
1232 last_body.lock().unwrap().as_deref(),
1233 Some(&b"deliver-me"[..]),
1234 "body replayed intact on each re-delivery"
1235 );
1236 }
1237
1238 #[proxima::test]
1239 async fn response_redelivery_is_not_idempotent_by_default_and_idempotent_only_guards_it() {
1240 let observed = Arc::new(AtomicU32::new(0));
1241 let sink = FlakyClientDelivery {
1242 nak_until: 3,
1243 observed: observed.clone(),
1244 last_body: Arc::new(Mutex::new(None)),
1245 };
1246 let stack = Retry::new(sink)
1247 .with_max_attempts(5)
1248 .with_base_delay(Duration::ZERO)
1249 .with_max_delay(Duration::ZERO)
1250 .with_predicate(RetryPredicate::OnIdempotentOnly);
1251 let outcome = SendPipe::call(&stack, Response::ok("deliver-me")).await;
1252 assert!(
1253 matches!(outcome, Err(ProximaError::Config(_))),
1254 "idempotent_only must suppress unsafe re-delivery"
1255 );
1256 assert_eq!(
1257 observed.load(Ordering::SeqCst),
1258 1,
1259 "exactly one delivery attempt — no retry"
1260 );
1261 }
1262
1263 #[test]
1264 fn delivery_outcome_classifies_ack_as_success_only() {
1265 assert!(DeliveryOutcome::Ack.is_success());
1266 assert!(!DeliveryOutcome::Nak.is_success());
1267 assert!(!DeliveryOutcome::WriteError.is_success());
1268 assert_eq!(
1269 DeliveryOutcome::Ack.retry_status(),
1270 None,
1271 "delivery is statusless — error/nak governs, not http status"
1272 );
1273 }
1274
1275 #[proxima::test]
1276 async fn response_replay_forks_the_body_for_redelivery() {
1277 let response = Response::ok("body-bytes");
1278 let (first, source) = Replayable::fork(response, DEFAULT_REPLAY_CAP_BYTES);
1279 let first_body = first.collect_body().await.expect("first body");
1280 assert_eq!(&first_body[..], b"body-bytes");
1281 let replayed = Response::replay(&source).expect("replay");
1282 let replayed_body = replayed.collect_body().await.expect("replayed body");
1283 assert_eq!(
1284 &replayed_body[..],
1285 b"body-bytes",
1286 "the tee replays the response body verbatim"
1287 );
1288 }
1289
1290 use crate::pipe::clock::testing::RecordingClock;
1299
1300 #[proxima::test]
1301 async fn fake_clock_drives_backoff_between_retries_no_real_sleep() {
1302 let clock = RecordingClock::new();
1303 let svc = FailUntil {
1304 threshold: 3,
1305 observed: AtomicU32::new(0),
1306 failure_status: 503,
1307 };
1308 let stack = Retry::with_clock(into_handle(svc), clock.clone())
1312 .with_max_attempts(5)
1313 .with_base_delay(Duration::from_secs(5))
1314 .with_max_delay(Duration::from_secs(30));
1315 let (request, metrics) = build_request_with_metrics("GET");
1316 let response = SendPipe::call(&stack, request).await.expect("call");
1317
1318 assert_eq!(response.status, 200, "retried past two 503s to success");
1319 let success = metrics
1320 .counter(METRIC_SUCCEEDED, &Labels::empty())
1321 .expect("succeeded counter");
1322 assert_eq!(success, 1);
1323
1324 let delays = clock.delays();
1325 assert_eq!(
1326 delays.len(),
1327 2,
1328 "two retryable 503s before the third attempt succeeds"
1329 );
1330 for delay in delays {
1331 assert!(
1332 delay <= Duration::from_secs(30),
1333 "delay {delay:?} exceeds the configured max_delay"
1334 );
1335 }
1336 }
1337
1338 #[proxima::test]
1339 async fn fake_clock_non_retryable_error_stops_after_one_attempt() {
1340 let clock = RecordingClock::new();
1341 let svc = FailUntil {
1342 threshold: 100,
1343 observed: AtomicU32::new(0),
1344 failure_status: 422,
1345 };
1346 let stack = Retry::with_clock(into_handle(svc), clock.clone())
1347 .with_max_attempts(5)
1348 .with_base_delay(Duration::from_secs(5))
1349 .with_max_delay(Duration::from_secs(30));
1350 let response = SendPipe::call(&stack, build_request("GET"))
1351 .await
1352 .expect("call");
1353
1354 assert_eq!(
1355 response.status, 422,
1356 "422 is outside the default retry_on_status set"
1357 );
1358 assert!(
1359 clock.delays().is_empty(),
1360 "non-retryable outcome must not schedule any delay"
1361 );
1362 }
1363}