tango/error.rs
1//! Error types returned by the SDK.
2//!
3//! Every fallible call returns [`Result<T, Error>`](crate::Result). The
4//! [`Error`] enum carries enough structure that callers can dispatch on the
5//! variant (e.g. retry on `RateLimit`, surface `Validation.message` to a user)
6//! without parsing strings. For programmatic dispatch by HTTP status,
7//! [`Error::status`] returns `Some(code)` for variants that have one.
8//!
9//! The retry loop in [`crate::transport`] calls [`Error::is_retryable`] to
10//! decide whether to wait and re-issue the request.
11
12use std::time::Duration;
13
14/// A parsed Tango API error body. Carried on the API-error variants so callers
15/// can introspect the server's structured error response without re-parsing.
16///
17/// The Tango API surfaces error bodies in a handful of shapes:
18///
19/// - `{"detail": "..."}` (DRF envelope)
20/// - `{"message": "..."}` or `{"error": "..."}` (generic envelope)
21/// - `{"<field>": ["..."]}` (DRF field-error array)
22///
23/// The raw decoded JSON is preserved in [`ErrorBody::raw`] for callers that need to inspect a custom shape.
24/// [`ErrorBody::message`] is the server's own explanation: the first non-empty string among the envelope keys `error`, `message` and `detail`, then the same keys inside a `detail` object (a plan gate's structured 403).
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ErrorBody {
27 /// The human-readable message the SDK extracted from the body, when one
28 /// was available. Empty when the body had no obvious message slot.
29 pub message: String,
30
31 /// The raw decoded JSON value as a string (re-serialized to be stable).
32 /// `None` when the response had no body or the body was not JSON.
33 pub raw: Option<serde_json::Value>,
34}
35
36/// The error type returned by all fallible SDK calls.
37///
38/// Use [`Error::is_retryable`] in custom retry policies and [`Error::status`]
39/// for programmatic dispatch by HTTP status code.
40#[derive(Debug, thiserror::Error)]
41#[non_exhaustive]
42pub enum Error {
43 /// HTTP 401 — the API key was missing, malformed, or rejected.
44 #[error("tango: authentication failed (status 401)")]
45 Auth {
46 /// The parsed error body, when one was returned.
47 response: Option<ErrorBody>,
48 },
49
50 /// HTTP 404 — the requested resource does not exist.
51 #[error("tango: resource not found (status 404)")]
52 NotFound {
53 /// The parsed error body, when one was returned.
54 response: Option<ErrorBody>,
55 },
56
57 /// HTTP 400 — the request was syntactically valid but the server rejected
58 /// its parameters. The `message` is the SDK's best guess at a human-readable
59 /// reason; the SDK walks envelope keys (`detail` / `message` / `error`)
60 /// first, then sorted-key iteration over the remaining keys, preferring
61 /// array values over strings.
62 #[error("tango: invalid request (status 400): {message}")]
63 Validation {
64 /// Human-readable validation message.
65 message: String,
66 /// The parsed error body, when one was returned.
67 response: Option<ErrorBody>,
68 },
69
70 /// HTTP 400 from an attachment-download route whose entry is an outbound
71 /// link rather than a stored document. There is nothing to download; `url`
72 /// is where the link points.
73 ///
74 /// Returned by [`Client::get_ebuy_attachment_url`](crate::Client::get_ebuy_attachment_url).
75 #[error("tango: attachment is an external link, not a stored document (status 400): {url}")]
76 ExternalLink {
77 /// The link's target URL.
78 url: String,
79 /// The parsed error body, when one was returned.
80 response: Option<ErrorBody>,
81 },
82
83 /// HTTP 429 — the caller exceeded a rate limit.
84 ///
85 /// `retry_after` is populated from the `Retry-After` header when present
86 /// (in seconds, capped at 10s by the retry loop). `limit_type` is
87 /// populated from `X-RateLimit-Type` when the server sets it
88 /// (e.g. `"minute"`, `"hour"`, `"day"`).
89 #[error("tango: rate limit exceeded (status 429); retry after {retry_after}s")]
90 RateLimit {
91 /// Seconds to wait before retrying, as suggested by the server.
92 retry_after: u32,
93 /// The rate-limit bucket the server reported, when present.
94 limit_type: Option<String>,
95 /// The parsed error body, when one was returned.
96 response: Option<ErrorBody>,
97 },
98
99 /// A request exceeded its configured timeout. No HTTP status was received.
100 #[error("tango: request timed out after {timeout:?}")]
101 Timeout {
102 /// The timeout duration that elapsed.
103 timeout: Duration,
104 },
105
106 /// Any other non-2xx HTTP response.
107 #[error("tango: API error (status {status}): {message}")]
108 Api {
109 /// HTTP status code.
110 status: u16,
111 /// Human-readable message.
112 message: String,
113 /// The parsed error body, when one was returned.
114 response: Option<ErrorBody>,
115 },
116
117 /// An HTTP transport-level failure (DNS, connection refused, TLS, etc.).
118 /// `Send`-safe `reqwest::Error` is preserved for `Error::source` traversal.
119 #[error("tango: HTTP transport error")]
120 Transport(#[from] reqwest::Error),
121
122 /// A response body failed JSON decoding.
123 #[error("tango: failed to decode response body")]
124 Decode(#[from] serde_json::Error),
125
126 /// The SDK failed to build a request (URL parse, bad input, etc.).
127 /// Strictly internal — callers should not see this in practice.
128 #[error("tango: failed to build request: {0}")]
129 Build(String),
130}
131
132impl Error {
133 /// Returns the HTTP status code for variants that carry one.
134 #[must_use]
135 pub fn status(&self) -> Option<u16> {
136 match self {
137 Self::Auth { .. } => Some(401),
138 Self::NotFound { .. } => Some(404),
139 Self::Validation { .. } | Self::ExternalLink { .. } => Some(400),
140 Self::RateLimit { .. } => Some(429),
141 Self::Api { status, .. } => Some(*status),
142 Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None,
143 }
144 }
145
146 /// Returns `true` if this error is one the SDK's retry loop will recover from.
147 ///
148 /// Retryable:
149 /// - `RateLimit` (429)
150 /// - `Timeout` (transport-level deadline)
151 /// - `Api { status: 5xx }` (server-side faults)
152 /// - `Api { status: 408 }` (request timeout reported by server)
153 /// - `Transport` (DNS/TCP/TLS-level failures)
154 ///
155 /// Not retryable:
156 /// - `Auth` / `NotFound` / `Validation` (client-side; retry won't change the answer)
157 /// - `Api { status: other 4xx }`
158 /// - `Decode` / `Build` (programmer/server error)
159 #[must_use]
160 pub fn is_retryable(&self) -> bool {
161 match self {
162 Self::RateLimit { .. } | Self::Timeout { .. } | Self::Transport(_) => true,
163 Self::Api { status, .. } => *status == 408 || (500..600).contains(status),
164 Self::Auth { .. }
165 | Self::NotFound { .. }
166 | Self::Validation { .. }
167 | Self::ExternalLink { .. }
168 | Self::Decode(_)
169 | Self::Build(_) => false,
170 }
171 }
172
173 /// Returns the parsed error body when one was returned by the server.
174 #[must_use]
175 pub fn response(&self) -> Option<&ErrorBody> {
176 match self {
177 Self::Auth { response, .. }
178 | Self::NotFound { response, .. }
179 | Self::Validation { response, .. }
180 | Self::ExternalLink { response, .. }
181 | Self::RateLimit { response, .. }
182 | Self::Api { response, .. } => response.as_ref(),
183 Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None,
184 }
185 }
186}
187
188/// Alias for the SDK's standard `Result` type.
189pub type Result<T> = std::result::Result<T, Error>;
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn status_codes() {
197 assert_eq!(Error::Auth { response: None }.status(), Some(401));
198 assert_eq!(Error::NotFound { response: None }.status(), Some(404));
199 assert_eq!(
200 Error::Validation {
201 message: "x".into(),
202 response: None
203 }
204 .status(),
205 Some(400)
206 );
207 assert_eq!(
208 Error::RateLimit {
209 retry_after: 1,
210 limit_type: None,
211 response: None
212 }
213 .status(),
214 Some(429)
215 );
216 assert_eq!(
217 Error::Api {
218 status: 502,
219 message: "x".into(),
220 response: None
221 }
222 .status(),
223 Some(502)
224 );
225 assert_eq!(
226 Error::Timeout {
227 timeout: Duration::from_secs(1)
228 }
229 .status(),
230 None
231 );
232 }
233
234 #[test]
235 fn retry_decisions() {
236 assert!(Error::RateLimit {
237 retry_after: 0,
238 limit_type: None,
239 response: None
240 }
241 .is_retryable());
242 assert!(Error::Timeout {
243 timeout: Duration::from_secs(1)
244 }
245 .is_retryable());
246 assert!(Error::Api {
247 status: 502,
248 message: "x".into(),
249 response: None
250 }
251 .is_retryable());
252 assert!(Error::Api {
253 status: 408,
254 message: "x".into(),
255 response: None
256 }
257 .is_retryable());
258
259 assert!(!Error::Auth { response: None }.is_retryable());
260 assert!(!Error::NotFound { response: None }.is_retryable());
261 assert!(!Error::Validation {
262 message: "x".into(),
263 response: None
264 }
265 .is_retryable());
266 assert!(!Error::Api {
267 status: 418,
268 message: "x".into(),
269 response: None
270 }
271 .is_retryable());
272 assert!(!Error::ExternalLink {
273 url: "https://example.test/doc".into(),
274 response: None
275 }
276 .is_retryable());
277 assert!(!Error::Build("x".into()).is_retryable());
278 }
279}