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 /// Invalid configuration
182 #[error("Configuration error: {0}")]
183 Config(String),
184
185 /// Generic error
186 #[error("{0}")]
187 Other(String),
188}
189
190impl HttpError {
191 /// Create an HTTP error from a status code and message
192 pub fn from_status(status: u16, message: impl Into<String>, body: Option<String>) -> Self {
193 Self::Http {
194 status,
195 message: message.into(),
196 body,
197 }
198 }
199
200 /// Create a serialization error
201 pub fn serialization_error(error: impl std::fmt::Display) -> Self {
202 Self::Serialization(error.to_string())
203 }
204
205 /// Create a deserialization error
206 pub fn deserialization_error(error: impl std::fmt::Display) -> Self {
207 Self::Deserialization(error.to_string())
208 }
209
210 /// Check if this is a client error (4xx)
211 pub fn is_client_error(&self) -> bool {
212 matches!(self, Self::Http { status, .. } if *status >= 400 && *status < 500)
213 }
214
215 /// Check if this is a server error (5xx)
216 pub fn is_server_error(&self) -> bool {
217 matches!(self, Self::Http { status, .. } if *status >= 500 && *status < 600)
218 }
219
220 /// Check if this error is retryable
221 pub fn is_retryable(&self) -> bool {
222 match self {
223 Self::Network(_) => true,
224 Self::Timeout => true,
225 Self::Http { status, .. } => {
226 // Retry on 429 (rate limit), 500, 502, 503, 504
227 matches!(status, 429 | 500 | 502 | 503 | 504)
228 }
229 _ => false,
230 }
231 }
232}
233
234/// Result type for HTTP operations
235pub type HttpResult<T> = Result<T, HttpError>;
236
237/// Envelope for an API response we received but couldn't (or didn't) treat as success.
238///
239/// `ApiError<E>` is returned whenever the server actually responded — whether the
240/// status was non-2xx, or the 2xx body failed to deserialize into the expected
241/// type. `status`, `headers`, and `body` are always populated so callers can
242/// inspect what the server actually sent without having to hack the generated
243/// client. `typed` is `Some(_)` when the raw body was successfully parsed into a
244/// per-operation error type; `parse_error` records why parsing failed when not.
245/// Formatting the error limits only the displayed body preview; the public
246/// fields retain the complete response and parsing details.
247#[derive(Debug, Clone)]
248pub struct ApiError<E> {
249 pub status: u16,
250 pub headers: reqwest::header::HeaderMap,
251 pub body: String,
252 pub typed: Option<E>,
253 pub parse_error: Option<String>,
254}
255
256const API_ERROR_BODY_DISPLAY_LIMIT: usize = 500;
257const API_ERROR_BODY_TRUNCATION_MARKER: &str = "... [truncated]";
258
259fn display_api_error_body(body: &str) -> std::borrow::Cow<'_, str> {
260 let Some((end, _)) = body.char_indices().nth(API_ERROR_BODY_DISPLAY_LIMIT) else {
261 return std::borrow::Cow::Borrowed(body);
262 };
263
264 let mut displayed = String::with_capacity(end + API_ERROR_BODY_TRUNCATION_MARKER.len());
265 displayed.push_str(&body[..end]);
266 displayed.push_str(API_ERROR_BODY_TRUNCATION_MARKER);
267 std::borrow::Cow::Owned(displayed)
268}
269
270impl<E> ApiError<E> {
271 pub fn is_client_error(&self) -> bool {
272 (400..500).contains(&self.status)
273 }
274
275 pub fn is_server_error(&self) -> bool {
276 (500..600).contains(&self.status)
277 }
278
279 /// Decode the generated RFC 9457 validation-problem profile without
280 /// disturbing a documented typed error.
281 ///
282 /// Only the `application/problem+json` media type is accepted. Arbitrary
283 /// JSON error bodies are deliberately not reclassified as Problem Details.
284 pub fn problem_details(&self) -> Option<openapi_to_rust_problem::ProblemDetails> {
285 let content_type = self
286 .headers
287 .get(reqwest::header::CONTENT_TYPE)?
288 .to_str()
289 .ok()?;
290 let media_type = content_type.split(';').next()?.trim();
291 if !media_type.eq_ignore_ascii_case("application/problem+json") {
292 return None;
293 }
294 serde_json::from_str(&self.body).ok()
295 }
296}
297
298impl<E: std::fmt::Debug> std::fmt::Display for ApiError<E> {
299 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
300 write!(
301 f,
302 "API error {}: {}",
303 self.status,
304 display_api_error_body(&self.body)
305 )?;
306
307 if let Some(typed) = &self.typed {
308 write!(f, "; typed: {typed:?}")?;
309 }
310
311 if let Some(parse_error) = &self.parse_error {
312 write!(f, "; parse error: {parse_error}")?;
313 }
314
315 Ok(())
316 }
317}
318
319impl<E: std::fmt::Debug> std::error::Error for ApiError<E> {}
320
321/// Result error type for generated operation methods.
322///
323/// `Transport` covers failures where the request never produced a response we
324/// can inspect (network, timeout, middleware, request-side serialization).
325/// `Api` covers any case where the server *did* respond — the envelope always
326/// carries status + headers + raw body even when the typed deserialize fails.
327#[derive(Debug, thiserror::Error)]
328pub enum ApiOpError<E: std::fmt::Debug> {
329 #[error(transparent)]
330 Transport(#[from] HttpError),
331
332 #[error(transparent)]
333 Api(ApiError<E>),
334}
335
336impl<E: std::fmt::Debug> ApiOpError<E> {
337 /// Convenience accessor: if this is an Api variant, return the envelope.
338 pub fn api(&self) -> Option<&ApiError<E>> {
339 match self {
340 Self::Api(e) => Some(e),
341 Self::Transport(_) => None,
342 }
343 }
344}