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