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