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