use std::collections::HashMap;
use std::fmt;
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ErrorKind {
Authentication,
Quota,
Subscription,
CustomCatalogAccess,
InvalidRequest,
InvalidAudio,
RateLimit,
StreamLimit,
NotReleased,
Blocked,
NeedsUpdate,
ServerError,
}
impl ErrorKind {
#[must_use]
pub fn from_code(code: i32) -> Self {
match code {
900 | 901 | 903 => Self::Authentication,
902 => Self::Quota,
904 | 905 => Self::Subscription,
50 | 51 | 600 | 601 | 602 | 700 | 701 | 702 | 906 => Self::InvalidRequest,
300 | 400 | 500 => Self::InvalidAudio,
610 => Self::StreamLimit,
611 => Self::RateLimit,
907 => Self::NotReleased,
19 | 31337 => Self::Blocked,
20 => Self::NeedsUpdate,
_ => Self::ServerError,
}
}
}
#[must_use]
pub fn error_for_code(code: i32) -> ErrorKind {
ErrorKind::from_code(code)
}
#[derive(Debug, thiserror::Error)]
pub enum AudDError {
#[error("[#{code}] {message}")]
Api {
code: i32,
message: String,
kind: ErrorKind,
http_status: u16,
request_id: Option<String>,
requested_params: HashMap<String, Value>,
request_method: Option<String>,
branded_message: Option<String>,
raw_response: Value,
},
#[error("HTTP {http_status}: {message}")]
Server {
http_status: u16,
message: String,
request_id: Option<String>,
raw_response: String,
},
#[error("connection error: {message}")]
Connection {
message: String,
#[source]
source: Option<Box<dyn std::error::Error + Send + Sync>>,
},
#[error("could not parse response: {message}")]
Serialization {
message: String,
raw_text: String,
},
#[error("invalid source: {0}")]
Source(String),
#[error("configuration error: {message}")]
Configuration {
message: String,
},
}
impl AudDError {
#[must_use]
pub fn is_authentication(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::Authentication,
..
}
)
}
#[must_use]
pub fn is_quota(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::Quota,
..
}
)
}
#[must_use]
pub fn is_subscription(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::Subscription | ErrorKind::CustomCatalogAccess,
..
}
)
}
#[must_use]
pub fn is_custom_catalog_access(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::CustomCatalogAccess,
..
}
)
}
#[must_use]
pub fn is_invalid_request(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::InvalidRequest,
..
}
)
}
#[must_use]
pub fn is_invalid_audio(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::InvalidAudio,
..
}
)
}
#[must_use]
pub fn is_rate_limit(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::RateLimit,
..
}
)
}
#[must_use]
pub fn is_stream_limit(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::StreamLimit,
..
}
)
}
#[must_use]
pub fn is_not_released(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::NotReleased,
..
}
)
}
#[must_use]
pub fn is_blocked(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::Blocked,
..
}
)
}
#[must_use]
pub fn is_needs_update(&self) -> bool {
matches!(
self,
Self::Api {
kind: ErrorKind::NeedsUpdate,
..
}
)
}
#[must_use]
pub fn is_api(&self) -> bool {
matches!(self, Self::Api { .. })
}
#[must_use]
pub fn error_code(&self) -> Option<i32> {
if let Self::Api { code, .. } = self {
Some(*code)
} else {
None
}
}
#[must_use]
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api { request_id, .. } | Self::Server { request_id, .. } => request_id.as_deref(),
_ => None,
}
}
}
#[doc(hidden)]
pub fn raise_from_error_response_for_test(
body: &Value,
http_status: u16,
request_id: Option<String>,
custom_catalog_context: bool,
) -> AudDError {
raise_from_error_response(body, http_status, request_id, custom_catalog_context)
}
pub(crate) fn raise_from_error_response(
body: &Value,
http_status: u16,
request_id: Option<String>,
custom_catalog_context: bool,
) -> AudDError {
let err_obj = body.get("error").and_then(Value::as_object);
let code = err_obj
.and_then(|o| o.get("error_code"))
.and_then(coerce_i32)
.unwrap_or(0);
let message = err_obj
.and_then(|o| o.get("error_message"))
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let requested_params = body
.get("request_params")
.or_else(|| body.get("requested_params"))
.and_then(Value::as_object)
.map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
.unwrap_or_default();
let request_method = body
.get("request_api_method")
.and_then(Value::as_str)
.map(String::from);
let branded_message = branded_message(body.get("result"));
let mut kind = ErrorKind::from_code(code);
if custom_catalog_context && kind == ErrorKind::Subscription {
kind = ErrorKind::CustomCatalogAccess;
}
let final_message = if kind == ErrorKind::CustomCatalogAccess {
custom_catalog_message(&message)
} else {
message
};
AudDError::Api {
code,
message: final_message,
kind,
http_status,
request_id,
requested_params,
request_method,
branded_message,
raw_response: body.clone(),
}
}
fn coerce_i32(v: &Value) -> Option<i32> {
if let Some(n) = v.as_i64() {
return i32::try_from(n).ok();
}
if let Some(s) = v.as_str() {
return s.parse().ok();
}
None
}
fn branded_message(result: Option<&Value>) -> Option<String> {
let obj = result?.as_object()?;
let artist = obj.get("artist").and_then(Value::as_str);
let title = obj.get("title").and_then(Value::as_str);
match (artist, title) {
(Some(a), Some(t)) if !a.is_empty() && !t.is_empty() => Some(format!("{a} — {t}")),
(Some(a), _) if !a.is_empty() => Some(a.to_string()),
(_, Some(t)) if !t.is_empty() => Some(t.to_string()),
_ => None,
}
}
fn custom_catalog_message(server_message: &str) -> String {
format!(
"Adding songs to your custom catalog requires enterprise access that isn't \
enabled on your account.\n\n\
Note: the custom-catalog endpoint is for adding songs to your private \
fingerprint database, not for music recognition. If you intended to \
identify music, use recognize(...) (or recognize_enterprise(...) for \
files longer than 25 seconds) instead.\n\n\
To request custom-catalog access, contact api@audd.io.\n\n\
[Server message: {server_message}]"
)
}
impl fmt::Display for ErrorKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
Self::Authentication => "authentication",
Self::Quota => "quota",
Self::Subscription => "subscription",
Self::CustomCatalogAccess => "custom-catalog-access",
Self::InvalidRequest => "invalid-request",
Self::InvalidAudio => "invalid-audio",
Self::RateLimit => "rate-limit",
Self::StreamLimit => "stream-limit",
Self::NotReleased => "not-released",
Self::Blocked => "blocked",
Self::NeedsUpdate => "needs-update",
Self::ServerError => "server-error",
};
f.write_str(s)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn code_to_kind() {
assert_eq!(ErrorKind::from_code(900), ErrorKind::Authentication);
assert_eq!(ErrorKind::from_code(902), ErrorKind::Quota);
assert_eq!(ErrorKind::from_code(904), ErrorKind::Subscription);
assert_eq!(ErrorKind::from_code(700), ErrorKind::InvalidRequest);
assert_eq!(ErrorKind::from_code(400), ErrorKind::InvalidAudio);
assert_eq!(ErrorKind::from_code(610), ErrorKind::StreamLimit);
assert_eq!(ErrorKind::from_code(611), ErrorKind::RateLimit);
assert_eq!(ErrorKind::from_code(907), ErrorKind::NotReleased);
assert_eq!(ErrorKind::from_code(19), ErrorKind::Blocked);
assert_eq!(ErrorKind::from_code(31337), ErrorKind::Blocked);
assert_eq!(ErrorKind::from_code(20), ErrorKind::NeedsUpdate);
assert_eq!(ErrorKind::from_code(100), ErrorKind::ServerError);
assert_eq!(ErrorKind::from_code(99999), ErrorKind::ServerError);
}
#[test]
fn raise_from_error_response_basic() {
let body = json!({
"status": "error",
"error": {"error_code": 900, "error_message": "bad token"},
"request_params": {"api_token": "d***"},
"request_api_method": "recognize"
});
let e = raise_from_error_response(&body, 200, None, false);
assert!(e.is_authentication());
assert_eq!(e.error_code(), Some(900));
if let AudDError::Api {
message,
request_method,
..
} = &e
{
assert_eq!(message, "bad token");
assert_eq!(request_method.as_deref(), Some("recognize"));
} else {
panic!("not Api: {e:?}");
}
}
#[test]
fn raise_with_branded() {
let body = json!({
"status": "error",
"error": {"error_code": 19, "error_message": "blocked"},
"result": {"artist": "ApiRequest failed", "title": "Sorry, your IP was banned"}
});
let e = raise_from_error_response(&body, 200, None, false);
assert!(e.is_blocked());
if let AudDError::Api {
branded_message, ..
} = &e
{
assert!(branded_message.is_some());
}
}
#[test]
fn custom_catalog_override() {
let body = json!({
"status": "error",
"error": {"error_code": 904, "error_message": "no access"}
});
let e = raise_from_error_response(&body, 200, None, true);
assert!(e.is_custom_catalog_access());
assert!(e.is_subscription());
if let AudDError::Api { message, .. } = &e {
assert!(message.contains("custom catalog"));
assert!(message.contains("Server message: no access"));
}
}
#[test]
fn helpers() {
let e = AudDError::Connection {
message: "boom".into(),
source: None,
};
assert!(!e.is_api());
assert_eq!(e.error_code(), None);
}
#[test]
fn coerce_string_code() {
let body = json!({
"status": "error",
"error": {"error_code": "902", "error_message": "limit"},
});
let e = raise_from_error_response(&body, 200, None, false);
assert_eq!(e.error_code(), Some(902));
assert!(e.is_quota());
}
}