Skip to main content

proxima_primitives/pipe/
retry.rs

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// the HTTP-handle pipe impls (primitives::Pipe over the dispatch handles) are
23// std-tier; their trait paths are only needed there.
24#[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// `Replay` is std-only in proxima-transport (its stream types want std Mutex/Vec
39// freely); the HTTP `Replayable` instantiation below is therefore std-tier. The
40// generic `Retry<Inner>` core needs only the `Replayable` trait (forms, no_std).
41#[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
48/// Default replay-tee byte cap for the generic core's `Retry::new`. Mirrors
49/// `crate::transport::DEFAULT_REPLAY_CAP_BYTES` (which is std-only), so the
50/// alloc tier carries its own copy.
51const 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
65// ── retry budget ──────────────────────────────────────────────────────────────
66
67pub 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
126// ── main struct ───────────────────────────────────────────────────────────────
127
128/// Retry middleware. Generic over the inner pipe AND the clock — composes the
129/// pure [`RetryController`] decision core (the same sans-IO engine
130/// `resilience::Retry<Inner, Clk>` drives) instead of re-deciding against
131/// `RetryRules` directly, and schedules its between-attempt delay against the
132/// injected [`Clock`] instead of a bare `sleep`. `Clk` defaults to
133/// [`TimeClock`], the production monotonic clock, so every existing caller
134/// (`Retry::new`, `RetryConfig`, `RetryFactory`) is unaffected.
135pub 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
146// Hardcoded to `TimeClock` (not `Clk: Default`) so `Retry::new(...)` resolves
147// without turbofish, the same way `RateLimit::new`/`with_caps` pin `Clk` — a
148// bare `Clk: Default` bound would leave the type parameter ambiguous at every
149// existing call site with no annotation to pin it.
150impl<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    /// Materialise with an explicit clock — the seam a deterministic test or
159    /// example injects a fake clock through; production code goes via `new`,
160    /// which defaults `Clk` to [`TimeClock`].
161    #[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    /// Build the pure decision core for one call, from this middleware's
216    /// current knobs. No deadline: `Retry` has never enforced a wall-clock
217    /// budget, only an attempt cap — this mirrors that unchanged.
218    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// std: the process-global rng decorrelates jitter entropy across retry
396// instances. `Backoff::delay` (invoked by `RetryController::on_outcome`) turns
397// this raw entropy into the actual jittered duration — this only sources it.
398#[cfg(feature = "std")]
399fn attempt_rand(_attempt: u32) -> u64 {
400    fastrand::u64(..)
401}
402
403// no_std: no process-global rng (fastrand's global needs std for the seed), so
404// seed a per-call Rng from the attempt. Deterministic within an instance; the
405// backoff envelope is preserved, only cross-instance decorrelation is lost.
406#[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// ── HTTP instantiation ────────────────────────────────────────────────────────
422
423#[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/// Serialisable retry-budget config. Mirrors [`RetryBudget`]'s constructor;
574/// presence of a `budget` block (even empty) enables the budget at defaults,
575/// matching the historical hand-parser.
576#[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/// Typed config surface for the `retry` middleware.
599#[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    /// Total attempts (>= 1). Defaults to 3.
605    #[setting(default = 3)]
606    #[serde(default = "default_max_attempts")]
607    #[builder(default = default_max_attempts())]
608    pub max_attempts: u32,
609
610    /// Base backoff delay in ms. Defaults to 50.
611    #[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    /// Backoff ceiling in ms. Defaults to 2000.
617    #[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    /// Replay buffer cap in bytes for request-body replay across attempts.
623    #[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    /// Status codes that trigger a retry.
629    #[setting(skip)]
630    #[serde(default)]
631    #[builder(default)]
632    pub retry_on_status: Vec<u16>,
633
634    /// Retry on any inner error.
635    #[setting(default)]
636    #[serde(default)]
637    #[builder(default)]
638    pub retry_on_error: bool,
639
640    /// Only retry idempotent requests.
641    #[setting(default)]
642    #[serde(default)]
643    #[builder(default)]
644    pub idempotent_only: bool,
645
646    /// Optional retry budget. Presence enables it (defaults when fields omitted).
647    #[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    /// Materialise the retry middleware around `inner`, on the production
690    /// [`TimeClock`].
691    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// `#[proxima::test]` pulls in the `proxima` dev-dependency, which the
790// loom build keeps out of the graph (see
791// `[target.'cfg(not(loom))'.dev-dependencies]` in Cargo.toml); these
792// tests are unrelated to the Notify/watch loom protocol.
793#[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    // principle-4 parity: the fluent builder and the config value must lower to
874    // identical Retry state (attempts, delays, rules, budget, replay cap).
875    #[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    // ── Clock-injectable determinism ────────────────────────────────────────
1291    //
1292    // `Retry::with_clock` takes any `Clock` impl, so the between-attempt delay
1293    // can be proven deterministically — `delay` resolves instantly and records
1294    // what it was asked to wait, never a real sleep. `RecordingClock` is the
1295    // canonical double for this (see `crate::pipe::clock::testing` for why
1296    // it, not `MockClock`).
1297
1298    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        // a base/max delay of seconds would make a real-sleep implementation
1309        // take real seconds to run; this test still completes instantly
1310        // because `RecordingClock::delay` never actually waits.
1311        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}