use std::time::Duration;
pub type Result<T> = std::result::Result<T, Error>;
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("openrouter api error: {status} {code:?} — {message}")]
Api {
status: u16,
code: Option<String>,
message: String,
metadata: Option<serde_json::Value>,
provider: Option<String>,
retry_after: Option<Duration>,
},
#[error("http transport: {0}")]
Http(#[from] reqwest::Error),
#[error("decode: {0}")]
Decode(#[from] serde_json::Error),
#[error("stream: {0}")]
Stream(String),
#[error("retry exhausted after {attempts} attempt(s)")]
RetryExhausted {
attempts: u32,
#[source]
source: Box<Error>,
},
#[error("invalid input: {0}")]
InvalidInput(&'static str),
#[error("builder: missing required field `{0}`")]
MissingField(&'static str),
}
#[derive(serde::Deserialize)]
#[allow(dead_code)]
struct ApiErrorEnvelope {
error: ApiErrorBody,
}
#[derive(serde::Deserialize)]
struct ApiErrorBody {
#[serde(default)]
code: Option<serde_json::Value>,
#[serde(default)]
message: Option<String>,
#[serde(default)]
metadata: Option<serde_json::Value>,
#[serde(default)]
provider_name: Option<String>,
}
impl Error {
pub(crate) fn from_response_body(
status: u16,
body: &[u8],
retry_after: Option<Duration>,
) -> Error {
let parsed: Option<ApiErrorEnvelope> = serde_json::from_slice(body).ok();
let (code, message, metadata, provider) = match parsed {
Some(env) => {
let code = env.error.code.and_then(|v| match v {
serde_json::Value::String(s) => Some(s),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
});
let message = env
.error
.message
.unwrap_or_else(|| String::from_utf8_lossy(body).into_owned());
(code, message, env.error.metadata, env.error.provider_name)
}
None => (None, String::from_utf8_lossy(body).into_owned(), None, None),
};
Error::Api {
status,
code,
message,
metadata,
provider,
retry_after,
}
}
pub(crate) fn is_transient(&self) -> bool {
match self {
Error::Api { status, .. } => *status == 429 || (500..=599).contains(status),
Error::Http(e) => e.is_timeout() || e.is_connect() || e.is_request(),
_ => false,
}
}
pub(crate) fn retry_after(&self) -> Option<Duration> {
match self {
Error::Api { retry_after, .. } => *retry_after,
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_structured_api_error() {
let body = br#"{"error":{"code":"invalid_request_error","message":"bad model","metadata":{"raw":"foo"},"provider_name":"openai"}}"#;
let err = Error::from_response_body(400, body, None);
match err {
Error::Api {
status,
code,
message,
provider,
metadata,
retry_after,
} => {
assert_eq!(status, 400);
assert_eq!(code.as_deref(), Some("invalid_request_error"));
assert_eq!(message, "bad model");
assert_eq!(provider.as_deref(), Some("openai"));
assert!(metadata.is_some());
assert!(retry_after.is_none());
}
_ => panic!("expected Api"),
}
}
#[test]
fn falls_back_to_raw_body_on_non_json() {
let err = Error::from_response_body(502, b"upstream gone", None);
match err {
Error::Api {
status,
message,
code,
..
} => {
assert_eq!(status, 502);
assert_eq!(message, "upstream gone");
assert!(code.is_none());
}
_ => panic!("expected Api"),
}
}
#[test]
fn numeric_code_is_stringified() {
let body = br#"{"error":{"code":429,"message":"too many"}}"#;
let err = Error::from_response_body(429, body, Some(Duration::from_secs(3)));
if let Error::Api {
code, retry_after, ..
} = err
{
assert_eq!(code.as_deref(), Some("429"));
assert_eq!(retry_after, Some(Duration::from_secs(3)));
} else {
panic!("expected Api");
}
}
#[test]
fn is_transient_logic() {
let server = Error::Api {
status: 503,
code: None,
message: "x".into(),
metadata: None,
provider: None,
retry_after: None,
};
let rate = Error::Api {
status: 429,
code: None,
message: "x".into(),
metadata: None,
provider: None,
retry_after: None,
};
let bad = Error::Api {
status: 400,
code: None,
message: "x".into(),
metadata: None,
provider: None,
retry_after: None,
};
assert!(server.is_transient());
assert!(rate.is_transient());
assert!(!bad.is_transient());
assert!(!Error::InvalidInput("x").is_transient());
}
#[test]
fn display_does_not_panic() {
let e = Error::MissingField("api_key");
let _ = format!("{e}");
}
}