qubit_retry/error/retry_error.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 execution errors.
11//!
12//! This module contains the error returned when a retry executor stops without a
13//! successful result. The original application error type is preserved in the
14//! generic parameter `E`.
15
16use serde::{
17 Deserialize,
18 Serialize,
19};
20use std::error::Error;
21use std::fmt;
22
23use crate::{
24 AttemptFailure,
25 RetryContext,
26 RetryErrorReason,
27};
28
29/// Error returned when a retry flow terminates without a successful result.
30///
31/// The generic parameter `E` is the caller's application error type. It is
32/// preserved in [`AttemptFailure::Error`] when the terminal failure came from
33/// the user operation. Runtime failures such as timeout, panic, and executor
34/// failures are preserved through [`RetryError::last_failure`].
35#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(bound(
37 serialize = "E: serde::Serialize",
38 deserialize = "E: serde::de::DeserializeOwned"
39))]
40pub struct RetryError<E> {
41 /// Terminal reason selected by the retry flow.
42 reason: RetryErrorReason,
43 /// Last attempt failure, if any attempt ran before termination.
44 last_failure: Option<AttemptFailure<E>>,
45 /// Context snapshot captured when the retry flow stopped.
46 context: RetryContext,
47}
48
49/// Result alias returned by retry executor execution.
50///
51/// The success type `T` is chosen by each operation. The error type `E`
52/// remains the caller's original application error and is wrapped by
53/// [`RetryError`] only when retry execution terminates unsuccessfully.
54pub type RetryResult<T, E> = Result<T, RetryError<E>>;
55
56impl<E> RetryError<E> {
57 /// Creates a retry error.
58 ///
59 /// # Parameters
60 /// - `reason`: Terminal reason.
61 /// - `last_failure`: Last observed attempt failure, if any.
62 /// - `context`: Retry context captured at termination.
63 ///
64 /// # Returns
65 /// A retry error preserving the terminal reason and context.
66 #[inline]
67 pub(crate) fn new(
68 reason: RetryErrorReason,
69 last_failure: Option<AttemptFailure<E>>,
70 context: RetryContext,
71 ) -> Self {
72 Self {
73 reason,
74 last_failure,
75 context,
76 }
77 }
78
79 /// Returns the terminal retry error reason.
80 ///
81 /// # Parameters
82 /// This method has no parameters.
83 ///
84 /// # Returns
85 /// The reason the retry flow stopped.
86 #[inline]
87 pub fn reason(&self) -> RetryErrorReason {
88 self.reason
89 }
90
91 /// Returns the retry context captured at termination.
92 ///
93 /// # Parameters
94 /// This method has no parameters.
95 ///
96 /// # Returns
97 /// A context snapshot with attempt counts and timing metadata.
98 #[inline]
99 pub fn context(&self) -> &RetryContext {
100 &self.context
101 }
102
103 /// Returns the timeout source that produced the final attempt timeout, if any.
104 ///
105 /// # Parameters
106 /// This method has no parameters.
107 ///
108 /// # Returns
109 /// The timeout source when present, or `None` when no attempt timeout was
110 /// selected for the terminal context.
111 #[inline]
112 pub fn attempt_timeout_source(&self) -> Option<crate::event::AttemptTimeoutSource> {
113 self.context.attempt_timeout_source()
114 }
115
116 /// Returns the number of worker threads not observed to exit after cancellation.
117 ///
118 /// # Parameters
119 /// This method has no parameters.
120 ///
121 /// # Returns
122 /// Count of timed-out worker attempts that did not finish within the worker
123 /// cancellation grace period.
124 #[inline]
125 pub fn unreaped_worker_count(&self) -> u32 {
126 self.context.unreaped_worker_count()
127 }
128
129 /// Returns the number of attempts that were executed.
130 ///
131 /// # Returns
132 /// The number of operation attempts observed before termination.
133 #[inline]
134 pub fn attempts(&self) -> u32 {
135 self.context.attempt()
136 }
137
138 /// Returns the last failure, if one exists.
139 ///
140 /// # Returns
141 /// `Some(&AttemptFailure<E>)` when at least one attempt failure was observed;
142 /// `None` when the retry flow stopped before any attempt ran.
143 #[inline]
144 pub fn last_failure(&self) -> Option<&AttemptFailure<E>> {
145 self.last_failure.as_ref()
146 }
147
148 /// Returns the last application error, if one exists.
149 ///
150 /// # Parameters
151 /// This method has no parameters.
152 ///
153 /// # Returns
154 /// `Some(&E)` when the terminal failure wraps an application error;
155 /// `None` for timeout, panic, executor failures, or elapsed-budget failures
156 /// with no attempt.
157 #[inline]
158 pub fn last_error(&self) -> Option<&E> {
159 self.last_failure().and_then(AttemptFailure::as_error)
160 }
161
162 /// Consumes the retry error and returns the last application error when
163 /// the final failure wraps one.
164 ///
165 /// # Parameters
166 /// This method has no parameters.
167 ///
168 /// # Returns
169 /// `Some(E)` when the terminal failure owns an application error; `None`
170 /// when the terminal failure was a timeout, panic, executor failure, or
171 /// when no attempt ran.
172 #[inline]
173 pub fn into_last_error(self) -> Option<E> {
174 self.last_failure.and_then(AttemptFailure::into_error)
175 }
176
177 /// Consumes the retry error and returns all terminal parts.
178 ///
179 /// # Parameters
180 /// This method has no parameters.
181 ///
182 /// # Returns
183 /// A tuple `(reason, last_failure, context)` preserving all terminal data.
184 #[inline]
185 pub fn into_parts(self) -> (RetryErrorReason, Option<AttemptFailure<E>>, RetryContext) {
186 (self.reason, self.last_failure, self.context)
187 }
188}
189
190impl<E> fmt::Display for RetryError<E>
191where
192 E: fmt::Display,
193{
194 /// Formats the retry error for diagnostics.
195 ///
196 /// # Parameters
197 /// - `f`: Formatter provided by the standard formatting machinery.
198 ///
199 /// # Returns
200 /// `fmt::Result` from the formatter.
201 ///
202 /// # Errors
203 /// Returns a formatting error if the underlying formatter fails.
204 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
205 let attempts = self.attempts();
206 let message = match self.reason {
207 RetryErrorReason::Aborted => format!("retry aborted after {attempts} attempt(s)"),
208 RetryErrorReason::AttemptsExceeded => format!(
209 "retry attempts exceeded after {attempts} attempt(s), max {}",
210 self.context.max_attempts()
211 ),
212 RetryErrorReason::MaxOperationElapsedExceeded => {
213 format!("retry max operation elapsed exceeded after {attempts} attempt(s)")
214 }
215 RetryErrorReason::MaxTotalElapsedExceeded => {
216 format!("retry max total elapsed exceeded after {attempts} attempt(s)")
217 }
218 RetryErrorReason::UnsupportedOperation => {
219 "run() does not support attempt timeout; use run_async() or run_in_worker()"
220 .to_string()
221 }
222 RetryErrorReason::WorkerStillRunning => {
223 format!(
224 "retry worker still running after timeout cancellation grace, unreaped {}",
225 self.context.unreaped_worker_count()
226 )
227 }
228 };
229 f.write_str(&message)?;
230 if let Some(failure) = &self.last_failure {
231 write!(f, "; last failure: {failure}")?;
232 }
233 Ok(())
234 }
235}
236
237impl<E> Error for RetryError<E>
238where
239 E: Error + 'static,
240{
241 /// Returns the source terminal failure when one is available.
242 ///
243 /// # Parameters
244 /// This method has no parameters.
245 ///
246 /// # Returns
247 /// `Some(&dyn Error)` when the terminal failure wraps an application error,
248 /// captured panic, or executor failure; otherwise `None`.
249 ///
250 /// # Errors
251 /// This method does not return errors.
252 fn source(&self) -> Option<&(dyn Error + 'static)> {
253 match self.last_failure() {
254 Some(AttemptFailure::Error(error)) => Some(error as &(dyn Error + 'static)),
255 Some(AttemptFailure::Panic(panic)) => Some(panic as &(dyn Error + 'static)),
256 Some(AttemptFailure::Executor(error)) => Some(error as &(dyn Error + 'static)),
257 Some(AttemptFailure::Timeout) | None => None,
258 }
259 }
260}