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}