use reqwest::StatusCode;
use serde_json::Value;
use std::fmt;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("configuration error: {0}")]
Config(String),
#[error("invalid request: {0}")]
InvalidRequest(String),
#[error(transparent)]
Api(ApiError),
#[error("connection error: {0}")]
Connection(String),
#[error("failed to decode response body: {0}")]
Decode(String),
}
impl Error {
pub fn status(&self) -> Option<StatusCode> {
match self {
Error::Api(e) => Some(e.status),
_ => None,
}
}
pub fn is_authentication(&self) -> bool {
self.status() == Some(StatusCode::UNAUTHORIZED)
}
pub fn is_rate_limited(&self) -> bool {
matches!(self.status().map(|s| s.as_u16()), Some(429) | Some(529))
}
pub fn is_validation(&self) -> bool {
matches!(self.status().map(|s| s.as_u16()), Some(400) | Some(422))
}
}
#[derive(Debug)]
pub struct ApiError {
pub status: StatusCode,
pub message: String,
pub body: Option<Value>,
}
impl fmt::Display for ApiError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "API error (status {}): {}", self.status, self.message)
}
}
impl std::error::Error for ApiError {}
impl ApiError {
pub(crate) fn from_response(status: StatusCode, raw: &str) -> Self {
let body: Option<Value> = serde_json::from_str(raw).ok();
let message = body
.as_ref()
.and_then(|b| b.get("detail"))
.map(extract_detail_message)
.filter(|m| !m.is_empty())
.unwrap_or_else(|| {
if raw.is_empty() {
status
.canonical_reason()
.unwrap_or("unknown error")
.to_owned()
} else {
raw.chars().take(512).collect()
}
});
ApiError {
status,
message,
body,
}
}
}
fn extract_detail_message(detail: &Value) -> String {
match detail {
Value::String(s) => s.clone(),
Value::Object(map) => map
.get("message")
.and_then(Value::as_str)
.map(str::to_owned)
.unwrap_or_else(|| detail.to_string()),
Value::Array(items) => items
.iter()
.map(|item| {
let loc = item
.get("loc")
.and_then(Value::as_array)
.map(|parts| {
parts
.iter()
.filter_map(|p| {
p.as_str()
.map(str::to_owned)
.or_else(|| p.as_number().map(|n| n.to_string()))
})
.collect::<Vec<_>>()
.join(".")
})
.unwrap_or_default();
let msg = item.get("msg").and_then(Value::as_str).unwrap_or("invalid");
if loc.is_empty() {
msg.to_owned()
} else {
format!("{loc}: {msg}")
}
})
.collect::<Vec<_>>()
.join("; "),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_object_detail() {
let err = ApiError::from_response(
StatusCode::UNAUTHORIZED,
r#"{"detail":{"error_type":"authentication_error","message":"Cannot authenticate with the server. Please check your API key and try again."}}"#,
);
assert_eq!(
err.message,
"Cannot authenticate with the server. Please check your API key and try again."
);
assert_eq!(err.status, StatusCode::UNAUTHORIZED);
}
#[test]
fn parses_string_detail() {
let err = ApiError::from_response(
StatusCode::BAD_REQUEST,
r#"{"detail":"Noul question must have criteria or instructions: q"}"#,
);
assert_eq!(
err.message,
"Noul question must have criteria or instructions: q"
);
}
#[test]
fn parses_fastapi_validation_list() {
let err = ApiError::from_response(
StatusCode::UNPROCESSABLE_ENTITY,
r#"{"detail":[{"loc":["body","questions"],"msg":"field required","type":"value_error.missing"}]}"#,
);
assert_eq!(err.message, "body.questions: field required");
}
#[test]
fn falls_back_to_raw_body() {
let err = ApiError::from_response(StatusCode::INTERNAL_SERVER_ERROR, "oops");
assert_eq!(err.message, "oops");
assert!(err.body.is_none());
}
}