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}