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