Skip to main content

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}