Skip to main content

rerout/
error.rs

1//! Error type for the Rerout SDK.
2//!
3//! Every public function returns [`Result<T, ReroutError>`]. The error
4//! distinguishes between API failures (the server replied with a non-2xx),
5//! transport failures (network, timeout, decode), and unexpected responses.
6
7use serde::{Deserialize, Serialize};
8
9/// Convenience alias for `Result<T, ReroutError>`.
10pub type Result<T> = std::result::Result<T, ReroutError>;
11
12/// Extra structured fields returned alongside an API error response.
13///
14/// The Rerout API may surface a `path` and `timestamp` for diagnostics, and an
15/// optional `details` object with field-level validation hints.
16#[derive(Debug, Clone, Default, Serialize, Deserialize)]
17pub struct ApiErrorDetails {
18    /// Request path the server attached to the error, when supplied.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub path: Option<String>,
21    /// Server-side timestamp (ISO 8601), when supplied.
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub timestamp: Option<String>,
24    /// Raw `details` field from the JSON envelope, when supplied.
25    #[serde(skip_serializing_if = "Option::is_none")]
26    pub details: Option<serde_json::Value>,
27}
28
29/// Every error surfaced by the SDK.
30///
31/// Match on the variant for branching logic, and read [`ReroutError::code`]
32/// for the stable string identifier (e.g. `bad_target_url`, `rate_limited`).
33#[derive(Debug, thiserror::Error)]
34pub enum ReroutError {
35    /// The server replied with a non-2xx status.
36    ///
37    /// `code` is the stable identifier — preferring the API's `code` when one
38    /// was provided in the JSON body, falling back to a synthetic
39    /// status-derived code otherwise.
40    #[error("Rerout API error ({code}, HTTP {status}): {message}")]
41    Api {
42        /// Stable error identifier (e.g. `bad_target_url`, `rate_limited`).
43        code: String,
44        /// HTTP status code returned by the server.
45        status: u16,
46        /// Human-readable error message.
47        message: String,
48        /// Extra fields (`path`, `timestamp`, `details`) when supplied.
49        ///
50        /// Boxed to keep the [`ReroutError`] enum compact — the optional
51        /// diagnostics rarely matter on the hot path.
52        extra: Box<ApiErrorDetails>,
53    },
54
55    /// The request never reached the server (DNS, TLS, connection reset, ...).
56    #[error("Rerout network error: {message}")]
57    Network {
58        /// Reason from the transport layer.
59        message: String,
60    },
61
62    /// The request was aborted after exceeding the client timeout.
63    #[error("Rerout request timed out: {message}")]
64    Timeout {
65        /// Reason from the transport layer.
66        message: String,
67    },
68
69    /// The server returned 2xx but the body could not be decoded as JSON.
70    #[error("Rerout returned an unexpected response (HTTP {status}): {message}")]
71    Unexpected {
72        /// HTTP status code (always 2xx here).
73        status: u16,
74        /// Reason this body could not be interpreted.
75        message: String,
76    },
77
78    /// A JSON body failed to decode against the expected schema.
79    #[error("Rerout response decode error: {message}")]
80    Decode {
81        /// Reason this body could not be deserialized.
82        message: String,
83    },
84
85    /// The client was misconfigured (missing API key, invalid base URL, ...).
86    ///
87    /// `code` mirrors the synthetic codes the API would otherwise use
88    /// (e.g. `missing_api_key`, `invalid_base_url`).
89    #[error("Rerout configuration error ({code}): {message}")]
90    Config {
91        /// Stable synthetic code for the configuration failure.
92        code: String,
93        /// Human-readable explanation.
94        message: String,
95    },
96}
97
98impl ReroutError {
99    /// The stable string error code — either from the API or a synthetic one.
100    ///
101    /// Use this for branching logic (`if err.code() == "rate_limited" { ... }`)
102    /// rather than parsing the message.
103    pub fn code(&self) -> &str {
104        match self {
105            ReroutError::Api { code, .. } => code,
106            ReroutError::Network { .. } => "network_error",
107            ReroutError::Timeout { .. } => "timeout",
108            ReroutError::Unexpected { .. } => "unexpected_response",
109            ReroutError::Decode { .. } => "decode_error",
110            ReroutError::Config { code, .. } => code,
111        }
112    }
113
114    /// HTTP status code, or `0` when no response reached the client.
115    pub fn status(&self) -> u16 {
116        match self {
117            ReroutError::Api { status, .. } | ReroutError::Unexpected { status, .. } => *status,
118            ReroutError::Network { .. }
119            | ReroutError::Timeout { .. }
120            | ReroutError::Decode { .. }
121            | ReroutError::Config { .. } => 0,
122        }
123    }
124
125    /// Human-readable message — equivalent to `format!("{err}")`.
126    pub fn message(&self) -> String {
127        match self {
128            ReroutError::Api { message, .. }
129            | ReroutError::Network { message, .. }
130            | ReroutError::Timeout { message, .. }
131            | ReroutError::Unexpected { message, .. }
132            | ReroutError::Decode { message, .. }
133            | ReroutError::Config { message, .. } => message.clone(),
134        }
135    }
136
137    /// `true` when this is an HTTP 429 (`rate_limited`) response.
138    ///
139    /// Caller should back off and retry with jitter.
140    pub fn is_rate_limited(&self) -> bool {
141        self.status() == 429
142    }
143
144    /// `true` when this is an HTTP 5xx response — a server-side issue.
145    pub fn is_server_error(&self) -> bool {
146        let s = self.status();
147        (500..600).contains(&s)
148    }
149
150    /// `path` from the API error envelope, when supplied.
151    pub fn path(&self) -> Option<&str> {
152        match self {
153            ReroutError::Api { extra, .. } => extra.path.as_deref(),
154            _ => None,
155        }
156    }
157
158    /// `timestamp` from the API error envelope, when supplied.
159    pub fn timestamp(&self) -> Option<&str> {
160        match self {
161            ReroutError::Api { extra, .. } => extra.timestamp.as_deref(),
162            _ => None,
163        }
164    }
165
166    /// Optional `details` field from the API error envelope.
167    pub fn details(&self) -> Option<&serde_json::Value> {
168        match self {
169            ReroutError::Api { extra, .. } => extra.details.as_ref(),
170            _ => None,
171        }
172    }
173}
174
175/// Map an HTTP status to a synthetic error code, used when the server didn't
176/// return a parseable JSON body.
177pub(crate) fn synthetic_code_for_status(status: u16) -> &'static str {
178    match status {
179        401 => "unauthorized",
180        403 => "forbidden",
181        404 => "not_found",
182        429 => "rate_limited",
183        500..=599 => "server_error",
184        _ => "client_error",
185    }
186}
187
188/// Build a [`ReroutError::Api`] from an HTTP response body. Best-effort JSON
189/// parse — falls back to a synthetic code when the body is empty or not JSON.
190pub(crate) fn build_api_error(status: u16, body: &str) -> ReroutError {
191    if body.is_empty() {
192        return ReroutError::Api {
193            code: synthetic_code_for_status(status).to_string(),
194            status,
195            message: format!("Rerout returned HTTP {status} with no body."),
196            extra: Box::default(),
197        };
198    }
199    match serde_json::from_str::<serde_json::Value>(body) {
200        Ok(serde_json::Value::Object(map)) => {
201            let code = map
202                .get("code")
203                .and_then(|v| v.as_str())
204                .map(str::to_string)
205                .unwrap_or_else(|| synthetic_code_for_status(status).to_string());
206            let message = map
207                .get("message")
208                .and_then(|v| v.as_str())
209                .map(str::to_string)
210                .unwrap_or_else(|| format!("Rerout returned HTTP {status}."));
211            let path = map.get("path").and_then(|v| v.as_str()).map(str::to_string);
212            let timestamp = map
213                .get("timestamp")
214                .and_then(|v| v.as_str())
215                .map(str::to_string);
216            let details = map.get("details").cloned();
217            ReroutError::Api {
218                code,
219                status,
220                message,
221                extra: Box::new(ApiErrorDetails {
222                    path,
223                    timestamp,
224                    details,
225                }),
226            }
227        }
228        _ => ReroutError::Api {
229            code: synthetic_code_for_status(status).to_string(),
230            status,
231            message: format!("Rerout returned HTTP {status} (non-JSON body)."),
232            extra: Box::default(),
233        },
234    }
235}