Skip to main content

videosdk/
error.rs

1//! The SDK's error type.
2//!
3//! Every fallible SDK call returns [`Result<T>`], whose error is [`Error`].
4//! Errors that came from an HTTP response carry an [`ApiError`] with the status,
5//! the server's message and machine-readable code, and a request id for support.
6
7use std::fmt;
8use std::time::Duration;
9
10use serde_json::Value;
11
12/// A convenience alias for a `Result` whose error is the SDK's [`Error`].
13pub type Result<T> = std::result::Result<T, Error>;
14
15/// A coarse classification of an [`Error`].
16///
17/// For errors that came from an HTTP response the kind is derived from the
18/// status code; client-side failures (misconfiguration, timeouts) set it
19/// directly.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
21#[non_exhaustive]
22pub enum ErrorKind {
23    /// An error that doesn't map to a more specific kind (includes 5xx).
24    Generic,
25    /// The client was constructed or used with invalid configuration.
26    Configuration,
27    /// HTTP 401 — the token is missing or invalid, or the account is inactive.
28    Authentication,
29    /// HTTP 403 — authenticated, but not permitted.
30    Permission,
31    /// HTTP 429 — rate limited.
32    RateLimit,
33    /// HTTP 400 — request validation failed.
34    Validation,
35    /// HTTP 404 or 402 — resource not found. The API uses both.
36    NotFound,
37    /// HTTP 409 — conflicting or duplicate state.
38    Conflict,
39    /// The client-side request timeout elapsed.
40    Timeout,
41}
42
43impl ErrorKind {
44    /// Classifies an HTTP status code.
45    ///
46    /// Note that **402 maps to [`ErrorKind::NotFound`]** alongside 404: the API
47    /// returns 402 for some missing resources. This matches the TypeScript and
48    /// Go SDKs.
49    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/// An error returned by the API as a non-2xx HTTP response.
63#[derive(Debug, Clone)]
64#[non_exhaustive]
65pub struct ApiError {
66    /// A human-readable description, normalized from the API's error body.
67    pub message: String,
68    /// The classification derived from [`ApiError::status`].
69    pub kind: ErrorKind,
70    /// A machine-readable error code returned by the API, when present.
71    pub code: Option<String>,
72    /// The HTTP response status code.
73    pub status: u16,
74    /// A server request id, read from the response headers. Useful for support.
75    pub request_id: Option<String>,
76    /// The raw parsed error body, for advanced inspection.
77    pub details: Option<Value>,
78    /// The HTTP method of the failing request.
79    pub method: String,
80    /// The path of the failing request.
81    pub path: String,
82    /// The server-requested cooldown parsed from the `Retry-After` header on a
83    /// 429 response.
84    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/// The error type returned by every fallible SDK call.
107#[derive(Debug, thiserror::Error)]
108#[non_exhaustive]
109pub enum Error {
110    /// The API responded with a non-2xx status.
111    ///
112    /// Boxed so that `Result<T>` stays small on the happy path, which every
113    /// call in the crate pays for.
114    #[error("{0}")]
115    Api(Box<ApiError>),
116
117    /// The client was constructed or used with invalid configuration.
118    #[error("videosdk: {0}")]
119    Configuration(String),
120
121    /// A token could not be verified (malformed, bad signature, or expired).
122    #[error("videosdk: {0}")]
123    Authentication(String),
124
125    /// A client-side precondition failed before the request was sent.
126    #[error("videosdk: {0}")]
127    Validation(String),
128
129    /// The SDK resolved that a resource does not exist without the API saying
130    /// so, e.g. asking for a participant in a room that has no live session.
131    #[error("videosdk: {0}")]
132    NotFound(String),
133
134    /// A multi-step operation the SDK drives to completion failed, e.g. a
135    /// batch-call file that the server could not parse.
136    ///
137    /// Carries a machine-readable [`code`](Error::code) but no HTTP status,
138    /// since the failure is a *result* rather than a response.
139    #[error("videosdk: {message}")]
140    Operation {
141        /// How to classify this failure.
142        kind: ErrorKind,
143        /// A machine-readable code, e.g. `batchcall_parse_failed`.
144        code: String,
145        /// A human-readable description.
146        message: String,
147    },
148
149    /// The client-side request timeout elapsed.
150    #[error("videosdk: request timed out after {}ms ({method} {path})", .elapsed.as_millis())]
151    Timeout {
152        /// The HTTP method of the request that timed out.
153        method: String,
154        /// The path of the request that timed out.
155        path: String,
156        /// The timeout that elapsed.
157        elapsed: Duration,
158    },
159
160    /// The request could not be sent, or the response could not be read.
161    #[error("videosdk: network request failed ({method} {path}): {source}")]
162    Network {
163        /// The HTTP method of the failing request.
164        method: String,
165        /// The path of the failing request.
166        path: String,
167        /// The underlying transport error.
168        #[source]
169        source: reqwest::Error,
170    },
171
172    /// A response body could not be deserialized into the expected type.
173    #[error("videosdk: failed to decode response ({context}): {source}")]
174    Decode {
175        /// What was being decoded, e.g. `GET /v2/rooms`.
176        context: String,
177        /// The underlying deserialization error.
178        #[source]
179        source: serde_json::Error,
180    },
181
182    /// A request body could not be serialized.
183    #[error("videosdk: failed to encode request body: {source}")]
184    Encode {
185        /// The underlying serialization error.
186        #[source]
187        source: serde_json::Error,
188    },
189}
190
191impl Error {
192    /// The coarse classification of this error.
193    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    /// The underlying [`ApiError`], if this error came from an HTTP response.
209    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    /// The HTTP status code, if this error came from an HTTP response.
221    pub fn status(&self) -> Option<u16> {
222        self.as_api().map(|e| e.status)
223    }
224
225    /// The machine-readable error code, if present.
226    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    /// The server request id, if the response carried one.
247    pub fn request_id(&self) -> Option<&str> {
248        self.as_api().and_then(|e| e.request_id.as_deref())
249    }
250
251    /// The cooldown the server asked for via `Retry-After` on a 429.
252    pub fn retry_after(&self) -> Option<Duration> {
253        self.as_api().and_then(|e| e.retry_after)
254    }
255
256    /// Whether this is a not-found error (HTTP 404 or 402).
257    pub fn is_not_found(&self) -> bool {
258        self.kind() == ErrorKind::NotFound
259    }
260
261    /// Whether this is an authentication error (HTTP 401, or a bad token).
262    pub fn is_authentication(&self) -> bool {
263        self.kind() == ErrorKind::Authentication
264    }
265
266    /// Whether this is a permission error (HTTP 403).
267    pub fn is_permission(&self) -> bool {
268        self.kind() == ErrorKind::Permission
269    }
270
271    /// Whether this is a validation error (HTTP 400, or a client-side check).
272    pub fn is_validation(&self) -> bool {
273        self.kind() == ErrorKind::Validation
274    }
275
276    /// Whether this is a rate-limit error (HTTP 429).
277    pub fn is_rate_limit(&self) -> bool {
278        self.kind() == ErrorKind::RateLimit
279    }
280
281    /// Whether this is a conflict error (HTTP 409).
282    pub fn is_conflict(&self) -> bool {
283        self.kind() == ErrorKind::Conflict
284    }
285
286    /// Whether this is a client-side timeout.
287    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
315/// Builds a typed [`ApiError`] from a non-2xx response, pulling a human message
316/// and optional code out of the parsed body.
317pub(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
339/// Pulls a message and optional code out of an arbitrary parsed error body: a
340/// bare string, or an object with `message`/`error`/`msg`/`statusMessage` and
341/// `code`/`errorCode` fields.
342fn 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        // The API overloads 402 to mean "not found" for some resources.
386        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}