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