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