Skip to main content

qubit_http/error/
http_error.rs

1/*******************************************************************************
2 *
3 *    Copyright (c) 2025 - 2026.
4 *    Haixing Hu, Qubit Co. Ltd.
5 *
6 *    All rights reserved.
7 *
8 ******************************************************************************/
9//! Unified [`HttpError`] type.
10
11use std::error::Error;
12use std::time::Duration;
13
14use http::{Method, StatusCode};
15use thiserror::Error;
16use url::Url;
17
18use super::RetryHint;
19use qubit_common::BoxError;
20
21use super::HttpErrorKind;
22
23/// Unified HTTP error type.
24#[derive(Debug, Error)]
25#[error("{message}")]
26pub struct HttpError {
27    /// Error category.
28    pub kind: HttpErrorKind,
29    /// Optional HTTP method.
30    pub method: Option<Method>,
31    /// Optional request URL.
32    pub url: Option<Url>,
33    /// Optional response status code.
34    pub status: Option<StatusCode>,
35    /// Human-readable message.
36    pub message: String,
37    /// Optional preview of non-success response body.
38    pub response_body_preview: Option<String>,
39    /// Optional `Retry-After` duration parsed from a non-success response.
40    pub retry_after: Option<Duration>,
41    /// Optional source error.
42    #[source]
43    pub source: Option<BoxError>,
44}
45
46impl HttpError {
47    /// Creates an error with kind and message; other fields are unset until chained.
48    ///
49    /// # Parameters
50    /// - `kind`: Classification for retry logic and handling.
51    /// - `message`: Human-readable description.
52    ///
53    /// # Returns
54    /// New [`HttpError`].
55    pub fn new(kind: HttpErrorKind, message: impl Into<String>) -> Self {
56        Self {
57            kind,
58            method: None,
59            url: None,
60            status: None,
61            message: message.into(),
62            response_body_preview: None,
63            retry_after: None,
64            source: None,
65        }
66    }
67
68    /// Attaches the HTTP method for diagnostics.
69    ///
70    /// # Parameters
71    /// - `method`: Request method associated with the failure.
72    ///
73    /// # Returns
74    /// `self` for chaining.
75    pub fn with_method(mut self, method: Method) -> Self {
76        self.method = Some(method);
77        self
78    }
79
80    /// Attaches the request URL for diagnostics.
81    ///
82    /// # Parameters
83    /// - `url`: Request URL associated with the failure.
84    ///
85    /// # Returns
86    /// `self` for chaining.
87    pub fn with_url(mut self, url: Url) -> Self {
88        self.url = Some(url);
89        self
90    }
91
92    /// Attaches an HTTP status code (e.g. for [`HttpErrorKind::Status`]).
93    ///
94    /// # Parameters
95    /// - `status`: Response status code.
96    ///
97    /// # Returns
98    /// `self` for chaining.
99    pub fn with_status(mut self, status: StatusCode) -> Self {
100        self.status = Some(status);
101        self
102    }
103
104    /// Wraps an underlying error as the [`HttpError::source`] chain.
105    ///
106    /// # Parameters
107    /// - `source`: Root cause (`Send + Sync + 'static`).
108    ///
109    /// # Returns
110    /// `self` for chaining.
111    pub fn with_source<E>(mut self, source: E) -> Self
112    where
113        E: Error + Send + Sync + 'static,
114    {
115        self.source = Some(Box::new(source));
116        self
117    }
118
119    /// Attaches a preview of the non-success response body.
120    ///
121    /// # Parameters
122    /// - `preview`: Truncated or summarized response body text.
123    ///
124    /// # Returns
125    /// `self` for chaining.
126    pub fn with_response_body_preview(mut self, preview: impl Into<String>) -> Self {
127        self.response_body_preview = Some(preview.into());
128        self
129    }
130
131    /// Attaches parsed `Retry-After` duration from a non-success response.
132    ///
133    /// # Parameters
134    /// - `retry_after`: Parsed retry delay.
135    ///
136    /// # Returns
137    /// `self` for chaining.
138    pub fn with_retry_after(mut self, retry_after: Duration) -> Self {
139        self.retry_after = Some(retry_after);
140        self
141    }
142
143    /// Builds [`HttpErrorKind::InvalidUrl`].
144    ///
145    /// # Parameters
146    /// - `message`: Why the URL is invalid or cannot be resolved.
147    ///
148    /// # Returns
149    /// New [`HttpError`].
150    pub fn invalid_url(message: impl Into<String>) -> Self {
151        Self::new(HttpErrorKind::InvalidUrl, message)
152    }
153
154    /// Builds [`HttpErrorKind::BuildClient`] (e.g. reqwest builder failure).
155    ///
156    /// # Parameters
157    /// - `message`: Build failure description.
158    ///
159    /// # Returns
160    /// New [`HttpError`].
161    pub fn build_client(message: impl Into<String>) -> Self {
162        Self::new(HttpErrorKind::BuildClient, message)
163    }
164
165    /// Builds [`HttpErrorKind::ProxyConfig`].
166    ///
167    /// # Parameters
168    /// - `message`: Invalid proxy settings explanation.
169    ///
170    /// # Returns
171    /// New [`HttpError`].
172    pub fn proxy_config(message: impl Into<String>) -> Self {
173        Self::new(HttpErrorKind::ProxyConfig, message)
174    }
175
176    /// Builds [`HttpErrorKind::ConnectTimeout`].
177    ///
178    /// # Parameters
179    /// - `message`: Timeout context.
180    ///
181    /// # Returns
182    /// New [`HttpError`].
183    pub fn connect_timeout(message: impl Into<String>) -> Self {
184        Self::new(HttpErrorKind::ConnectTimeout, message)
185    }
186
187    /// Builds [`HttpErrorKind::ReadTimeout`].
188    ///
189    /// # Parameters
190    /// - `message`: Timeout context.
191    ///
192    /// # Returns
193    /// New [`HttpError`].
194    pub fn read_timeout(message: impl Into<String>) -> Self {
195        Self::new(HttpErrorKind::ReadTimeout, message)
196    }
197
198    /// Builds [`HttpErrorKind::WriteTimeout`].
199    ///
200    /// # Parameters
201    /// - `message`: Timeout context.
202    ///
203    /// # Returns
204    /// New [`HttpError`].
205    pub fn write_timeout(message: impl Into<String>) -> Self {
206        Self::new(HttpErrorKind::WriteTimeout, message)
207    }
208
209    /// Builds [`HttpErrorKind::RequestTimeout`].
210    ///
211    /// # Parameters
212    /// - `message`: Timeout context for the whole request deadline.
213    ///
214    /// # Returns
215    /// New [`HttpError`].
216    pub fn request_timeout(message: impl Into<String>) -> Self {
217        Self::new(HttpErrorKind::RequestTimeout, message)
218    }
219
220    /// Builds [`HttpErrorKind::Transport`].
221    ///
222    /// # Parameters
223    /// - `message`: Low-level I/O or network failure description.
224    ///
225    /// # Returns
226    /// New [`HttpError`].
227    pub fn transport(message: impl Into<String>) -> Self {
228        Self::new(HttpErrorKind::Transport, message)
229    }
230
231    /// Builds [`HttpErrorKind::Status`] with the given status pre-filled.
232    ///
233    /// # Parameters
234    /// - `status`: HTTP status from the response.
235    /// - `message`: Additional context.
236    ///
237    /// # Returns
238    /// New [`HttpError`] with [`HttpError::status`] set.
239    pub fn status(status: StatusCode, message: impl Into<String>) -> Self {
240        Self::new(HttpErrorKind::Status, message).with_status(status)
241    }
242
243    /// Builds [`HttpErrorKind::Decode`] (body or payload decoding).
244    ///
245    /// # Parameters
246    /// - `message`: Decode failure description.
247    ///
248    /// # Returns
249    /// New [`HttpError`].
250    pub fn decode(message: impl Into<String>) -> Self {
251        Self::new(HttpErrorKind::Decode, message)
252    }
253
254    /// Builds [`HttpErrorKind::SseProtocol`] (framing, UTF-8, SSE line rules).
255    ///
256    /// # Parameters
257    /// - `message`: Protocol violation description.
258    ///
259    /// # Returns
260    /// New [`HttpError`].
261    pub fn sse_protocol(message: impl Into<String>) -> Self {
262        Self::new(HttpErrorKind::SseProtocol, message)
263    }
264
265    /// Builds [`HttpErrorKind::SseDecode`] (e.g. JSON in SSE data).
266    ///
267    /// # Parameters
268    /// - `message`: Payload decode failure description.
269    ///
270    /// # Returns
271    /// New [`HttpError`].
272    pub fn sse_decode(message: impl Into<String>) -> Self {
273        Self::new(HttpErrorKind::SseDecode, message)
274    }
275
276    /// Builds [`HttpErrorKind::Cancelled`].
277    ///
278    /// # Parameters
279    /// - `message`: Why the operation was cancelled.
280    ///
281    /// # Returns
282    /// New [`HttpError`].
283    pub fn cancelled(message: impl Into<String>) -> Self {
284        Self::new(HttpErrorKind::Cancelled, message)
285    }
286
287    /// Builds [`HttpErrorKind::Other`].
288    ///
289    /// # Parameters
290    /// - `message`: Catch-all description.
291    ///
292    /// # Returns
293    /// New [`HttpError`].
294    pub fn other(message: impl Into<String>) -> Self {
295        Self::new(HttpErrorKind::Other, message)
296    }
297
298    /// Classifies this error for retry policies ([`RetryHint`]).
299    ///
300    /// # Returns
301    /// [`RetryHint::Retryable`] for timeouts, transport errors, and some HTTP statuses; otherwise non-retryable.
302    pub fn retry_hint(&self) -> RetryHint {
303        match self.kind {
304            HttpErrorKind::ConnectTimeout
305            | HttpErrorKind::ReadTimeout
306            | HttpErrorKind::WriteTimeout
307            | HttpErrorKind::RequestTimeout
308            | HttpErrorKind::Transport => RetryHint::Retryable,
309            HttpErrorKind::Status => {
310                if let Some(status) = self.status {
311                    if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
312                        RetryHint::Retryable
313                    } else {
314                        RetryHint::NonRetryable
315                    }
316                } else {
317                    RetryHint::NonRetryable
318                }
319            }
320            _ => RetryHint::NonRetryable,
321        }
322    }
323}
324
325impl From<std::io::Error> for HttpError {
326    /// Maps [`std::io::Error`] to [`HttpError::transport`] with the I/O error as source.
327    ///
328    /// # Parameters
329    /// - `error`: Underlying I/O error.
330    ///
331    /// # Returns
332    /// Wrapped [`HttpError`].
333    fn from(error: std::io::Error) -> Self {
334        Self::transport(error.to_string()).with_source(error)
335    }
336}
337
338impl From<reqwest::Error> for HttpError {
339    /// Maps [`reqwest::Error`] to [`HttpErrorKind::BuildClient`] with chained source.
340    ///
341    /// # Parameters
342    /// - `error`: Reqwest error to wrap.
343    ///
344    /// # Returns
345    /// Wrapped [`HttpError`].
346    fn from(error: reqwest::Error) -> Self {
347        Self::build_client(format!("Failed to build reqwest client: {}", error)).with_source(error)
348    }
349}