Skip to main content

qubit_retry/executor/
retry_builder.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026 Haixing Hu.
4 *
5 *    SPDX-License-Identifier: Apache-2.0
6 *
7 *    Licensed under the Apache License, Version 2.0.
8 *
9 ******************************************************************************/
10//! Retry builder.
11//!
12//! The builder collects retry options, attempt listeners, failure listeners, and
13//! terminal error listeners before producing a validated [`Retry`].
14//! It is the main public configuration surface; once [`RetryBuilder::build`]
15//! succeeds, the resulting policy is immutable and can be cloned cheaply.
16
17use std::time::Duration;
18
19use qubit_error::BoxError;
20use qubit_function::{
21    BiConsumer,
22    BiFunction,
23    BiPredicate,
24    Consumer,
25};
26
27use crate::constants::KEY_MAX_ATTEMPTS;
28use crate::event::RetryListeners;
29use crate::{
30    AttemptFailure,
31    AttemptFailureDecision,
32    AttemptTimeoutOption,
33    AttemptTimeoutPolicy,
34    Retry,
35    RetryAfterHint,
36    RetryConfigError,
37    RetryContext,
38    RetryDelay,
39    RetryError,
40    RetryJitter,
41    RetryOptions,
42};
43
44/// Builder for [`Retry`].
45///
46/// The generic parameter `E` is the operation error type preserved inside
47/// [`AttemptFailure::Error`]. Failure listeners may observe failures, override
48/// the retry decision, or return [`AttemptFailureDecision::UseDefault`] to let
49/// the policy decide from configured limits and delay strategy.
50pub struct RetryBuilder<E = BoxError> {
51    /// Retry limits, delay strategy, jitter, and elapsed budgets.
52    options: RetryOptions,
53    /// Pending policy used when timeout duration is configured later.
54    pending_attempt_timeout_policy: AttemptTimeoutPolicy,
55    /// Optional retry-after hint extractor.
56    retry_after_hint: Option<RetryAfterHint<E>>,
57    /// Lifecycle listeners registered on the builder.
58    listeners: RetryListeners<E>,
59    /// Whether listener panics should be isolated.
60    isolate_listener_panics: bool,
61    /// Stored validation error when max attempts is configured as zero.
62    max_attempts_error: Option<RetryConfigError>,
63}
64
65impl<E> RetryBuilder<E> {
66    /// Creates a builder with default options and no listeners.
67    ///
68    /// # Returns
69    /// A retry builder using [`RetryOptions::default`].
70    #[inline]
71    pub fn new() -> Self {
72        Self {
73            options: RetryOptions::default(),
74            pending_attempt_timeout_policy: AttemptTimeoutPolicy::default(),
75            retry_after_hint: None,
76            listeners: RetryListeners::default(),
77            isolate_listener_panics: false,
78            max_attempts_error: None,
79        }
80    }
81
82    /// Replaces all retry options.
83    ///
84    /// # Parameters
85    /// - `options`: Retry option snapshot.
86    ///
87    /// # Returns
88    /// The updated builder.
89    #[inline]
90    pub fn options(mut self, options: RetryOptions) -> Self {
91        self.pending_attempt_timeout_policy = options
92            .attempt_timeout()
93            .map(|attempt_timeout| attempt_timeout.policy())
94            .unwrap_or_default();
95        self.options = options;
96        self.max_attempts_error = None;
97        self
98    }
99
100    /// Sets the maximum total attempts, including the initial attempt.
101    ///
102    /// # Parameters
103    /// - `max_attempts`: Maximum attempts. Zero is recorded as a build error.
104    ///
105    /// # Returns
106    /// The updated builder.
107    pub fn max_attempts(mut self, max_attempts: u32) -> Self {
108        if let Some(max_attempts) = std::num::NonZeroU32::new(max_attempts) {
109            self.options.max_attempts = max_attempts;
110            self.max_attempts_error = None;
111        } else {
112            self.max_attempts_error = Some(RetryConfigError::invalid_value(
113                KEY_MAX_ATTEMPTS,
114                "max_attempts must be greater than zero",
115            ));
116        }
117        self
118    }
119
120    /// Sets the maximum retry count after the initial attempt.
121    ///
122    /// # Parameters
123    /// - `max_retries`: Number of retries after the first attempt.
124    ///
125    /// # Returns
126    /// The updated builder.
127    #[inline]
128    pub fn max_retries(self, max_retries: u32) -> Self {
129        self.max_attempts(max_retries.saturating_add(1))
130    }
131
132    /// Sets the maximum cumulative user operation time.
133    ///
134    /// # Parameters
135    /// - `max_operation_elapsed`: Optional cumulative user operation time budget.
136    ///
137    /// # Returns
138    /// The updated builder.
139    #[inline]
140    pub fn max_operation_elapsed(mut self, max_operation_elapsed: Option<Duration>) -> Self {
141        self.options.max_operation_elapsed = max_operation_elapsed;
142        self
143    }
144
145    /// Sets the maximum total monotonic retry-flow elapsed time.
146    ///
147    /// # Parameters
148    /// - `max_total_elapsed`: Optional total retry-flow time budget. Operation
149    ///   execution, retry sleeps, retry-after sleeps, and retry control-path
150    ///   listener time are included.
151    ///
152    /// # Returns
153    /// The updated builder.
154    #[inline]
155    pub fn max_total_elapsed(mut self, max_total_elapsed: Option<Duration>) -> Self {
156        self.options.max_total_elapsed = max_total_elapsed;
157        self
158    }
159
160    /// Sets the retry delay strategy.
161    ///
162    /// # Parameters
163    /// - `delay`: Base delay strategy used between attempts.
164    ///
165    /// # Returns
166    /// The updated builder.
167    #[inline]
168    pub fn delay(mut self, delay: RetryDelay) -> Self {
169        self.options.delay = delay;
170        self
171    }
172
173    /// Configures immediate retries with no sleep.
174    ///
175    /// # Returns
176    /// The updated builder.
177    #[inline]
178    pub fn no_delay(self) -> Self {
179        self.delay(RetryDelay::none())
180    }
181
182    /// Configures a fixed retry delay.
183    ///
184    /// # Parameters
185    /// - `delay`: Delay slept before each retry.
186    ///
187    /// # Returns
188    /// The updated builder.
189    #[inline]
190    pub fn fixed_delay(self, delay: Duration) -> Self {
191        self.delay(RetryDelay::fixed(delay))
192    }
193
194    /// Configures a random retry delay range.
195    ///
196    /// # Parameters
197    /// - `min`: Inclusive lower delay bound.
198    /// - `max`: Inclusive upper delay bound.
199    ///
200    /// # Returns
201    /// The updated builder.
202    #[inline]
203    pub fn random_delay(self, min: Duration, max: Duration) -> Self {
204        self.delay(RetryDelay::random(min, max))
205    }
206
207    /// Configures exponential backoff with the default multiplier `2.0`.
208    ///
209    /// # Parameters
210    /// - `initial`: First retry delay.
211    /// - `max`: Maximum retry delay.
212    ///
213    /// # Returns
214    /// The updated builder.
215    #[inline]
216    pub fn exponential_backoff(self, initial: Duration, max: Duration) -> Self {
217        self.exponential_backoff_with_multiplier(initial, max, 2.0)
218    }
219
220    /// Configures exponential backoff with a custom multiplier.
221    ///
222    /// # Parameters
223    /// - `initial`: First retry delay.
224    /// - `max`: Maximum retry delay.
225    /// - `multiplier`: Multiplier applied after each failed attempt.
226    ///
227    /// # Returns
228    /// The updated builder.
229    #[inline]
230    pub fn exponential_backoff_with_multiplier(self, initial: Duration, max: Duration, multiplier: f64) -> Self {
231        self.delay(RetryDelay::exponential(initial, max, multiplier))
232    }
233
234    /// Sets the jitter strategy.
235    ///
236    /// # Parameters
237    /// - `jitter`: Jitter strategy applied to base delays.
238    ///
239    /// # Returns
240    /// The updated builder.
241    #[inline]
242    pub fn jitter(mut self, jitter: RetryJitter) -> Self {
243        self.options.jitter = jitter;
244        self
245    }
246
247    /// Sets relative jitter by factor.
248    ///
249    /// # Parameters
250    /// - `factor`: Relative jitter factor in `[0.0, 1.0]`.
251    ///
252    /// # Returns
253    /// The updated builder.
254    #[inline]
255    pub fn jitter_factor(self, factor: f64) -> Self {
256        self.jitter(RetryJitter::factor(factor))
257    }
258
259    /// Sets a per-attempt timeout.
260    ///
261    /// # Parameters
262    /// - `attempt_timeout`: Timeout applied by `run_async` and
263    ///   `run_in_worker`. `None` disables per-attempt timeout.
264    ///
265    /// # Returns
266    /// The updated builder.
267    #[inline]
268    pub fn attempt_timeout(mut self, attempt_timeout: Option<Duration>) -> Self {
269        if let Some(timeout) = attempt_timeout {
270            self.options.attempt_timeout =
271                Some(AttemptTimeoutOption::new(timeout, self.pending_attempt_timeout_policy));
272        } else {
273            self.pending_attempt_timeout_policy = AttemptTimeoutPolicy::default();
274            self.options.attempt_timeout = None;
275        }
276        self
277    }
278
279    /// Sets the complete per-attempt timeout option.
280    ///
281    /// # Parameters
282    /// - `attempt_timeout`: Timeout option. `None` disables per-attempt timeout.
283    ///
284    /// # Returns
285    /// The updated builder.
286    #[inline]
287    pub fn attempt_timeout_option(mut self, attempt_timeout: Option<AttemptTimeoutOption>) -> Self {
288        if let Some(attempt_timeout) = attempt_timeout {
289            self.pending_attempt_timeout_policy = attempt_timeout.policy();
290        } else {
291            self.pending_attempt_timeout_policy = AttemptTimeoutPolicy::default();
292        }
293        self.options.attempt_timeout = attempt_timeout;
294        self
295    }
296
297    /// Sets the policy used when an attempt times out.
298    ///
299    /// If a timeout duration is already configured, this updates the complete
300    /// timeout option. Otherwise the policy is kept and applied when
301    /// [`RetryBuilder::attempt_timeout`] is called later.
302    ///
303    /// # Parameters
304    /// - `policy`: Timeout policy to use.
305    ///
306    /// # Returns
307    /// The updated builder.
308    #[inline]
309    pub fn attempt_timeout_policy(mut self, policy: AttemptTimeoutPolicy) -> Self {
310        self.pending_attempt_timeout_policy = policy;
311        self.options.attempt_timeout = self
312            .options
313            .attempt_timeout
314            .map(|attempt_timeout| attempt_timeout.with_policy(policy));
315        self
316    }
317
318    /// Sets how long worker-thread execution waits after cancelling a timed-out worker.
319    ///
320    /// # Parameters
321    /// - `grace`: Duration to wait after the attempt timeout fires and the
322    ///   cooperative cancellation token is marked as cancelled. Use zero to skip
323    ///   the grace wait.
324    ///
325    /// # Returns
326    /// The updated builder.
327    #[inline]
328    pub fn worker_cancel_grace(mut self, grace: Duration) -> Self {
329        self.options.worker_cancel_grace = grace;
330        self
331    }
332
333    /// Extracts an optional retry-after hint from each failure.
334    ///
335    /// # Parameters
336    /// - `hint`: Function that inspects a failure and context before failure
337    ///   listeners run.
338    ///
339    /// # Returns
340    /// The updated builder.
341    pub fn retry_after_hint<H>(mut self, hint: H) -> Self
342    where
343        H: BiFunction<AttemptFailure<E>, RetryContext, Option<Duration>> + Send + Sync + 'static,
344    {
345        self.retry_after_hint = Some(hint.into_arc());
346        self
347    }
348
349    /// Extracts an optional retry-after hint from operation errors.
350    ///
351    /// # Parameters
352    /// - `hint`: Function returning a delay hint for application errors.
353    ///
354    /// # Returns
355    /// The updated builder.
356    pub fn retry_after_from_error<H>(self, hint: H) -> Self
357    where
358        H: Fn(&E) -> Option<Duration> + Send + Sync + 'static,
359    {
360        self.retry_after_hint(move |failure: &AttemptFailure<E>, _context: &RetryContext| {
361            failure.as_error().and_then(&hint)
362        })
363    }
364
365    /// Registers a listener invoked before every attempt.
366    ///
367    /// # Parameters
368    /// - `listener`: Listener receiving the retry context.
369    ///
370    /// # Returns
371    /// The updated builder.
372    pub fn before_attempt<C>(mut self, listener: C) -> Self
373    where
374        C: Consumer<RetryContext> + Send + Sync + 'static,
375    {
376        self.listeners.before_attempt.push(listener.into_arc());
377        self
378    }
379
380    /// Registers a listener invoked when an attempt succeeds.
381    ///
382    /// # Parameters
383    /// - `listener`: Listener receiving the success context.
384    ///
385    /// # Returns
386    /// The updated builder.
387    pub fn on_success<C>(mut self, listener: C) -> Self
388    where
389        C: Consumer<RetryContext> + Send + Sync + 'static,
390    {
391        self.listeners.attempt_success.push(listener.into_arc());
392        self
393    }
394
395    /// Registers a listener invoked after each attempt failure.
396    ///
397    /// # Parameters
398    /// - `listener`: Listener returning a retry failure decision.
399    ///
400    /// # Returns
401    /// The updated builder.
402    pub fn on_failure<F>(mut self, listener: F) -> Self
403    where
404        F: BiFunction<AttemptFailure<E>, RetryContext, AttemptFailureDecision> + Send + Sync + 'static,
405    {
406        self.listeners.failure.push(listener.into_arc());
407        self
408    }
409
410    /// Registers a listener invoked after a retry delay has been selected.
411    ///
412    /// The listener receives the failed attempt and a context whose
413    /// [`RetryContext::next_delay`] contains the delay that will be slept before
414    /// the next attempt. The listener is observational and cannot change the
415    /// retry decision.
416    ///
417    /// # Parameters
418    /// - `listener`: Listener receiving the failure and scheduled-retry context.
419    ///
420    /// # Returns
421    /// The updated builder.
422    pub fn on_retry<C>(mut self, listener: C) -> Self
423    where
424        C: BiConsumer<AttemptFailure<E>, RetryContext> + Send + Sync + 'static,
425    {
426        self.listeners.retry_scheduled.push(listener.into_arc());
427        self
428    }
429
430    /// Registers an error-only predicate where `true` means retry.
431    ///
432    /// # Parameters
433    /// - `predicate`: Predicate applied only to [`AttemptFailure::Error`].
434    ///
435    /// # Returns
436    /// The updated builder.
437    pub fn retry_if_error<P>(self, predicate: P) -> Self
438    where
439        P: BiPredicate<E, RetryContext> + Send + Sync + 'static,
440    {
441        self.on_failure(
442            move |failure: &AttemptFailure<E>, context: &RetryContext| match failure {
443                AttemptFailure::Error(error) => {
444                    if predicate.test(error, context) {
445                        AttemptFailureDecision::Retry
446                    } else {
447                        AttemptFailureDecision::Abort
448                    }
449                }
450                AttemptFailure::Timeout | AttemptFailure::Panic(_) | AttemptFailure::Executor(_) => {
451                    AttemptFailureDecision::UseDefault
452                }
453            },
454        )
455    }
456
457    /// Registers a listener invoked when the retry flow returns [`RetryError`].
458    ///
459    /// # Parameters
460    /// - `listener`: Observational listener that cannot resume the retry flow.
461    ///
462    /// # Returns
463    /// The updated builder.
464    pub fn on_error<C>(mut self, listener: C) -> Self
465    where
466        C: BiConsumer<RetryError<E>, RetryContext> + Send + Sync + 'static,
467    {
468        self.listeners.error.push(listener.into_arc());
469        self
470    }
471
472    /// Aborts the retry flow when a configured per-attempt timeout expires.
473    ///
474    /// Max-elapsed effective timeouts are not controlled by this policy and stop
475    /// with [`crate::RetryErrorReason::MaxOperationElapsedExceeded`].
476    ///
477    /// # Returns
478    /// The updated builder.
479    pub fn abort_on_timeout(self) -> Self {
480        self.attempt_timeout_policy(AttemptTimeoutPolicy::Abort)
481    }
482
483    /// Retries configured per-attempt timeouts while limits allow it.
484    ///
485    /// Max-elapsed effective timeouts are not controlled by this policy and stop
486    /// with [`crate::RetryErrorReason::MaxOperationElapsedExceeded`].
487    ///
488    /// # Returns
489    /// The updated builder.
490    pub fn retry_on_timeout(self) -> Self {
491        self.attempt_timeout_policy(AttemptTimeoutPolicy::Retry)
492    }
493
494    /// Enables panic isolation for all registered listeners.
495    ///
496    /// # Returns
497    /// The updated builder.
498    #[inline]
499    pub fn isolate_listener_panics(mut self) -> Self {
500        self.isolate_listener_panics = true;
501        self
502    }
503
504    /// Builds and validates the retry policy.
505    ///
506    /// # Returns
507    /// A validated [`Retry`].
508    ///
509    /// # Errors
510    /// Returns [`RetryConfigError`] when options are invalid.
511    pub fn build(self) -> Result<Retry<E>, RetryConfigError> {
512        if let Some(error) = self.max_attempts_error {
513            return Err(error);
514        }
515        self.options.validate()?;
516        Ok(Retry::new(
517            self.options,
518            self.retry_after_hint,
519            self.isolate_listener_panics,
520            self.listeners,
521        ))
522    }
523}
524
525impl<E> Default for RetryBuilder<E> {
526    /// Creates a default retry builder.
527    ///
528    /// # Returns
529    /// A builder equivalent to [`RetryBuilder::new`].
530    #[inline]
531    fn default() -> Self {
532        Self::new()
533    }
534}