use serde::{Deserialize, Deserializer, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiErrorPayload {
#[serde(default, deserialize_with = "deserialize_null_as_default")]
pub message: String,
#[serde(rename = "type", default)]
pub error_type: Option<String>,
#[serde(default)]
pub param: Option<String>,
#[serde(default, deserialize_with = "deserialize_string_or_int")]
pub code: Option<String>,
}
fn deserialize_null_as_default<'de, D>(deserializer: D) -> Result<String, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<String>::deserialize(deserializer)?;
Ok(opt.unwrap_or_default())
}
fn deserialize_string_or_int<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: Deserializer<'de>,
{
let opt = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(opt.and_then(|val| match val {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Number(n) => Some(n.to_string()),
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Null => None,
other => Some(other.to_string()),
}))
}
fn deserialize_api_error_payload<'de, D>(deserializer: D) -> Result<ApiErrorPayload, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum ErrorPayloadOrString {
Payload(ApiErrorPayload),
String(String),
}
let val = ErrorPayloadOrString::deserialize(deserializer)?;
Ok(match val {
ErrorPayloadOrString::Payload(p) => p,
ErrorPayloadOrString::String(s) => ApiErrorPayload {
message: s,
error_type: None,
param: None,
code: None,
},
})
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ApiErrorEnvelope {
#[serde(deserialize_with = "deserialize_api_error_payload")]
pub error: ApiErrorPayload,
}
#[derive(Debug, thiserror::Error)]
pub enum ClientError {
#[error("HTTP transport error: {0}")]
Http(#[from] reqwest::Error),
#[error("API error{}: {message}", .status.map(|s| format!(" (status {s})")).unwrap_or_default())]
Api {
status: Option<reqwest::StatusCode>,
message: String,
error_type: Option<String>,
code: Option<String>,
param: Option<String>,
},
#[error(
"JSON serialization error: {source}{}",
.raw_payload.as_deref().map(|p| format!(" (raw payload: {p})")).unwrap_or_default()
)]
Serialization {
#[source]
source: serde_json::Error,
raw_payload: Option<String>,
},
#[error("Stream error: {0}")]
Stream(String),
#[error("Missing API key: {0}")]
MissingApiKey(String),
#[error("Invalid URL: {0}")]
InvalidUrl(String),
#[error("Invalid HTTP header: {0}")]
InvalidHeader(String),
}
impl From<serde_json::Error> for ClientError {
fn from(source: serde_json::Error) -> Self {
Self::Serialization {
source,
raw_payload: None,
}
}
}
impl ClientError {
pub fn status(&self) -> Option<reqwest::StatusCode> {
match self {
Self::Api { status, .. } => *status,
Self::Http(err) => err.status(),
_ => None,
}
}
pub fn is_rate_limited(&self) -> bool {
if self.status() == Some(reqwest::StatusCode::TOO_MANY_REQUESTS) {
return true;
}
if let Self::Api { code, .. } = self
&& let Some(c) = code.as_deref()
{
return c == "429" || c == "rate_limit_exceeded" || c == "rate_limit";
}
false
}
pub fn is_transient(&self) -> bool {
if let Some(status) = self.status()
&& (status.is_server_error() || status == reqwest::StatusCode::REQUEST_TIMEOUT)
{
return true;
}
if let Self::Api {
code, error_type, ..
} = self
{
if let Some(c) = code.as_deref()
&& matches!(
c,
"500"
| "502"
| "503"
| "504"
| "server_error"
| "overloaded"
| "timeout"
| "service_unavailable"
| "gateway_timeout"
| "engine_overloaded"
)
{
return true;
}
if let Some(t) = error_type.as_deref()
&& (t == "server_error" || t == "timeout" || t == "service_unavailable")
{
return true;
}
}
if let Self::Http(err) = self {
#[cfg(not(target_arch = "wasm32"))]
return err.is_timeout() || err.is_connect();
#[cfg(target_arch = "wasm32")]
return err.is_timeout();
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_string_error_code() {
let json = r#"{"error": {"message": "Rate limit exceeded", "type": "tokens", "code": "rate_limit_exceeded"}}"#;
let env: ApiErrorEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(env.error.code.as_deref(), Some("rate_limit_exceeded"));
assert_eq!(env.error.message, "Rate limit exceeded");
}
#[test]
fn test_parse_numeric_error_code() {
let json = r#"{"error": {"message": "Payment required", "code": 402}}"#;
let env: ApiErrorEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(env.error.code.as_deref(), Some("402"));
assert_eq!(env.error.message, "Payment required");
}
#[test]
fn test_error_status_and_helpers() {
let err429 = ClientError::Api {
status: Some(reqwest::StatusCode::TOO_MANY_REQUESTS),
message: "Rate limit reached".to_string(),
error_type: None,
code: None,
param: None,
};
assert_eq!(
err429.status(),
Some(reqwest::StatusCode::TOO_MANY_REQUESTS)
);
assert!(err429.is_rate_limited());
assert!(!err429.is_transient());
let err_code_rate_limit = ClientError::Api {
status: None,
message: "Too fast".to_string(),
error_type: None,
code: Some("rate_limit_exceeded".to_string()),
param: None,
};
assert_eq!(err_code_rate_limit.status(), None);
assert!(err_code_rate_limit.is_rate_limited());
assert!(!err_code_rate_limit.is_transient());
let err500 = ClientError::Api {
status: Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
message: "Internal server error".to_string(),
error_type: None,
code: None,
param: None,
};
assert_eq!(
err500.status(),
Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR)
);
assert!(!err500.is_rate_limited());
assert!(err500.is_transient());
let err_in_band_503 = ClientError::Api {
status: None,
message: "Model overloaded".to_string(),
error_type: Some("server_error".to_string()),
code: Some("503".to_string()),
param: None,
};
assert!(err_in_band_503.is_transient());
let err408 = ClientError::Api {
status: Some(reqwest::StatusCode::REQUEST_TIMEOUT),
message: "Timeout".to_string(),
error_type: None,
code: None,
param: None,
};
assert!(err408.is_transient());
let err_service_unavailable = ClientError::Api {
status: None,
message: "Service unavailable".to_string(),
error_type: Some("service_unavailable".to_string()),
code: Some("service_unavailable".to_string()),
param: None,
};
assert!(err_service_unavailable.is_transient());
let err_gateway_timeout = ClientError::Api {
status: None,
message: "Gateway timeout".to_string(),
error_type: None,
code: Some("gateway_timeout".to_string()),
param: None,
};
assert!(err_gateway_timeout.is_transient());
}
#[test]
fn test_parse_null_or_missing_error_message() {
let json_null = r#"{"error": {"message": null, "code": 500}}"#;
let env_null: ApiErrorEnvelope = serde_json::from_str(json_null).unwrap();
assert_eq!(env_null.error.message, "");
assert_eq!(env_null.error.code.as_deref(), Some("500"));
let json_missing = r#"{"error": {"code": 503}}"#;
let env_missing: ApiErrorEnvelope = serde_json::from_str(json_missing).unwrap();
assert_eq!(env_missing.error.message, "");
assert_eq!(env_missing.error.code.as_deref(), Some("503"));
}
#[test]
fn test_parse_bare_string_error_message() {
let json_str = r#"{"error": "model is currently overloaded, please try again"}"#;
let env: ApiErrorEnvelope = serde_json::from_str(json_str).unwrap();
assert_eq!(
env.error.message,
"model is currently overloaded, please try again"
);
assert_eq!(env.error.code, None);
assert_eq!(env.error.error_type, None);
}
#[test]
fn test_parse_various_error_codes() {
let json_float = r#"{"error": {"message": "Rate limit", "code": 429.5}}"#;
let env_float: ApiErrorEnvelope = serde_json::from_str(json_float).unwrap();
assert_eq!(env_float.error.code.as_deref(), Some("429.5"));
let json_bool = r#"{"error": {"message": "Invalid", "code": true}}"#;
let env_bool: ApiErrorEnvelope = serde_json::from_str(json_bool).unwrap();
assert_eq!(env_bool.error.code.as_deref(), Some("true"));
let json_null_code = r#"{"error": {"message": "Error", "code": null}}"#;
let env_null_code: ApiErrorEnvelope = serde_json::from_str(json_null_code).unwrap();
assert_eq!(env_null_code.error.code, None);
}
#[test]
fn test_serialization_error_display_with_and_without_raw_payload() {
let err_no_payload: ClientError =
serde_json::from_str::<serde_json::Value>("invalid json{")
.unwrap_err()
.into();
let display_no_payload = err_no_payload.to_string();
assert!(display_no_payload.starts_with("JSON serialization error:"));
assert!(!display_no_payload.contains("raw payload"));
let err_with_payload = ClientError::Serialization {
source: serde_json::from_str::<serde_json::Value>("invalid json{").unwrap_err(),
raw_payload: Some("data: {malformed}".to_string()),
};
let display_with_payload = err_with_payload.to_string();
assert!(display_with_payload.contains("raw payload: data: {malformed}"));
}
}