Skip to main content

qubit_retry/executor/
retry.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 policy facade and public execution entry point.
11//!
12//! A [`Retry`] owns validated retry options and lifecycle events. Execution
13//! mode details live in dedicated runner objects. This keeps the public API
14//! centered on "configure a policy, then run an operation" while sync, async,
15//! and worker-thread mechanics remain private executor responsibilities.
16
17use std::fmt;
18#[cfg(feature = "tokio")]
19use std::future::Future;
20
21use qubit_error::BoxError;
22
23#[cfg(feature = "tokio")]
24use super::async_retry_runner::AsyncRetryRunner;
25use super::attempt_cancel_token::AttemptCancelToken;
26use super::retry_builder::RetryBuilder;
27use super::retry_runner::RetryRunner;
28use super::worker_retry_runner::WorkerRetryRunner;
29use crate::event::{
30    RetryEvents,
31    RetryListeners,
32};
33use crate::{
34    RetryAfterHint,
35    RetryConfigError,
36    RetryError,
37    RetryOptions,
38};
39
40/// Retry policy and executor facade bound to an operation error type.
41///
42/// The generic parameter `E` is the caller's operation error type. Cloning a
43/// retry policy shares all registered functors through reference-counted
44/// `rs-function` wrappers.
45///
46/// # Architecture
47///
48/// `Retry` is the public facade of the retry engine. It owns two stable pieces
49/// of state:
50///
51/// - [`RetryOptions`], which describes the policy: attempt limits, elapsed
52///   budgets, backoff, retry-after handling, per-attempt timeout, and
53///   worker-cancellation grace.
54/// - `RetryEvents`, the internal dispatcher for listener callbacks and
55///   retry-after hints.
56///
57/// The facade deliberately contains no retry loop logic. Each public execution
58/// method creates a mode-specific runner and delegates the real control flow:
59///
60/// - [`Retry::run`] uses `RetryRunner` and executes the caller's closure on the
61///   current thread.
62/// - `Retry::run_async` (requires the `tokio` feature) uses `AsyncRetryRunner`
63///   and executes each future on the current Tokio task.
64/// - [`Retry::run_in_worker`] uses `WorkerRetryRunner` and executes each
65///   attempt inside a worker thread.
66///
67/// Those runners all follow the same conceptual pipeline. They adapt the
68/// caller's operation into an internal attempt object, keep a
69/// `RetryFlowState`, fire lifecycle events, call the operation once per
70/// attempt, and pass failures to `RetryFailureHandler` to decide whether to
71/// sleep and retry or return a terminal [`RetryError`]. The mode-specific runner
72/// owns only the execution mechanics that differ by mode: blocking sleep,
73/// Tokio timeout, worker-thread panic capture, and cooperative cancellation.
74/// This split keeps the public API small while keeping timeout and concurrency
75/// details out of the `Retry` facade.
76#[derive(Clone)]
77pub struct Retry<E = BoxError> {
78    /// Validated retry limits and backoff settings.
79    options: RetryOptions,
80    /// Retry lifecycle event dispatcher.
81    events: RetryEvents<E>,
82}
83
84#[allow(clippy::result_large_err)]
85impl<E> Retry<E> {
86    /// Creates a retry builder.
87    ///
88    /// # Returns
89    /// A [`RetryBuilder`] configured with defaults.
90    #[inline]
91    pub fn builder() -> RetryBuilder<E> {
92        RetryBuilder::new()
93    }
94
95    /// Creates a retry policy from options.
96    ///
97    /// # Parameters
98    /// - `options`: Retry options to validate and install.
99    ///
100    /// # Returns
101    /// A retry policy using the default listener set.
102    ///
103    /// # Errors
104    /// Returns [`RetryConfigError`] if the options are invalid.
105    pub fn from_options(options: RetryOptions) -> Result<Self, RetryConfigError> {
106        Self::builder().options(options).build()
107    }
108
109    /// Returns the immutable options used by this retry policy.
110    ///
111    /// # Returns
112    /// Shared retry options.
113    #[inline]
114    pub fn options(&self) -> &RetryOptions {
115        &self.options
116    }
117
118    /// Runs a synchronous operation with retry.
119    ///
120    /// This method is the same-thread execution path. The call flow is:
121    ///
122    /// 1. Create a `RetryRunner` that borrows this retry policy.
123    /// 2. Wrap `operation` in a value-capturing internal adapter so the retry
124    ///    loop can work with a type-erased `Result<(), AttemptFailure<E>>`
125    ///    while preserving the successful `T` value.
126    /// 3. Reject configured `attempt_timeout`, because a same-thread closure
127    ///    cannot be interrupted safely once it starts running.
128    /// 4. For each attempt, update `RetryFlowState`, fire `before_attempt`,
129    ///    call the closure directly on the current thread, record elapsed
130    ///    operation time, and fire success or failure events.
131    /// 5. On failure, let `RetryFailureHandler` apply retry limits, error
132    ///    predicates, retry-after hints, elapsed budgets, and backoff. If it
133    ///    chooses retry, sleep with `std::thread::sleep` and start the next
134    ///    attempt; otherwise return the produced [`RetryError`].
135    ///
136    /// # Parameters
137    /// - `operation`: Operation called once per attempt until it succeeds or the
138    ///   retry flow stops.
139    ///
140    /// # Returns
141    /// `Ok(T)` with the operation value, or [`RetryError`] when retrying stops.
142    ///
143    /// # Panics
144    /// Propagates operation panics and listener panics unless listener panic
145    /// isolation is enabled.
146    ///
147    /// # Blocking
148    /// Blocks the current thread with `std::thread::sleep` between attempts when
149    /// a non-zero retry delay is selected.
150    ///
151    /// # Elapsed Budget
152    /// `max_operation_elapsed` counts only user operation execution time.
153    /// `max_total_elapsed` counts monotonic retry-flow time, including
154    /// operation execution, retry sleep, retry-after sleep, and retry
155    /// control-path listener time. This synchronous mode cannot interrupt an
156    /// already-running operation; it checks budgets before attempts and after
157    /// failed attempts. If `attempt_timeout` is configured, this method returns
158    /// [`crate::RetryErrorReason::UnsupportedOperation`] because timeout
159    /// enforcement requires worker-thread or async execution.
160    pub fn run<T, F>(&self, operation: F) -> Result<T, RetryError<E>>
161    where
162        F: FnMut() -> Result<T, E>,
163    {
164        RetryRunner::new(self).run(operation)
165    }
166
167    /// Runs an asynchronous operation with retry.
168    ///
169    /// This method is the Tokio execution path. The call flow is:
170    ///
171    /// 1. Create an `AsyncRetryRunner` that borrows this retry policy.
172    /// 2. Wrap `operation` in an async value-capturing adapter. The operation is
173    ///    a factory: it must create a fresh future for every attempt because a
174    ///    Rust future cannot be polled again after it completes.
175    /// 3. Before each attempt, compute the effective timeout from the configured
176    ///    `attempt_timeout`, remaining `max_operation_elapsed`, and remaining
177    ///    `max_total_elapsed`; the shortest available budget wins.
178    /// 4. Fire `before_attempt`, recompute budgets in case listeners consumed
179    ///    total elapsed time, then await the attempt future. If an effective
180    ///    timeout exists, the future is wrapped in `tokio::time::timeout` and
181    ///    dropped when the timer fires.
182    /// 5. Record elapsed operation time, fire success events, or route the
183    ///    failure through elapsed-budget classification and
184    ///    `RetryFailureHandler`. Retry delays use `tokio::time::sleep`; terminal
185    ///    decisions return [`RetryError`].
186    ///
187    /// # Parameters
188    /// - `operation`: Factory returning a fresh future for each attempt.
189    ///
190    /// # Returns
191    /// `Ok(T)` with the operation value, or [`RetryError`] when retrying stops.
192    ///
193    /// # Panics
194    /// Propagates operation panics from the current async task. They are not
195    /// converted to [`crate::AttemptFailure::Panic`] because `run_async` does
196    /// not create an isolation boundary. Listener panics are propagated unless
197    /// listener panic isolation is enabled. Tokio may panic if timer APIs are
198    /// used outside a runtime with a time driver.
199    ///
200    /// # Elapsed Budget
201    /// `max_operation_elapsed` counts only user operation execution time.
202    /// `max_total_elapsed` counts monotonic retry-flow time. Async attempts use
203    /// the shortest of configured attempt timeout, remaining
204    /// max-operation-elapsed budget, and remaining max-total-elapsed budget as
205    /// their effective timeout.
206    #[cfg(feature = "tokio")]
207    pub async fn run_async<T, F, Fut>(&self, operation: F) -> Result<T, RetryError<E>>
208    where
209        F: FnMut() -> Fut,
210        Fut: Future<Output = Result<T, E>>,
211    {
212        AsyncRetryRunner::new(self).run(operation).await
213    }
214
215    /// Runs a blocking operation with retry inside worker-thread attempts.
216    ///
217    /// This method is the blocking-isolation execution path. The call flow is:
218    ///
219    /// 1. Create a `WorkerRetryRunner` that borrows this retry policy.
220    /// 2. Wrap the operation in a shared blocking adapter. The adapter stores
221    ///    the successful `T` value outside the type-erased retry loop.
222    /// 3. For each attempt, compute the effective timeout from configured
223    ///    `attempt_timeout` and remaining elapsed budgets, fire
224    ///    `before_attempt`, then spawn one worker-thread attempt through
225    ///    `WorkerAttemptExecutor`.
226    /// 4. The worker receives an [`AttemptCancelToken`]. If the effective
227    ///    timeout expires, the runner marks that token as cancelled and waits up
228    ///    to [`crate::RetryOptions::worker_cancel_grace`] for cooperative exit.
229    /// 5. Worker panics become [`crate::AttemptFailure::Panic`], worker-spawn
230    ///    failures become [`crate::AttemptFailure::Executor`], and timeout or
231    ///    application failures are passed through the normal retry policy.
232    /// 6. The runner refuses to start another worker while a timed-out worker is
233    ///    still running, because that would create concurrent attempts for one
234    ///    retry flow. That condition returns
235    ///    [`crate::RetryErrorReason::WorkerStillRunning`].
236    ///
237    /// Each attempt runs on a worker thread. Worker panics are captured as
238    /// [`crate::AttemptFailure::Panic`]. Worker-spawn failures are reported as
239    /// [`crate::AttemptFailure::Executor`]. If the effective timeout expires,
240    /// the retry executor stops waiting and marks the attempt's
241    /// [`AttemptCancelToken`] as cancelled. It then waits up to
242    /// [`crate::RetryOptions::worker_cancel_grace`] for the worker to exit.
243    /// Configured attempt-timeout expirations continue according to
244    /// [`crate::AttemptTimeoutPolicy`] only when the worker exits within that
245    /// grace period; otherwise the retry flow stops with
246    /// [`crate::RetryErrorReason::WorkerStillRunning`]. Elapsed-budget
247    /// expirations stop with
248    /// [`crate::RetryErrorReason::MaxOperationElapsedExceeded`] or
249    /// [`crate::RetryErrorReason::MaxTotalElapsedExceeded`].
250    ///
251    /// # Parameters
252    /// - `operation`: Thread-safe operation called once per attempt. It receives
253    ///   a cooperative cancellation token for that attempt.
254    ///
255    /// # Returns
256    /// `Ok(T)` with the operation value, or [`RetryError`] when retrying stops.
257    ///
258    /// # Panics
259    /// Does not propagate operation panics. Listener panic behavior follows this
260    /// retry policy's listener isolation setting.
261    ///
262    /// # Blocking
263    /// Blocks the current thread while waiting for each worker result or timeout
264    /// and while sleeping between retry attempts.
265    ///
266    /// # Elapsed Budget
267    /// `max_operation_elapsed` counts only user operation execution time.
268    /// `max_total_elapsed` counts monotonic retry-flow time. Worker attempts use
269    /// the shortest of configured attempt timeout, remaining
270    /// max-operation-elapsed budget, and remaining max-total-elapsed budget as
271    /// their effective timeout.
272    pub fn run_in_worker<T, F>(&self, operation: F) -> Result<T, RetryError<E>>
273    where
274        T: Send + 'static,
275        E: Send + 'static,
276        F: Fn(AttemptCancelToken) -> Result<T, E> + Send + Sync + 'static,
277    {
278        WorkerRetryRunner::new(self).run(operation)
279    }
280
281    /// Creates a retry policy from validated parts.
282    ///
283    /// # Parameters
284    /// - `options`: Retry options.
285    /// - `retry_after_hint`: Optional hint extractor.
286    /// - `isolate_listener_panics`: Whether listener panics are isolated.
287    /// - `listeners`: Lifecycle listeners.
288    ///
289    /// # Returns
290    /// A retry policy.
291    pub(super) fn new(
292        options: RetryOptions,
293        retry_after_hint: Option<RetryAfterHint<E>>,
294        isolate_listener_panics: bool,
295        listeners: RetryListeners<E>,
296    ) -> Self {
297        Self {
298            options,
299            events: RetryEvents::new(retry_after_hint, isolate_listener_panics, listeners),
300        }
301    }
302
303    /// Returns the internal event dispatcher.
304    ///
305    /// # Returns
306    /// Event dispatcher used by retry runners.
307    #[inline]
308    pub(in crate::executor) fn events(&self) -> &RetryEvents<E> {
309        &self.events
310    }
311}
312
313impl<E> fmt::Debug for Retry<E> {
314    /// Formats the retry policy without exposing callbacks.
315    ///
316    /// # Parameters
317    /// - `f`: Formatter.
318    ///
319    /// # Returns
320    /// Formatter result.
321    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
322        f.debug_struct("Retry")
323            .field("options", &self.options)
324            .finish_non_exhaustive()
325    }
326}