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}