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 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 (in seconds, capped at 10s by the retry loop).
86    /// `limit_type` is `"daily"` or `"burst"`, read from the rate the API names in the body's `detail`; an `X-RateLimit-Type` header takes precedence when the server sets one, and it is `None` when neither names a limit.
87    #[error("tango: rate limit exceeded (status 429); retry after {retry_after}s")]
88    RateLimit {
89        /// Seconds to wait before retrying, as suggested by the server.
90        retry_after: u32,
91        /// Which limit was hit (`"daily"` or `"burst"`), when the response says.
92        limit_type: Option<String>,
93        /// The parsed error body, when one was returned.
94        response: Option<ErrorBody>,
95    },
96
97    /// A request exceeded its configured timeout. No HTTP status was received.
98    #[error("tango: request timed out after {timeout:?}")]
99    Timeout {
100        /// The timeout duration that elapsed.
101        timeout: Duration,
102    },
103
104    /// Any other non-2xx HTTP response.
105    #[error("tango: API error (status {status}): {message}")]
106    Api {
107        /// HTTP status code.
108        status: u16,
109        /// Human-readable message.
110        message: String,
111        /// The parsed error body, when one was returned.
112        response: Option<ErrorBody>,
113    },
114
115    /// An HTTP transport-level failure (DNS, connection refused, TLS, etc.).
116    /// `Send`-safe `reqwest::Error` is preserved for `Error::source` traversal.
117    #[error("tango: HTTP transport error")]
118    Transport(#[from] reqwest::Error),
119
120    /// A response body failed JSON decoding.
121    #[error("tango: failed to decode response body")]
122    Decode(#[from] serde_json::Error),
123
124    /// The SDK failed to build a request (URL parse, bad input, etc.).
125    /// Strictly internal — callers should not see this in practice.
126    #[error("tango: failed to build request: {0}")]
127    Build(String),
128}
129
130impl Error {
131    /// Returns the HTTP status code for variants that carry one.
132    #[must_use]
133    pub fn status(&self) -> Option<u16> {
134        match self {
135            Self::Auth { .. } => Some(401),
136            Self::NotFound { .. } => Some(404),
137            Self::Validation { .. } | Self::ExternalLink { .. } => Some(400),
138            Self::RateLimit { .. } => Some(429),
139            Self::Api { status, .. } => Some(*status),
140            Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None,
141        }
142    }
143
144    /// Returns `true` if this error is one the SDK's retry loop will recover from.
145    ///
146    /// Retryable:
147    /// - `RateLimit` (429)
148    /// - `Timeout` (transport-level deadline)
149    /// - `Api { status: 5xx }` (server-side faults)
150    /// - `Api { status: 408 }` (request timeout reported by server)
151    /// - `Transport` (DNS/TCP/TLS-level failures)
152    ///
153    /// Not retryable:
154    /// - `Auth` / `NotFound` / `Validation` (client-side; retry won't change the answer)
155    /// - `Api { status: other 4xx }`
156    /// - `Decode` / `Build` (programmer/server error)
157    #[must_use]
158    pub fn is_retryable(&self) -> bool {
159        match self {
160            Self::RateLimit { .. } | Self::Timeout { .. } | Self::Transport(_) => true,
161            Self::Api { status, .. } => *status == 408 || (500..600).contains(status),
162            Self::Auth { .. }
163            | Self::NotFound { .. }
164            | Self::Validation { .. }
165            | Self::ExternalLink { .. }
166            | Self::Decode(_)
167            | Self::Build(_) => false,
168        }
169    }
170
171    /// Returns the parsed error body when one was returned by the server.
172    #[must_use]
173    pub fn response(&self) -> Option<&ErrorBody> {
174        match self {
175            Self::Auth { response, .. }
176            | Self::NotFound { response, .. }
177            | Self::Validation { response, .. }
178            | Self::ExternalLink { response, .. }
179            | Self::RateLimit { response, .. }
180            | Self::Api { response, .. } => response.as_ref(),
181            Self::Timeout { .. } | Self::Transport(_) | Self::Decode(_) | Self::Build(_) => None,
182        }
183    }
184}
185
186/// Alias for the SDK's standard `Result` type.
187pub type Result<T> = std::result::Result<T, Error>;
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn status_codes() {
195        assert_eq!(Error::Auth { response: None }.status(), Some(401));
196        assert_eq!(Error::NotFound { response: None }.status(), Some(404));
197        assert_eq!(
198            Error::Validation {
199                message: "x".into(),
200                response: None
201            }
202            .status(),
203            Some(400)
204        );
205        assert_eq!(
206            Error::RateLimit {
207                retry_after: 1,
208                limit_type: None,
209                response: None
210            }
211            .status(),
212            Some(429)
213        );
214        assert_eq!(
215            Error::Api {
216                status: 502,
217                message: "x".into(),
218                response: None
219            }
220            .status(),
221            Some(502)
222        );
223        assert_eq!(
224            Error::Timeout {
225                timeout: Duration::from_secs(1)
226            }
227            .status(),
228            None
229        );
230    }
231
232    #[test]
233    fn retry_decisions() {
234        assert!(Error::RateLimit {
235            retry_after: 0,
236            limit_type: None,
237            response: None
238        }
239        .is_retryable());
240        assert!(Error::Timeout {
241            timeout: Duration::from_secs(1)
242        }
243        .is_retryable());
244        assert!(Error::Api {
245            status: 502,
246            message: "x".into(),
247            response: None
248        }
249        .is_retryable());
250        assert!(Error::Api {
251            status: 408,
252            message: "x".into(),
253            response: None
254        }
255        .is_retryable());
256
257        assert!(!Error::Auth { response: None }.is_retryable());
258        assert!(!Error::NotFound { response: None }.is_retryable());
259        assert!(!Error::Validation {
260            message: "x".into(),
261            response: None
262        }
263        .is_retryable());
264        assert!(!Error::Api {
265            status: 418,
266            message: "x".into(),
267            response: None
268        }
269        .is_retryable());
270        assert!(!Error::ExternalLink {
271            url: "https://example.test/doc".into(),
272            response: None
273        }
274        .is_retryable());
275        assert!(!Error::Build("x".into()).is_retryable());
276    }
277}