1use std::fmt;
8use std::time::Duration;
9
10use serde_json::Value;
11
12pub type Result<T> = std::result::Result<T, Error>;
14
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum ErrorKind {
23 Generic,
25 Configuration,
27 Authentication,
29 Permission,
31 RateLimit,
33 Validation,
35 NotFound,
37 Conflict,
39 Timeout,
41}
42
43impl ErrorKind {
44 pub fn from_status(status: u16) -> Self {
50 match status {
51 400 => ErrorKind::Validation,
52 401 => ErrorKind::Authentication,
53 403 => ErrorKind::Permission,
54 402 | 404 => ErrorKind::NotFound,
55 409 => ErrorKind::Conflict,
56 429 => ErrorKind::RateLimit,
57 _ => ErrorKind::Generic,
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
64#[non_exhaustive]
65pub struct ApiError {
66 pub message: String,
68 pub kind: ErrorKind,
70 pub code: Option<String>,
72 pub status: u16,
74 pub request_id: Option<String>,
76 pub details: Option<Value>,
78 pub method: String,
80 pub path: String,
82 pub retry_after: Option<Duration>,
85}
86
87impl fmt::Display for ApiError {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 let msg = if self.message.is_empty() {
90 "request failed"
91 } else {
92 &self.message
93 };
94 if self.method.is_empty() {
95 write!(f, "videosdk: {msg} (status {})", self.status)
96 } else {
97 write!(
98 f,
99 "videosdk: {msg} ({} {}, status {})",
100 self.method, self.path, self.status
101 )
102 }
103 }
104}
105
106#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum Error {
110 #[error("{0}")]
115 Api(Box<ApiError>),
116
117 #[error("videosdk: {0}")]
119 Configuration(String),
120
121 #[error("videosdk: {0}")]
123 Authentication(String),
124
125 #[error("videosdk: {0}")]
127 Validation(String),
128
129 #[error("videosdk: {0}")]
132 NotFound(String),
133
134 #[error("videosdk: {message}")]
140 Operation {
141 kind: ErrorKind,
143 code: String,
145 message: String,
147 },
148
149 #[error("videosdk: request timed out after {}ms ({method} {path})", .elapsed.as_millis())]
151 Timeout {
152 method: String,
154 path: String,
156 elapsed: Duration,
158 },
159
160 #[error("videosdk: network request failed ({method} {path}): {source}")]
162 Network {
163 method: String,
165 path: String,
167 #[source]
169 source: reqwest::Error,
170 },
171
172 #[error("videosdk: failed to decode response ({context}): {source}")]
174 Decode {
175 context: String,
177 #[source]
179 source: serde_json::Error,
180 },
181
182 #[error("videosdk: failed to encode request body: {source}")]
184 Encode {
185 #[source]
187 source: serde_json::Error,
188 },
189}
190
191impl Error {
192 pub fn kind(&self) -> ErrorKind {
194 match self {
195 Error::Api(e) => e.kind,
196 Error::Configuration(_) => ErrorKind::Configuration,
197 Error::Authentication(_) => ErrorKind::Authentication,
198 Error::Validation(_) => ErrorKind::Validation,
199 Error::NotFound(_) => ErrorKind::NotFound,
200 Error::Operation { kind, .. } => *kind,
201 Error::Timeout { .. } => ErrorKind::Timeout,
202 Error::Network { .. } | Error::Decode { .. } | Error::Encode { .. } => {
203 ErrorKind::Generic
204 }
205 }
206 }
207
208 pub fn as_api(&self) -> Option<&ApiError> {
210 match self {
211 Error::Api(e) => Some(e.as_ref()),
212 _ => None,
213 }
214 }
215
216 pub(crate) fn api(error: ApiError) -> Self {
217 Error::Api(Box::new(error))
218 }
219
220 pub fn status(&self) -> Option<u16> {
222 self.as_api().map(|e| e.status)
223 }
224
225 pub fn code(&self) -> Option<&str> {
227 match self {
228 Error::Api(e) => e.code.as_deref(),
229 Error::Operation { code, .. } => Some(code),
230 _ => None,
231 }
232 }
233
234 pub(crate) fn operation(
235 kind: ErrorKind,
236 code: impl Into<String>,
237 message: impl Into<String>,
238 ) -> Self {
239 Error::Operation {
240 kind,
241 code: code.into(),
242 message: message.into(),
243 }
244 }
245
246 pub fn request_id(&self) -> Option<&str> {
248 self.as_api().and_then(|e| e.request_id.as_deref())
249 }
250
251 pub fn retry_after(&self) -> Option<Duration> {
253 self.as_api().and_then(|e| e.retry_after)
254 }
255
256 pub fn is_not_found(&self) -> bool {
258 self.kind() == ErrorKind::NotFound
259 }
260
261 pub fn is_authentication(&self) -> bool {
263 self.kind() == ErrorKind::Authentication
264 }
265
266 pub fn is_permission(&self) -> bool {
268 self.kind() == ErrorKind::Permission
269 }
270
271 pub fn is_validation(&self) -> bool {
273 self.kind() == ErrorKind::Validation
274 }
275
276 pub fn is_rate_limit(&self) -> bool {
278 self.kind() == ErrorKind::RateLimit
279 }
280
281 pub fn is_conflict(&self) -> bool {
283 self.kind() == ErrorKind::Conflict
284 }
285
286 pub fn is_timeout(&self) -> bool {
288 self.kind() == ErrorKind::Timeout
289 }
290
291 pub(crate) fn config(msg: impl Into<String>) -> Self {
292 Error::Configuration(msg.into())
293 }
294
295 pub(crate) fn auth(msg: impl Into<String>) -> Self {
296 Error::Authentication(msg.into())
297 }
298
299 pub(crate) fn validation(msg: impl Into<String>) -> Self {
300 Error::Validation(msg.into())
301 }
302
303 pub(crate) fn not_found(msg: impl Into<String>) -> Self {
304 Error::NotFound(msg.into())
305 }
306
307 pub(crate) fn decode(context: impl Into<String>, source: serde_json::Error) -> Self {
308 Error::Decode {
309 context: context.into(),
310 source,
311 }
312 }
313}
314
315pub(crate) fn normalize_api_error(
318 status: u16,
319 body: Option<Value>,
320 method: &str,
321 path: &str,
322 request_id: Option<String>,
323 retry_after: Option<Duration>,
324) -> ApiError {
325 let (message, code) = extract_message_and_code(body.as_ref());
326 ApiError {
327 message: message.unwrap_or_else(|| format!("request failed with status {status}")),
328 kind: ErrorKind::from_status(status),
329 code,
330 status,
331 request_id,
332 details: body,
333 method: method.to_string(),
334 path: path.to_string(),
335 retry_after,
336 }
337}
338
339fn extract_message_and_code(body: Option<&Value>) -> (Option<String>, Option<String>) {
343 let Some(body) = body else {
344 return (None, None);
345 };
346 match body {
347 Value::Null => (None, None),
348 Value::String(s) => (Some(s.clone()), None),
349 Value::Object(map) => {
350 let pick = |keys: &[&str]| -> Option<String> {
351 keys.iter().find_map(|k| {
352 map.get(*k)
353 .and_then(Value::as_str)
354 .filter(|s| !s.is_empty())
355 .map(str::to_string)
356 })
357 };
358 (
359 pick(&["message", "error", "msg", "statusMessage"]),
360 pick(&["code", "errorCode"]),
361 )
362 }
363 other => (Some(other.to_string()), None),
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use serde_json::json;
371
372 #[test]
373 fn status_maps_to_kind() {
374 assert_eq!(ErrorKind::from_status(400), ErrorKind::Validation);
375 assert_eq!(ErrorKind::from_status(401), ErrorKind::Authentication);
376 assert_eq!(ErrorKind::from_status(403), ErrorKind::Permission);
377 assert_eq!(ErrorKind::from_status(409), ErrorKind::Conflict);
378 assert_eq!(ErrorKind::from_status(429), ErrorKind::RateLimit);
379 assert_eq!(ErrorKind::from_status(500), ErrorKind::Generic);
380 assert_eq!(ErrorKind::from_status(418), ErrorKind::Generic);
381 }
382
383 #[test]
384 fn both_402_and_404_are_not_found() {
385 assert_eq!(ErrorKind::from_status(402), ErrorKind::NotFound);
387 assert_eq!(ErrorKind::from_status(404), ErrorKind::NotFound);
388 }
389
390 #[test]
391 fn extracts_message_and_code_from_object() {
392 let body = json!({"message": "nope", "code": "room_gone"});
393 let (m, c) = extract_message_and_code(Some(&body));
394 assert_eq!(m.as_deref(), Some("nope"));
395 assert_eq!(c.as_deref(), Some("room_gone"));
396 }
397
398 #[test]
399 fn message_falls_back_through_key_aliases() {
400 for key in ["message", "error", "msg", "statusMessage"] {
401 let body = json!({ key: "boom" });
402 let (m, _) = extract_message_and_code(Some(&body));
403 assert_eq!(m.as_deref(), Some("boom"), "key {key}");
404 }
405 let body = json!({"errorCode": "E42"});
406 let (_, c) = extract_message_and_code(Some(&body));
407 assert_eq!(c.as_deref(), Some("E42"));
408 }
409
410 #[test]
411 fn empty_string_fields_are_skipped() {
412 let body = json!({"message": "", "error": "real"});
413 let (m, _) = extract_message_and_code(Some(&body));
414 assert_eq!(m.as_deref(), Some("real"));
415 }
416
417 #[test]
418 fn extracts_message_from_bare_string_body() {
419 let body = json!("plain text failure");
420 let (m, c) = extract_message_and_code(Some(&body));
421 assert_eq!(m.as_deref(), Some("plain text failure"));
422 assert!(c.is_none());
423 }
424
425 #[test]
426 fn falls_back_to_status_message_when_body_has_none() {
427 let err = normalize_api_error(500, Some(json!({})), "GET", "/v2/rooms", None, None);
428 assert_eq!(err.message, "request failed with status 500");
429 assert_eq!(err.kind, ErrorKind::Generic);
430 }
431
432 #[test]
433 fn display_includes_method_path_and_status() {
434 let err = normalize_api_error(404, Some(json!("gone")), "GET", "/v2/rooms/x", None, None);
435 assert_eq!(
436 Error::api(err).to_string(),
437 "videosdk: gone (GET /v2/rooms/x, status 404)"
438 );
439 }
440
441 #[test]
442 fn predicates_match_kind() {
443 let err = Error::api(normalize_api_error(404, None, "GET", "/x", None, None));
444 assert!(err.is_not_found());
445 assert!(!err.is_rate_limit());
446 assert_eq!(err.status(), Some(404));
447
448 let err = Error::config("bad");
449 assert_eq!(err.kind(), ErrorKind::Configuration);
450 assert_eq!(err.status(), None);
451 }
452}