use allama::{Error, ErrorContext};
use serde_json::{Map, Value};
use crate::Headers;
const REQUEST_ID_HEADERS: [&str; 2] = ["x-request-id", "request-id"];
#[must_use]
pub fn error_context(status: u16, headers: &Headers) -> ErrorContext {
let mut context = ErrorContext::new();
context.status = Some(status);
context.request_id = REQUEST_ID_HEADERS
.iter()
.filter_map(|name| headers.get(name))
.find_map(non_empty);
context.retry_after = headers.retry_after("retry-after");
context.server_retry_directive = headers.get("x-should-retry").and_then(retry_directive);
context
}
#[must_use]
pub fn status_error(status: u16, message: impl Into<String>) -> Error {
let message = message.into();
let error = match status {
300..=399 => Error::unexpected_response(message),
401 | 403 => Error::auth(message),
402 => Error::quota_exhausted(message),
408 | 504 | 524 => Error::timeout(message),
429 => Error::rate_limited(message),
499 => Error::provider(message),
400..=498 => Error::invalid_request(message),
503 | 529 => Error::overloaded(message),
_ => Error::provider(message),
};
error.with_status(status)
}
#[must_use]
pub fn error_envelope(body: &str) -> Option<ErrorEnvelope> {
let value = serde_json::from_str::<Value>(body.trim()).ok()?;
error_envelope_value(&value)
}
#[must_use]
pub fn error_envelope_value(value: &Value) -> Option<ErrorEnvelope> {
let Value::Object(object) = value else {
return None;
};
match object.get("error") {
Some(Value::Object(error)) => Some(ErrorEnvelope::from_object(error)),
Some(Value::String(message)) => Some(ErrorEnvelope {
message: non_empty(message),
..ErrorEnvelope::default()
}),
Some(_) => None,
None if object.get("message").is_some_and(Value::is_string) => {
Some(ErrorEnvelope::from_object(object))
}
None => None,
}
}
#[must_use]
pub fn malformed_response(status: u16, body: &[u8], err: impl std::fmt::Display) -> Error {
Error::unexpected_response("the response body could not be decoded")
.authored()
.with_status(status)
.with_upstream_message(err.to_string())
.with_body_opt(
(!body.is_empty())
.then(|| String::from_utf8_lossy(body))
.as_deref(),
)
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct ErrorEnvelope {
pub message: Option<String>,
pub code: Option<String>,
pub error_type: Option<String>,
pub param: Option<String>,
}
impl ErrorEnvelope {
#[must_use]
pub fn apply(self, error: Error) -> Error {
error
.with_upstream_message_opt(self.message.as_deref())
.with_provider_code_opt(self.code.as_deref())
.with_provider_type_opt(self.error_type.as_deref())
.with_param_opt(self.param.as_deref())
}
fn from_object(object: &Map<String, Value>) -> Self {
Self {
message: object
.get("message")
.and_then(Value::as_str)
.and_then(non_empty),
code: object.get("code").and_then(scalar_text),
error_type: object.get("type").and_then(scalar_text),
param: object.get("param").and_then(scalar_text),
}
}
}
fn scalar_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => non_empty(text),
Value::Number(number) => Some(number.to_string()),
_ => None,
}
}
fn non_empty(text: &str) -> Option<String> {
(!text.trim().is_empty()).then(|| text.to_owned())
}
fn retry_directive(value: &str) -> Option<bool> {
let value = value.trim();
if value.eq_ignore_ascii_case("true") {
Some(true)
} else if value.eq_ignore_ascii_case("false") {
Some(false)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn envelope(message: &str, code: Option<&str>, error_type: Option<&str>) -> ErrorEnvelope {
ErrorEnvelope {
message: Some(message.into()),
code: code.map(Into::into),
error_type: error_type.map(Into::into),
param: None,
}
}
fn headers(pairs: &[(&str, &str)]) -> Headers {
pairs.iter().copied().collect()
}
#[test]
fn error_context_reads_status_and_headers() {
let context = error_context(
409,
&headers(&[
("X-Request-Id", "req_1"),
("Retry-After", "3"),
("X-Should-Retry", "true"),
]),
);
assert_eq!(context.status, Some(409));
assert_eq!(context.request_id.as_deref(), Some("req_1"));
assert_eq!(context.retry_after, Some(std::time::Duration::from_secs(3)));
assert_eq!(context.server_retry_directive, Some(true));
let mut rest = context;
rest.status = None;
rest.request_id = None;
rest.retry_after = None;
rest.server_retry_directive = None;
assert_eq!(rest, ErrorContext::new());
let mut bare = error_context(500, &Headers::new());
assert_eq!(bare.status, Some(500));
bare.status = None;
assert_eq!(bare, ErrorContext::new());
}
#[test]
fn error_context_request_id_headers() {
let anthropic = error_context(429, &headers(&[("request-id", "req_a")]));
assert_eq!(anthropic.request_id.as_deref(), Some("req_a"));
let both = error_context(
429,
&headers(&[("request-id", "req_a"), ("x-request-id", "req_x")]),
);
assert_eq!(both.request_id.as_deref(), Some("req_x"));
let empty = error_context(
429,
&headers(&[("x-request-id", " "), ("request-id", "req_a")]),
);
assert_eq!(empty.request_id.as_deref(), Some("req_a"));
}
#[test]
fn error_context_retry_directive_values() {
for (value, expected) in [
("true", Some(true)),
("false", Some(false)),
(" TRUE ", Some(true)),
("False", Some(false)),
("1", None),
("yes", None),
("", None),
] {
let context = error_context(503, &headers(&[("x-should-retry", value)]));
assert_eq!(context.server_retry_directive, expected, "{value:?}");
}
let context = error_context(429, &headers(&[("retry-after-ms", "1500")]));
assert_eq!(
context.retry_after,
Some(std::time::Duration::from_millis(1500))
);
}
#[test]
fn status_error_kinds() {
for (status, kind) in [
(400, "invalid_request"),
(401, "auth"),
(402, "quota_exhausted"),
(403, "auth"),
(404, "invalid_request"),
(408, "timeout"),
(409, "invalid_request"),
(413, "invalid_request"),
(422, "invalid_request"),
(429, "rate_limited"),
(499, "provider"),
(500, "provider"),
(502, "provider"),
(503, "overloaded"),
(504, "timeout"),
(524, "timeout"),
(529, "overloaded"),
(600, "provider"),
] {
let err = status_error(status, format!("HTTP {status}"));
assert_eq!(err.kind_name(), kind, "HTTP {status}");
assert_eq!(err.status(), Some(status));
assert_eq!(err.message(), format!("HTTP {status}"));
}
}
#[test]
fn a_closed_request_is_neither_retried_nor_blamed_on_the_request() {
let err = status_error(499, "HTTP 499");
assert!(matches!(err, Error::Provider { .. }), "{err:?}");
assert!(!err.is_retryable());
assert!(!err.is_transient());
}
#[test]
fn timeouts_are_retryable() {
for status in [408, 504, 524] {
assert!(status_error(status, "x").is_retryable(), "{status}");
}
}
#[test]
fn malformed_responses_keep_the_body_out_of_the_log() {
let body = b"<html>my SSN is 123-45-6789</html>";
let err = malformed_response(200, body, "expected value at line 1 column 1");
assert!(matches!(err, Error::UnexpectedResponse { .. }), "{err:?}");
assert!(!err.is_retryable());
assert_eq!(err.status(), Some(200));
assert_eq!(
err.upstream_message(),
Some("expected value at line 1 column 1")
);
assert_eq!(
err.context().unwrap().body.as_deref(),
Some("<html>my SSN is 123-45-6789</html>")
);
assert!(!err.as_log().to_string().contains("SSN"));
assert_eq!(
malformed_response(200, b"", "EOF").context().unwrap().body,
None
);
}
#[test]
fn openai_shape() {
let parsed = error_envelope(
r#"{"error": {"message": "Invalid value", "type": "invalid_request_error", "code": "invalid_value", "param": "messages.[1].content"}}"#,
)
.unwrap();
assert_eq!(
parsed,
ErrorEnvelope {
param: Some("messages.[1].content".into()),
..envelope(
"Invalid value",
Some("invalid_value"),
Some("invalid_request_error")
)
}
);
}
#[test]
fn current_vllm_and_ollama_v1_shape() {
let vllm = error_envelope(
r#"{"error": {"message": "max_tokens must be at least 1", "type": "BadRequestError", "param": null, "code": 400}}"#,
)
.unwrap();
assert_eq!(
vllm,
envelope(
"max_tokens must be at least 1",
Some("400"),
Some("BadRequestError")
)
);
let ollama = error_envelope(
r#"{"error": {"message": "model \"x\" not found", "type": "api_error", "param": null, "code": null}}"#,
)
.unwrap();
assert_eq!(
ollama,
envelope("model \"x\" not found", None, Some("api_error"))
);
}
#[test]
fn legacy_vllm_shape() {
let parsed = error_envelope(
r#"{"object": "error", "message": "bad request", "type": "BadRequestError", "param": null, "code": 400}"#,
)
.unwrap();
assert_eq!(
parsed,
envelope("bad request", Some("400"), Some("BadRequestError"))
);
}
#[test]
fn native_ollama_shape() {
let parsed = error_envelope(r#"{"error": "model 'x' not found"}"#).unwrap();
assert_eq!(parsed, envelope("model 'x' not found", None, None));
}
#[test]
fn a_parsed_body_reads_as_its_text() {
let bodies = [
r#"{"error": {"message": "bad", "type": "invalid_request_error", "code": "x"}}"#,
r#"{"object": "error", "message": "bad", "type": "BadRequestError", "code": 400}"#,
r#"{"error": "model 'x' not found"}"#,
r#"{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}"#,
r#"{"message": {"role": "assistant", "content": "hi"}}"#,
r#"{"error": 3}"#,
r#"["error"]"#,
r#""error""#,
];
for body in bodies {
let value: Value = serde_json::from_str(body).unwrap();
assert_eq!(error_envelope_value(&value), error_envelope(body), "{body}");
}
}
#[test]
fn anthropic_shape_reads_as_openai() {
let parsed = error_envelope(
r#"{"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}, "request_id": "req_1"}"#,
)
.unwrap();
assert_eq!(
parsed,
envelope("Overloaded", None, Some("overloaded_error"))
);
}
#[test]
fn unknown_bodies_are_not_envelopes() {
for body in [
"",
" ",
"<html>my SSN is 123-45-6789</html>",
"upstream connect error",
"[1, 2]",
r#"{"detail": "Not Found"}"#,
r#"{"error": 42}"#,
r#"{"message": 42}"#,
] {
assert_eq!(error_envelope(body), None, "{body}");
}
}
#[test]
fn empty_messages_are_absent() {
let parsed =
error_envelope(r#"{"error": {"message": "", "code": "server_error"}}"#).unwrap();
assert_eq!(parsed.message, None);
assert_eq!(parsed.code.as_deref(), Some("server_error"));
}
#[test]
fn apply_keeps_upstream_text_out_of_the_log() {
let err = error_envelope(
r#"{"error": {"message": "SENTINEL", "type": "invalid_request_error", "code": "bad", "param": "tools[0].function.parameters"}}"#,
)
.unwrap()
.apply(Error::invalid_request("HTTP 400").authored());
assert_eq!(err.upstream_message(), Some("SENTINEL"));
let context = err.context().unwrap();
assert_eq!(context.provider_code.as_deref(), Some("bad"));
assert_eq!(
context.provider_type.as_deref(),
Some("invalid_request_error")
);
assert_eq!(
context.param.as_deref(),
Some("tools[0].function.parameters")
);
assert_eq!(
err.as_log().to_string(),
r#"kind=invalid_request provider_code=bad provider_type=invalid_request_error param=tools[0].function.parameters message="HTTP 400""#
);
}
}