Skip to main content

ferrin_spec/error/
api_call.rs

1//! HTTP call failures.
2
3use http::StatusCode;
4use url::Url;
5
6use super::BoxError;
7use super::truncate_for_display;
8use crate::json::JsonValue;
9use crate::shared::Headers;
10
11/// Maximum number of bytes of the message shown by `Display`.
12const DISPLAY_MAX_BYTES: usize = 2048;
13
14/// Returns the default retry classification for an HTTP status.
15///
16/// Retryable: 408 (request timeout), 409 (conflict), 429 (too many requests)
17/// and every 5xx status. `None` (no HTTP response, e.g. a network error) is
18/// treated as retryable.
19#[must_use]
20pub fn default_retryable(status_code: Option<StatusCode>) -> bool {
21    match status_code {
22        None => true,
23        Some(status) => {
24            status == StatusCode::REQUEST_TIMEOUT
25                || status == StatusCode::CONFLICT
26                || status == StatusCode::TOO_MANY_REQUESTS
27                || status.is_server_error()
28        }
29    }
30}
31
32/// An HTTP request to the provider failed or returned an error status.
33#[derive(Debug)]
34pub struct ApiCallError {
35    /// Human-readable message (no secrets, no full bodies).
36    pub message: String,
37    /// Request URL.
38    pub url: Url,
39    /// JSON request body that was sent, if any.
40    pub request_body: Option<JsonValue>,
41    /// HTTP status code, if a response was received.
42    pub status_code: Option<StatusCode>,
43    /// Response headers, if a response was received.
44    pub response_headers: Option<Headers>,
45    /// Raw response body text, if a response was received.
46    pub response_body: Option<String>,
47    /// Whether retrying may succeed.
48    pub is_retryable: bool,
49    /// Structured error payload parsed from the response, if any.
50    pub data: Option<JsonValue>,
51    /// Underlying cause (network error, JSON error, ...).
52    pub cause: Option<BoxError>,
53}
54
55impl ApiCallError {
56    /// Creates an error without a response; retryable by default.
57    #[must_use]
58    pub fn new(message: impl Into<String>, url: Url) -> Self {
59        Self {
60            message: message.into(),
61            url,
62            request_body: None,
63            status_code: None,
64            response_headers: None,
65            response_body: None,
66            is_retryable: default_retryable(None),
67            data: None,
68            cause: None,
69        }
70    }
71
72    /// Sets the status code and recomputes the default retry classification.
73    #[must_use]
74    pub fn with_status(mut self, status_code: StatusCode) -> Self {
75        self.status_code = Some(status_code);
76        self.is_retryable = default_retryable(Some(status_code));
77        self
78    }
79
80    /// Sets the request body.
81    #[must_use]
82    pub fn with_request_body(mut self, body: JsonValue) -> Self {
83        self.request_body = Some(body);
84        self
85    }
86
87    /// Sets response headers and body.
88    #[must_use]
89    pub fn with_response(mut self, headers: Headers, body: Option<String>) -> Self {
90        self.response_headers = Some(headers);
91        self.response_body = body;
92        self
93    }
94
95    /// Sets the structured error payload.
96    #[must_use]
97    pub fn with_data(mut self, data: JsonValue) -> Self {
98        self.data = Some(data);
99        self
100    }
101
102    /// Sets the underlying cause.
103    #[must_use]
104    pub fn with_cause(mut self, cause: impl std::error::Error + Send + Sync + 'static) -> Self {
105        self.cause = Some(Box::new(cause));
106        self
107    }
108
109    /// Overrides the retry classification.
110    #[must_use]
111    pub fn retryable(mut self, is_retryable: bool) -> Self {
112        self.is_retryable = is_retryable;
113        self
114    }
115}
116
117impl std::fmt::Display for ApiCallError {
118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
119        f.write_str(&truncate_for_display(&self.message, DISPLAY_MAX_BYTES))
120    }
121}
122
123impl std::error::Error for ApiCallError {
124    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
125        self.cause
126            .as_deref()
127            .map(|cause| cause as &(dyn std::error::Error + 'static))
128    }
129}