1use serde::Deserialize;
4
5pub type Result<T> = std::result::Result<T, Error>;
7
8#[derive(Debug, Clone, Deserialize)]
10#[serde(rename_all = "camelCase")]
11pub struct ErrorInfo {
12 pub code: i64,
14 #[serde(default)]
16 pub message: String,
17 #[serde(default)]
19 pub status_code: u16,
20 #[serde(default)]
22 pub href: Option<String>,
23}
24
25#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum Error {
31 #[error("HTTP transport error: {0}")]
33 Transport(#[from] reqwest::Error),
34 #[error("failed to decode response: {0}")]
36 Decode(String),
37 #[error("invalid request: {0}")]
40 InvalidRequest(String),
41 #[error("Ably API error {}: {message}", .info.code, message = .info.message)]
43 Api {
44 status: u16,
46 info: ErrorInfo,
48 },
49}
50
51#[derive(Deserialize)]
52struct Envelope {
53 error: ErrorInfo,
54}
55
56impl Error {
57 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 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 pub fn info(&self) -> Option<&ErrorInfo> {
83 match self {
84 Error::Api { info, .. } => Some(info),
85 _ => None,
86 }
87 }
88
89 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 pub fn is_not_found(&self) -> bool {
104 matches!(self, Error::Api { info, .. } if info.code == 40400)
105 }
106
107 pub fn is_rejected_by_rule(&self) -> bool {
109 matches!(self, Error::Api { info, .. } if info.code == 42211)
110 }
111
112 pub fn is_rejected_by_moderation(&self) -> bool {
114 matches!(self, Error::Api { info, .. } if info.code == 42213)
115 }
116
117 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 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 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}