use std::fmt;
use std::time::Duration;
use serde_json::Value;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ErrorKind {
Generic,
Configuration,
Authentication,
Permission,
RateLimit,
Validation,
NotFound,
Conflict,
Timeout,
}
impl ErrorKind {
pub fn from_status(status: u16) -> Self {
match status {
400 => ErrorKind::Validation,
401 => ErrorKind::Authentication,
403 => ErrorKind::Permission,
402 | 404 => ErrorKind::NotFound,
409 => ErrorKind::Conflict,
429 => ErrorKind::RateLimit,
_ => ErrorKind::Generic,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ApiError {
pub message: String,
pub kind: ErrorKind,
pub code: Option<String>,
pub status: u16,
pub request_id: Option<String>,
pub details: Option<Value>,
pub method: String,
pub path: String,
pub retry_after: Option<Duration>,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let msg = if self.message.is_empty() {
"request failed"
} else {
&self.message
};
if self.method.is_empty() {
write!(f, "videosdk: {msg} (status {})", self.status)
} else {
write!(
f,
"videosdk: {msg} ({} {}, status {})",
self.method, self.path, self.status
)
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("{0}")]
Api(Box<ApiError>),
#[error("videosdk: {0}")]
Configuration(String),
#[error("videosdk: {0}")]
Authentication(String),
#[error("videosdk: {0}")]
Validation(String),
#[error("videosdk: {0}")]
NotFound(String),
#[error("videosdk: {message}")]
Operation {
kind: ErrorKind,
code: String,
message: String,
},
#[error("videosdk: request timed out after {}ms ({method} {path})", .elapsed.as_millis())]
Timeout {
method: String,
path: String,
elapsed: Duration,
},
#[error("videosdk: network request failed ({method} {path}): {source}")]
Network {
method: String,
path: String,
#[source]
source: reqwest::Error,
},
#[error("videosdk: failed to decode response ({context}): {source}")]
Decode {
context: String,
#[source]
source: serde_json::Error,
},
#[error("videosdk: failed to encode request body: {source}")]
Encode {
#[source]
source: serde_json::Error,
},
}
impl Error {
pub fn kind(&self) -> ErrorKind {
match self {
Error::Api(e) => e.kind,
Error::Configuration(_) => ErrorKind::Configuration,
Error::Authentication(_) => ErrorKind::Authentication,
Error::Validation(_) => ErrorKind::Validation,
Error::NotFound(_) => ErrorKind::NotFound,
Error::Operation { kind, .. } => *kind,
Error::Timeout { .. } => ErrorKind::Timeout,
Error::Network { .. } | Error::Decode { .. } | Error::Encode { .. } => {
ErrorKind::Generic
}
}
}
pub fn as_api(&self) -> Option<&ApiError> {
match self {
Error::Api(e) => Some(e.as_ref()),
_ => None,
}
}
pub(crate) fn api(error: ApiError) -> Self {
Error::Api(Box::new(error))
}
pub fn status(&self) -> Option<u16> {
self.as_api().map(|e| e.status)
}
pub fn code(&self) -> Option<&str> {
match self {
Error::Api(e) => e.code.as_deref(),
Error::Operation { code, .. } => Some(code),
_ => None,
}
}
pub(crate) fn operation(
kind: ErrorKind,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Error::Operation {
kind,
code: code.into(),
message: message.into(),
}
}
pub fn request_id(&self) -> Option<&str> {
self.as_api().and_then(|e| e.request_id.as_deref())
}
pub fn retry_after(&self) -> Option<Duration> {
self.as_api().and_then(|e| e.retry_after)
}
pub fn is_not_found(&self) -> bool {
self.kind() == ErrorKind::NotFound
}
pub fn is_authentication(&self) -> bool {
self.kind() == ErrorKind::Authentication
}
pub fn is_permission(&self) -> bool {
self.kind() == ErrorKind::Permission
}
pub fn is_validation(&self) -> bool {
self.kind() == ErrorKind::Validation
}
pub fn is_rate_limit(&self) -> bool {
self.kind() == ErrorKind::RateLimit
}
pub fn is_conflict(&self) -> bool {
self.kind() == ErrorKind::Conflict
}
pub fn is_timeout(&self) -> bool {
self.kind() == ErrorKind::Timeout
}
pub(crate) fn config(msg: impl Into<String>) -> Self {
Error::Configuration(msg.into())
}
pub(crate) fn auth(msg: impl Into<String>) -> Self {
Error::Authentication(msg.into())
}
pub(crate) fn validation(msg: impl Into<String>) -> Self {
Error::Validation(msg.into())
}
pub(crate) fn not_found(msg: impl Into<String>) -> Self {
Error::NotFound(msg.into())
}
pub(crate) fn decode(context: impl Into<String>, source: serde_json::Error) -> Self {
Error::Decode {
context: context.into(),
source,
}
}
}
pub(crate) fn normalize_api_error(
status: u16,
body: Option<Value>,
method: &str,
path: &str,
request_id: Option<String>,
retry_after: Option<Duration>,
) -> ApiError {
let (message, code) = extract_message_and_code(body.as_ref());
ApiError {
message: message.unwrap_or_else(|| format!("request failed with status {status}")),
kind: ErrorKind::from_status(status),
code,
status,
request_id,
details: body,
method: method.to_string(),
path: path.to_string(),
retry_after,
}
}
fn extract_message_and_code(body: Option<&Value>) -> (Option<String>, Option<String>) {
let Some(body) = body else {
return (None, None);
};
match body {
Value::Null => (None, None),
Value::String(s) => (Some(s.clone()), None),
Value::Object(map) => {
let pick = |keys: &[&str]| -> Option<String> {
keys.iter().find_map(|k| {
map.get(*k)
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string)
})
};
(
pick(&["message", "error", "msg", "statusMessage"]),
pick(&["code", "errorCode"]),
)
}
other => (Some(other.to_string()), None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn status_maps_to_kind() {
assert_eq!(ErrorKind::from_status(400), ErrorKind::Validation);
assert_eq!(ErrorKind::from_status(401), ErrorKind::Authentication);
assert_eq!(ErrorKind::from_status(403), ErrorKind::Permission);
assert_eq!(ErrorKind::from_status(409), ErrorKind::Conflict);
assert_eq!(ErrorKind::from_status(429), ErrorKind::RateLimit);
assert_eq!(ErrorKind::from_status(500), ErrorKind::Generic);
assert_eq!(ErrorKind::from_status(418), ErrorKind::Generic);
}
#[test]
fn both_402_and_404_are_not_found() {
assert_eq!(ErrorKind::from_status(402), ErrorKind::NotFound);
assert_eq!(ErrorKind::from_status(404), ErrorKind::NotFound);
}
#[test]
fn extracts_message_and_code_from_object() {
let body = json!({"message": "nope", "code": "room_gone"});
let (m, c) = extract_message_and_code(Some(&body));
assert_eq!(m.as_deref(), Some("nope"));
assert_eq!(c.as_deref(), Some("room_gone"));
}
#[test]
fn message_falls_back_through_key_aliases() {
for key in ["message", "error", "msg", "statusMessage"] {
let body = json!({ key: "boom" });
let (m, _) = extract_message_and_code(Some(&body));
assert_eq!(m.as_deref(), Some("boom"), "key {key}");
}
let body = json!({"errorCode": "E42"});
let (_, c) = extract_message_and_code(Some(&body));
assert_eq!(c.as_deref(), Some("E42"));
}
#[test]
fn empty_string_fields_are_skipped() {
let body = json!({"message": "", "error": "real"});
let (m, _) = extract_message_and_code(Some(&body));
assert_eq!(m.as_deref(), Some("real"));
}
#[test]
fn extracts_message_from_bare_string_body() {
let body = json!("plain text failure");
let (m, c) = extract_message_and_code(Some(&body));
assert_eq!(m.as_deref(), Some("plain text failure"));
assert!(c.is_none());
}
#[test]
fn falls_back_to_status_message_when_body_has_none() {
let err = normalize_api_error(500, Some(json!({})), "GET", "/v2/rooms", None, None);
assert_eq!(err.message, "request failed with status 500");
assert_eq!(err.kind, ErrorKind::Generic);
}
#[test]
fn display_includes_method_path_and_status() {
let err = normalize_api_error(404, Some(json!("gone")), "GET", "/v2/rooms/x", None, None);
assert_eq!(
Error::api(err).to_string(),
"videosdk: gone (GET /v2/rooms/x, status 404)"
);
}
#[test]
fn predicates_match_kind() {
let err = Error::api(normalize_api_error(404, None, "GET", "/x", None, None));
assert!(err.is_not_found());
assert!(!err.is_rate_limit());
assert_eq!(err.status(), Some(404));
let err = Error::config("bad");
assert_eq!(err.kind(), ErrorKind::Configuration);
assert_eq!(err.status(), None);
}
}