Skip to main content

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