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