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}