openapi_to_rust/http_error.rs
1//! HTTP client error types with comprehensive retry detection.
2//!
3//! This module provides error types for HTTP operations with built-in support for
4//! retry detection, error categorization, and detailed error information.
5//!
6//! # Overview
7//!
8//! The [`HttpError`] enum covers all possible failure modes for HTTP requests:
9//! - Network errors (connection failures, DNS issues)
10//! - HTTP errors (4xx client errors, 5xx server errors)
11//! - Serialization/deserialization errors
12//! - Authentication errors
13//! - Timeouts
14//! - Configuration errors
15//!
16//! # Retry Detection
17//!
18//! The error type includes built-in retry detection via [`HttpError::is_retryable()`]:
19//! - Network errors → retryable
20//! - Timeouts → retryable
21//! - 429 (rate limit) → retryable
22//! - 500, 502, 503, 504 (server errors) → retryable
23//! - All other errors → not retryable
24//!
25//! When using the generated HTTP client with retry middleware (reqwest-retry),
26//! retryable errors are automatically retried with exponential backoff.
27//!
28//! # Examples
29//!
30//! ## Basic Error Handling
31//!
32//! ```
33//! # use openapi_to_rust::http_error::HttpError;
34//! fn handle_api_error(error: HttpError) {
35//! match error {
36//! HttpError::Network(e) => {
37//! eprintln!("Network error: {}", e);
38//! // Will be retried automatically if retry is configured
39//! }
40//! HttpError::Http { status, message, .. } => {
41//! match status {
42//! 400 => eprintln!("Bad request: {}", message),
43//! 401 => eprintln!("Unauthorized - check API key"),
44//! 404 => eprintln!("Not found"),
45//! 429 => eprintln!("Rate limited - will retry"),
46//! 500..=599 => eprintln!("Server error: {}", message),
47//! _ => eprintln!("HTTP error {}: {}", status, message),
48//! }
49//! }
50//! HttpError::Timeout => {
51//! eprintln!("Request timeout - will retry");
52//! }
53//! e => {
54//! eprintln!("Other error: {}", e);
55//! }
56//! }
57//! }
58//! ```
59//!
60//! ## Retry Detection
61//!
62//! ```
63//! # use openapi_to_rust::http_error::HttpError;
64//! fn classify_error(error: &HttpError) {
65//! if error.is_retryable() {
66//! println!("Retryable error: {}", error);
67//! // If retry middleware is configured, this will be retried automatically
68//! } else if error.is_client_error() {
69//! println!("Client error (4xx): fix the request");
70//! } else if error.is_server_error() {
71//! println!("Server error (5xx): may be transient");
72//! } else {
73//! println!("Non-retryable error: {}", error);
74//! }
75//! }
76//! ```
77//!
78//! ## Creating Errors
79//!
80//! ```
81//! use openapi_to_rust::http_error::HttpError;
82//!
83//! // Create HTTP error from status code
84//! let error = HttpError::from_status(404, "Resource not found", None);
85//!
86//! // Create serialization error
87//! let error = HttpError::serialization_error("invalid JSON");
88//!
89//! // Create deserialization error
90//! let error = HttpError::deserialization_error("unexpected field");
91//! ```
92//!
93//! # Integration with reqwest-retry
94//!
95//! When the generated HTTP client is configured with retry middleware,
96//! the retry logic automatically handles retryable errors:
97//!
98//! ```toml
99//! [http_client.retry]
100//! max_retries = 3
101//! initial_delay_ms = 500
102//! max_delay_ms = 16000
103//! ```
104//!
105//! The retry middleware uses exponential backoff and will retry:
106//! - Network errors (connection failures)
107//! - Timeouts
108//! - HTTP 429 (rate limit)
109//! - HTTP 500, 502, 503, 504 (server errors)
110//!
111//! # Error Categories
112//!
113//! Errors can be categorized using helper methods:
114//! - [`HttpError::is_retryable()`] - Should this error be retried?
115//! - [`HttpError::is_client_error()`] - Is this a 4xx error?
116//! - [`HttpError::is_server_error()`] - Is this a 5xx error?
117
118use thiserror::Error;
119
120/// The generated validation-problem profile based on RFC 9457.
121///
122/// The distinctive module name prevents collisions with OpenAPI schemas named
123/// `ProblemDetails` or `InvalidParameter`.
124pub mod openapi_to_rust_problem {
125 /// A sanitized validation problem emitted by generated servers.
126 #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
127 pub struct ProblemDetails {
128 #[serde(rename = "type")]
129 pub type_uri: String,
130 pub title: String,
131 pub status: u16,
132 pub code: String,
133 #[serde(default)]
134 pub errors: Vec<InvalidParameter>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub detail: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub instance: Option<String>,
139 }
140
141 /// One safe, machine-readable request validation violation.
142 #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
143 pub struct InvalidParameter {
144 pub code: String,
145 pub location: String,
146 pub message: String,
147 }
148}
149
150/// HTTP client errors that can occur during API requests
151#[derive(Error, Debug)]
152pub enum HttpError {
153 /// Network or connection error
154 #[error("Network error: {0}")]
155 Network(#[from] reqwest::Error),
156
157 /// Request serialization error
158 #[error("Failed to serialize request: {0}")]
159 Serialization(String),
160
161 /// Response deserialization error
162 #[error("Failed to deserialize response: {0}")]
163 Deserialization(String),
164
165 /// HTTP error response (4xx, 5xx)
166 #[error("HTTP error {status}: {message}")]
167 Http {
168 status: u16,
169 message: String,
170 body: Option<String>,
171 },
172
173 /// Authentication error
174 #[error("Authentication error: {0}")]
175 Auth(String),
176
177 /// Request timeout
178 #[error("Request timeout")]
179 Timeout,
180
181 /// A response body exceeded the configured in-memory limit
182 #[error("Response body exceeded configured limit of {limit} bytes")]
183 ResponseTooLarge { limit: usize },
184
185 /// Invalid configuration
186 #[error("Configuration error: {0}")]
187 Config(String),
188
189 /// Generic error
190 #[error("{0}")]
191 Other(String),
192}
193
194impl HttpError {
195 /// Create an HTTP error from a status code and message
196 pub fn from_status(status: u16, message: impl Into<String>, body: Option<String>) -> Self {
197 Self::Http {
198 status,
199 message: message.into(),
200 body,
201 }
202 }
203
204 /// Create a serialization error
205 pub fn serialization_error(error: impl std::fmt::Display) -> Self {
206 Self::Serialization(error.to_string())
207 }
208
209 /// Create a deserialization error
210 pub fn deserialization_error(error: impl std::fmt::Display) -> Self {
211 Self::Deserialization(error.to_string())
212 }
213
214 /// Check if this is a client error (4xx)
215 pub fn is_client_error(&self) -> bool {
216 matches!(self, Self::Http { status, .. } if *status >= 400 && *status < 500)
217 }
218
219 /// Check if this is a server error (5xx)
220 pub fn is_server_error(&self) -> bool {
221 matches!(self, Self::Http { status, .. } if *status >= 500 && *status < 600)
222 }
223
224 /// Check if this error is retryable
225 pub fn is_retryable(&self) -> bool {
226 match self {
227 Self::Network(_) => true,
228 Self::Timeout => true,
229 Self::Http { status, .. } => {
230 // Retry on 429 (rate limit), 500, 502, 503, 504
231 matches!(status, 429 | 500 | 502 | 503 | 504)
232 }
233 _ => false,
234 }
235 }
236}
237
238/// Result type for HTTP operations
239pub type HttpResult<T> = Result<T, HttpError>;
240
241/// Envelope for an API response we received but couldn't (or didn't) treat as success.
242///
243/// `ApiError<E>` is returned whenever the server actually responded — whether the
244/// status was non-2xx, or the 2xx body failed to deserialize into the expected
245/// type. `status`, `headers`, and `raw_body` preserve what the server actually
246/// sent, while `body` is a convenient lossy UTF-8 rendering. `typed` is `Some(_)`
247/// when the response body was successfully parsed into a
248/// per-operation error type; `parse_error` records why parsing failed when not.
249/// Formatting the error limits only the displayed body preview; the public
250/// fields retain the complete response and parsing details.
251#[derive(Debug, Clone)]
252pub struct ApiError<E> {
253 pub status: u16,
254 pub headers: reqwest::header::HeaderMap,
255 pub body: String,
256 /// Exact response bytes before lossy UTF-8 conversion.
257 pub raw_body: Vec<u8>,
258 pub typed: Option<E>,
259 pub parse_error: Option<String>,
260}
261
262const API_ERROR_BODY_DISPLAY_LIMIT: usize = 500;
263const API_ERROR_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
264
265fn display_api_error_body(body: &str) -> std::borrow::Cow<'_, str> {
266 let Some((end, _)) = body.char_indices().nth(API_ERROR_BODY_DISPLAY_LIMIT) else {
267 return std::borrow::Cow::Borrowed(body);
268 };
269
270 let mut displayed = String::with_capacity(end + API_ERROR_BODY_TRUNCATION_MARKER.len());
271 displayed.push_str(&body[..end]);
272 displayed.push_str(API_ERROR_BODY_TRUNCATION_MARKER);
273 std::borrow::Cow::Owned(displayed)
274}
275
276impl<E> ApiError<E> {
277 pub fn is_client_error(&self) -> bool {
278 (400..500).contains(&self.status)
279 }
280
281 pub fn is_server_error(&self) -> bool {
282 (500..600).contains(&self.status)
283 }
284
285 /// Decode the generated RFC 9457 validation-problem profile without
286 /// disturbing a documented typed error.
287 ///
288 /// Only the `application/problem+json` media type is accepted. Arbitrary
289 /// JSON error bodies are deliberately not reclassified as Problem Details.
290 pub fn problem_details(&self) -> Option<openapi_to_rust_problem::ProblemDetails> {
291 let content_type = self
292 .headers
293 .get(reqwest::header::CONTENT_TYPE)?
294 .to_str()
295 .ok()?;
296 let media_type = content_type.split(';').next()?.trim();
297 if !media_type.eq_ignore_ascii_case("application/problem+json") {
298 return None;
299 }
300 serde_json::from_str(&self.body).ok()
301 }
302}
303
304impl<E: std::fmt::Debug> std::fmt::Display for ApiError<E> {
305 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
306 write!(
307 f,
308 "API error {}: {}",
309 self.status,
310 display_api_error_body(&self.body)
311 )?;
312
313 if let Some(typed) = &self.typed {
314 write!(f, "; typed: {typed:?}")?;
315 }
316
317 if let Some(parse_error) = &self.parse_error {
318 write!(f, "; parse error: {parse_error}")?;
319 }
320
321 Ok(())
322 }
323}
324
325impl<E: std::fmt::Debug> std::error::Error for ApiError<E> {}
326
327/// Result error type for generated operation methods.
328///
329/// `Transport` covers failures where the request never produced a response we
330/// can inspect (network, timeout, middleware, request-side serialization).
331/// `Api` covers any case where the server *did* respond — the envelope always
332/// carries status + headers + raw body even when the typed deserialize fails.
333#[derive(Debug, thiserror::Error)]
334pub enum ApiOpError<E: std::fmt::Debug> {
335 #[error(transparent)]
336 Transport(#[from] HttpError),
337
338 #[error(transparent)]
339 Api(ApiError<E>),
340}
341
342impl<E: std::fmt::Debug> ApiOpError<E> {
343 /// Convenience accessor: if this is an Api variant, return the envelope.
344 pub fn api(&self) -> Option<&ApiError<E>> {
345 match self {
346 Self::Api(e) => Some(e),
347 Self::Transport(_) => None,
348 }
349 }
350}