Skip to main content

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