Skip to main content

ably_chat/
error.rs

1//! The crate's single public error type and its `Result` alias (ADR-0008).
2
3use serde::Deserialize;
4
5/// The result type returned by every fallible operation in this crate.
6pub type Result<T> = std::result::Result<T, Error>;
7
8/// The Ably error envelope carried by a non-2xx API response.
9#[derive(Debug, Clone, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ErrorInfo {
12    /// Ably-specific error code (e.g. `40400` for message-not-found).
13    pub code: i64,
14    /// Human-readable error message.
15    #[serde(default)]
16    pub message: String,
17    /// The HTTP status code reported inside the envelope.
18    #[serde(default)]
19    pub status_code: u16,
20    /// URL to Ably documentation for this error code, if provided.
21    #[serde(default)]
22    pub href: Option<String>,
23}
24
25/// The single error type surfaced by this crate.
26///
27/// `#[non_exhaustive]` so new variants can be added without a breaking change.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum Error {
31    /// An HTTP transport failure from `reqwest` (connection, timeout, TLS, ...).
32    #[error("HTTP transport error: {0}")]
33    Transport(#[from] reqwest::Error),
34    /// The response body could not be decoded into the expected type.
35    #[error("failed to decode response: {0}")]
36    Decode(String),
37    /// A request was rejected by client-side validation before being sent
38    /// (e.g. a `distinct`/`multiple` reaction delete missing its `name`).
39    #[error("invalid request: {0}")]
40    InvalidRequest(String),
41    /// A non-2xx response carrying the Ably error envelope.
42    #[error("Ably API error {}: {message}", .info.code, message = .info.message)]
43    Api {
44        /// The HTTP status code of the response.
45        status: u16,
46        /// The parsed Ably error envelope.
47        info: ErrorInfo,
48    },
49}
50
51#[derive(Deserialize)]
52struct Envelope {
53    error: ErrorInfo,
54}
55
56impl Error {
57    /// Builds an [`Error::Api`] from a non-2xx status and response body,
58    /// parsing the Ably envelope when present and otherwise preserving the raw
59    /// body as the message.
60    pub(crate) fn from_api_body(status: u16, body: &[u8]) -> Self {
61        let info = serde_json::from_slice::<Envelope>(body)
62            .map(|e| e.error)
63            .unwrap_or_else(|_| ErrorInfo {
64                code: 0,
65                message: String::from_utf8_lossy(body).into_owned(),
66                status_code: status,
67                href: None,
68            });
69        Error::Api { status, info }
70    }
71
72    /// The HTTP status code associated with this error, if any.
73    pub fn status(&self) -> Option<u16> {
74        match self {
75            Error::Api { status, .. } => Some(*status),
76            Error::Transport(e) => e.status().map(|s| s.as_u16()),
77            Error::Decode(_) | Error::InvalidRequest(_) => None,
78        }
79    }
80
81    /// The parsed Ably error envelope, if this is an API error.
82    pub fn info(&self) -> Option<&ErrorInfo> {
83        match self {
84            Error::Api { info, .. } => Some(info),
85            _ => None,
86        }
87    }
88
89    /// Whether retrying the request that produced this error may succeed.
90    ///
91    /// Transport timeouts/connect failures, HTTP `429`, and `5xx` are retryable;
92    /// decode failures and other API errors are not. The dispatch layer only
93    /// actually retries requests that are also idempotency-safe (ADR-0006).
94    pub fn is_retryable(&self) -> bool {
95        match self {
96            Error::Transport(e) => e.is_timeout() || e.is_connect(),
97            Error::Api { status, .. } => *status == 429 || (500..=599).contains(status),
98            Error::Decode(_) | Error::InvalidRequest(_) => false,
99        }
100    }
101
102    /// Ably code `40400`: the message was not found.
103    pub fn is_not_found(&self) -> bool {
104        matches!(self, Error::Api { info, .. } if info.code == 40400)
105    }
106
107    /// Ably code `42211`: the operation was rejected by a room rule.
108    pub fn is_rejected_by_rule(&self) -> bool {
109        matches!(self, Error::Api { info, .. } if info.code == 42211)
110    }
111
112    /// Ably code `42213`: the operation was rejected by moderation.
113    pub fn is_rejected_by_moderation(&self) -> bool {
114        matches!(self, Error::Api { info, .. } if info.code == 42213)
115    }
116
117    /// Whether this is an Ably token error (HTTP `401`, code in `[40140, 40150)`)
118    /// that a configured `TokenProvider` should renew on (spec RSA4b).
119    pub fn is_token_error(&self) -> bool {
120        matches!(self, Error::Api { status: 401, info } if (40140..40150).contains(&info.code))
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn parses_ably_envelope() {
130        let body = r#"{"error":{"code":40400,"message":"not found","statusCode":404}}"#;
131        let e = Error::from_api_body(404, body.as_bytes());
132        assert_eq!(e.status(), Some(404));
133        assert!(e.is_not_found());
134        assert!(!e.is_retryable());
135    }
136
137    #[test]
138    fn server_error_is_retryable() {
139        let e = Error::from_api_body(503, b"{}");
140        assert!(e.is_retryable());
141    }
142
143    #[test]
144    fn rate_limit_is_retryable() {
145        let e = Error::from_api_body(429, b"{}");
146        assert!(e.is_retryable());
147    }
148
149    #[test]
150    fn non_json_body_is_preserved_as_message() {
151        let e = Error::from_api_body(500, b"upstream boom");
152        assert_eq!(e.status(), Some(500));
153        assert!(e.is_retryable());
154        assert_eq!(e.info().map(|i| i.message.as_str()), Some("upstream boom"));
155    }
156
157    #[test]
158    fn token_error_range() {
159        // 401 + code in [40140,40150) -> token error (renewable).
160        for code in [40140, 40141, 40142, 40143, 40149] {
161            let body = format!(r#"{{"error":{{"code":{code},"message":"x","statusCode":401}}}}"#);
162            assert!(
163                Error::from_api_body(401, body.as_bytes()).is_token_error(),
164                "code {code}"
165            );
166        }
167        // Not a token error: wrong status, or code outside the range.
168        assert!(
169            !Error::from_api_body(403, br#"{"error":{"code":40140,"statusCode":403}}"#)
170                .is_token_error()
171        );
172        assert!(
173            !Error::from_api_body(401, br#"{"error":{"code":40150,"statusCode":401}}"#)
174                .is_token_error()
175        );
176        assert!(
177            !Error::from_api_body(401, br#"{"error":{"code":40400,"statusCode":401}}"#)
178                .is_token_error()
179        );
180    }
181
182    #[test]
183    fn code_predicates() {
184        assert!(
185            Error::from_api_body(
186                422,
187                br#"{"error":{"code":42211,"message":"x","statusCode":422}}"#
188            )
189            .is_rejected_by_rule()
190        );
191        assert!(
192            Error::from_api_body(
193                422,
194                br#"{"error":{"code":42213,"message":"x","statusCode":422}}"#
195            )
196            .is_rejected_by_moderation()
197        );
198    }
199}