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}