use serde_json::Value;
use thiserror::Error;
const MAX_BODY_CHARS: usize = 4096;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiErrorKind {
BadRequest,
Authentication,
PermissionDenied,
NotFound,
Conflict,
UnprocessableEntity,
RateLimit,
InternalServer,
Other,
}
impl ApiErrorKind {
pub fn from_status(status: u16) -> Self {
match status {
400 => Self::BadRequest,
401 => Self::Authentication,
403 => Self::PermissionDenied,
404 => Self::NotFound,
409 => Self::Conflict,
422 => Self::UnprocessableEntity,
429 => Self::RateLimit,
s if s >= 500 => Self::InternalServer,
_ => Self::Other,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ApiError {
pub status: u16,
pub kind: ApiErrorKind,
pub message: Option<String>,
pub error_type: Option<String>,
pub code: Option<String>,
pub param: Option<String>,
pub request_id: Option<String>,
pub body: Option<Value>,
}
impl ApiError {
pub(crate) fn from_response(status: u16, request_id: Option<String>, text: &str) -> Self {
let body: Option<Value> = serde_json::from_str(text).ok();
let field = |name: &str| {
body.as_ref()
.and_then(|b| b["error"][name].as_str())
.map(str::to_string)
};
let top_message = body
.as_ref()
.and_then(|b| b["message"].as_str())
.map(str::to_string);
let message = field("message")
.or(top_message)
.or_else(|| {
let text = text.trim();
(!text.is_empty()).then(|| text.to_string())
})
.map(|m| truncate(&m));
Self {
status,
kind: ApiErrorKind::from_status(status),
message,
error_type: field("type").or_else(|| field("status")),
code: field("code"),
param: field("param"),
request_id,
body,
}
}
}
impl std::fmt::Display for ApiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.message {
Some(message) => write!(f, "{} {}", self.status, message),
None => write!(f, "{} status code (no body)", self.status),
}
}
}
impl std::error::Error for ApiError {}
#[derive(Debug, Error)]
pub enum LlmError {
#[error("{0}")]
Api(Box<ApiError>),
#[error("Connection error.")]
Connection(#[source] reqwest::Error),
#[error("Request timed out.")]
Timeout(#[source] reqwest::Error),
#[error("failed to deserialize api response: {source}")]
Deserialize {
#[source]
source: serde_json::Error,
content: String,
},
#[error("unexpected api response: {0}")]
InvalidResponse(String),
#[error("invalid args: {0}")]
InvalidArgument(String),
}
impl LlmError {
pub(crate) fn from_reqwest(error: reqwest::Error) -> Self {
if error.is_builder() {
return Self::InvalidArgument(format!(
"could not build request: {}",
error.without_url()
));
}
let error = error.without_url();
if error.is_timeout() {
Self::Timeout(error)
} else {
Self::Connection(error)
}
}
pub fn status(&self) -> Option<u16> {
match self {
Self::Api(e) => Some(e.status),
_ => None,
}
}
pub fn request_id(&self) -> Option<&str> {
match self {
Self::Api(e) => e.request_id.as_deref(),
_ => None,
}
}
pub fn is_retryable(&self) -> bool {
match self {
Self::Api(e) => matches!(e.status, 408 | 409 | 429) || e.status >= 500,
Self::Connection(_) | Self::Timeout(_) => true,
_ => false,
}
}
}
pub(crate) fn truncate(text: &str) -> String {
text.chars().take(MAX_BODY_CHARS).collect()
}