use http::StatusCode;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResponseError {
pub status: Option<StatusCode>,
pub body: String,
pub provider_request_id: Option<String>,
pub headers: Option<Box<http::HeaderMap>>,
}
impl ProviderResponseError {
pub fn new(status: StatusCode, body: impl Into<String>) -> Self {
Self {
status: Some(status),
body: body.into(),
provider_request_id: None,
headers: None,
}
}
pub fn without_status(body: impl Into<String>) -> Self {
Self {
status: None,
body: body.into(),
provider_request_id: None,
headers: None,
}
}
pub fn with_provider_request_id(mut self, request_id: Option<String>) -> Self {
self.provider_request_id = request_id.filter(|id| !id.is_empty());
self
}
pub fn with_headers(mut self, headers: Option<Box<http::HeaderMap>>) -> Self {
self.headers = headers;
self
}
}
impl std::fmt::Display for ProviderResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.status {
Some(status) => write!(f, "status {status}: {}", self.body)?,
None => write!(f, "{}", self.body)?,
}
if let Some(request_id) = &self.provider_request_id {
write!(f, " (request id: {request_id})")?;
}
Ok(())
}
}
impl std::error::Error for ProviderResponseError {}
pub(crate) fn json(body: Option<&str>) -> Result<Option<serde_json::Value>, serde_json::Error> {
body.filter(|body| !body.is_empty())
.map(serde_json::from_str)
.transpose()
}
pub(crate) fn completion_error_from_body(
body: impl Into<String>,
) -> crate::completion::CompletionError {
crate::completion::CompletionError::ProviderResponse(ProviderResponseError::without_status(
body,
))
}
macro_rules! impl_provider_response_helpers {
($error:ty) => {
impl $error {
pub fn from_http_response(status: http::StatusCode, body: impl Into<String>) -> Self {
if status.is_success() {
Self::ProviderResponse($crate::provider_response::ProviderResponseError::new(
status, body,
))
} else {
Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithMessage(
status,
body.into(),
))
}
}
pub fn from_http_response_with_request_id(
status: http::StatusCode,
body: impl Into<String>,
provider_request_id: Option<String>,
) -> Self {
Self::ProviderResponse(
$crate::provider_response::ProviderResponseError::new(status, body)
.with_provider_request_id(provider_request_id),
)
}
pub fn with_response_headers(self, headers: Option<Box<http::HeaderMap>>) -> Self {
let Some(headers) = headers else {
return self;
};
match self {
Self::ProviderResponse(response) if response.headers.is_none() => {
Self::ProviderResponse(response.with_headers(Some(headers)))
}
Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithMessage(
status,
body,
)) => {
Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithDetails {
status,
body,
headers,
})
}
other => other,
}
}
pub fn from_provider_body(body: impl Into<String>) -> Self {
Self::ProviderResponse(
$crate::provider_response::ProviderResponseError::without_status(body),
)
}
pub fn provider_response_body(&self) -> Option<&str> {
match self {
Self::ProviderResponse(response) => Some(response.body.as_str()),
Self::HttpError(error) => error.non_success_body(),
_ => None,
}
}
pub fn provider_response_json(
&self,
) -> Result<Option<serde_json::Value>, serde_json::Error> {
$crate::provider_response::json(self.provider_response_body())
}
pub fn provider_response_status(&self) -> Option<http::StatusCode> {
match self {
Self::ProviderResponse(response) => response.status,
Self::HttpError(error) => error.non_success_status(),
_ => None,
}
}
pub fn provider_request_id(&self) -> Option<&str> {
match self {
Self::ProviderResponse(response) => response.provider_request_id.as_deref(),
_ => None,
}
}
pub fn provider_response_headers(&self) -> Option<&http::HeaderMap> {
match self {
Self::ProviderResponse(response) => response.headers.as_deref(),
Self::HttpError(error) => error.non_success_headers(),
_ => None,
}
}
}
};
}
pub(crate) use impl_provider_response_helpers;
macro_rules! response_metadata_setters {
($ty:ty) => {
impl $ty {
pub fn with_message_id(self, message_id: impl Into<String>) -> Self {
self.with_optional_message_id(Some(message_id.into()))
}
pub fn with_optional_message_id(
mut self,
message_id: Option<impl Into<String>>,
) -> Self {
self.message_id = message_id.map(Into::into).filter(|id| !id.is_empty());
self
}
pub fn with_response_id(self, response_id: impl Into<String>) -> Self {
self.with_optional_response_id(Some(response_id.into()))
}
pub fn with_optional_response_id(
mut self,
response_id: Option<impl Into<String>>,
) -> Self {
self.response_id = response_id.map(Into::into).filter(|id| !id.is_empty());
self
}
pub fn with_provider_request_id(self, request_id: impl Into<String>) -> Self {
self.with_optional_provider_request_id(Some(request_id.into()))
}
pub fn with_optional_provider_request_id(
mut self,
request_id: Option<impl Into<String>>,
) -> Self {
self.provider_request_id = request_id.map(Into::into).filter(|id| !id.is_empty());
self
}
pub fn with_model(self, model: impl Into<String>) -> Self {
self.with_optional_model(Some(model.into()))
}
pub fn with_optional_model(mut self, model: Option<impl Into<String>>) -> Self {
self.model = model.map(Into::into).filter(|model| !model.is_empty());
self
}
pub fn with_raw(mut self, raw: impl Into<serde_json::Value>) -> Self {
self.raw = raw.into();
self
}
}
};
}
pub(crate) use response_metadata_setters;
macro_rules! provider_error_enum {
(
$(#[$extra_doc:meta])*
$name:ident, $noun:literal {
$($mid_variants:tt)*
}
$({ $($late_variants:tt)* })?
) => {
#[doc = concat!("Errors returned by ", $noun, " models.")]
$(#[$extra_doc])*
#[derive(Debug, thiserror::Error)]
pub enum $name {
#[error("HttpError: {0}")]
HttpError(#[from] $crate::http_client::Error),
#[error("JsonError: {0}")]
JsonError(#[from] serde_json::Error),
$($mid_variants)*
#[doc = concat!("Error parsing the ", $noun, " response")]
#[error("ResponseError: {0}")]
ResponseError(String),
$($($late_variants)*)?
#[doc = concat!("Error returned by the ", $noun, " model provider")]
#[error("ProviderError: {0}")]
ProviderError(String),
#[doc = concat!("Raw error response preserved from the ", $noun, " model provider")]
#[error("ProviderResponseError: {0}")]
ProviderResponse($crate::provider_response::ProviderResponseError),
}
$crate::provider_response::impl_provider_response_helpers!($name);
};
}
pub(crate) use provider_error_enum;
#[cfg(test)]
mod tests {
use http::StatusCode;
macro_rules! assert_funnel {
($err:ty) => {{
let body = r#"{"error":{"message":"boom"}}"#;
let err = <$err>::from_http_response(StatusCode::SERVICE_UNAVAILABLE, body);
assert_eq!(
err.provider_response_status(),
Some(StatusCode::SERVICE_UNAVAILABLE),
concat!(stringify!($err), ": non-success status not preserved"),
);
assert_eq!(
err.provider_response_body(),
Some(body),
concat!(stringify!($err), ": non-success body not preserved"),
);
assert_eq!(
err.provider_response_json()
.expect("valid json")
.expect("present json")["error"]["message"],
"boom",
);
let err = <$err>::from_http_response(StatusCode::OK, body);
assert_eq!(
err.provider_response_status(),
Some(StatusCode::OK),
concat!(stringify!($err), ": 2xx envelope status not preserved"),
);
assert_eq!(err.provider_response_body(), Some(body));
let err = <$err>::from_provider_body(body);
assert_eq!(
err.provider_response_status(),
None,
concat!(
stringify!($err),
": status should be None for provider body"
),
);
assert_eq!(err.provider_response_body(), Some(body));
let err = <$err>::from_provider_body("");
assert_eq!(err.provider_response_body(), Some(""));
assert!(err.provider_response_json().expect("ok").is_none());
for err in [
<$err>::from_http_response(StatusCode::TOO_MANY_REQUESTS, body),
<$err>::from_http_response(StatusCode::OK, body),
<$err>::from_provider_body(body),
<$err>::from_http_response_with_request_id(
StatusCode::TOO_MANY_REQUESTS,
body,
Some("req_abc".to_string()),
),
] {
assert!(
err.provider_response_headers().is_none(),
concat!(stringify!($err), ": a funnel cannot invent headers"),
);
let untouched = err.with_response_headers(None);
assert!(untouched.provider_response_headers().is_none());
assert_eq!(untouched.provider_response_body(), Some(body));
}
let contract_less = <$err>::from_http_response(StatusCode::TOO_MANY_REQUESTS, body)
.with_response_headers(Some(retry_after_headers()));
let contract = <$err>::from_http_response_with_request_id(
StatusCode::TOO_MANY_REQUESTS,
body,
Some("req_abc".to_string()),
)
.with_response_headers(Some(retry_after_headers()));
for (label, err) in [("contract-less", contract_less), ("contract", contract)] {
let err_ty = stringify!($err);
assert_eq!(
err.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
"{err_ty}/{label}: captured Retry-After not surfaced",
);
assert_eq!(
err.provider_response_status(),
Some(StatusCode::TOO_MANY_REQUESTS),
"{err_ty}/{label}: status lost when headers were attached",
);
assert_eq!(
err.provider_response_body(),
Some(body),
"{err_ty}/{label}: body lost when headers were attached",
);
}
}};
}
fn retry_after_headers() -> Box<http::HeaderMap> {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::RETRY_AFTER,
http::HeaderValue::from_static("20"),
);
headers.insert("x-ratelimit-remaining", http::HeaderValue::from_static("0"));
Box::new(headers)
}
#[test]
fn funnel_preserves_status_and_body_for_every_capability_error() {
assert_funnel!(crate::completion::CompletionError);
assert_funnel!(crate::embeddings::embedding::EmbeddingError);
assert_funnel!(crate::transcription::TranscriptionError);
assert_funnel!(crate::client::verify::VerifyError);
assert_funnel!(crate::rerank::RerankError);
#[cfg(feature = "image")]
assert_funnel!(crate::image_generation::ImageGenerationError);
#[cfg(feature = "audio")]
assert_funnel!(crate::audio_generation::AudioGenerationError);
}
#[test]
fn with_request_id_funnel_preserves_non_success_as_provider_response() {
let error = crate::completion::CompletionError::from_http_response_with_request_id(
StatusCode::NOT_FOUND,
r#"{"error":"nope"}"#,
Some("req_abc".to_string()),
);
assert!(matches!(
error,
crate::completion::CompletionError::ProviderResponse(_)
));
assert_eq!(
error.provider_response_status(),
Some(StatusCode::NOT_FOUND)
);
assert_eq!(error.provider_response_body(), Some(r#"{"error":"nope"}"#));
assert_eq!(error.provider_request_id(), Some("req_abc"));
assert!(
error.to_string().contains("request id: req_abc"),
"the id support asks for appears in the message: {error}"
);
}
#[test]
fn with_request_id_funnel_tolerates_absent_id() {
let error = crate::completion::CompletionError::from_http_response_with_request_id(
StatusCode::BAD_REQUEST,
"bad",
None,
);
assert_eq!(error.provider_request_id(), None);
assert!(!error.to_string().contains("request id"));
}
#[test]
fn metadata_less_funnel_classification_is_unchanged() {
let error =
crate::completion::CompletionError::from_http_response(StatusCode::BAD_REQUEST, "bad");
assert!(matches!(
error,
crate::completion::CompletionError::HttpError(_)
));
assert_eq!(error.provider_request_id(), None);
}
#[test]
fn request_id_and_headers_coexist_on_one_error() {
let error = crate::completion::CompletionError::from_http_response_with_request_id(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":"slow down"}"#,
Some("req_abc".to_string()),
)
.with_response_headers(Some(retry_after_headers()));
assert_eq!(error.provider_request_id(), Some("req_abc"));
assert_eq!(
error
.provider_response_headers()
.and_then(|headers| headers.get("x-ratelimit-remaining"))
.and_then(|value| value.to_str().ok()),
Some("0"),
);
}
#[test]
fn attaching_headers_upgrades_the_transport_variant_in_place() {
let error = crate::completion::CompletionError::from_http_response(
StatusCode::TOO_MANY_REQUESTS,
"slow down",
)
.with_response_headers(Some(retry_after_headers()));
assert!(matches!(
error,
crate::completion::CompletionError::HttpError(
crate::http_client::Error::InvalidStatusCodeWithDetails { .. }
),
));
assert_eq!(
error.provider_response_status(),
Some(StatusCode::TOO_MANY_REQUESTS)
);
assert_eq!(error.provider_response_body(), Some("slow down"));
assert_eq!(error.provider_request_id(), None);
}
#[test]
fn attaching_headers_never_overwrites_an_earlier_capture() {
let mut later = http::HeaderMap::new();
later.insert(http::header::RETRY_AFTER, "999".parse().expect("value"));
for build in [
crate::completion::CompletionError::from_http_response,
|status, body| {
crate::completion::CompletionError::from_http_response_with_request_id(
status,
body,
Some("req_abc".to_string()),
)
},
] {
let error = build(StatusCode::TOO_MANY_REQUESTS, "slow down")
.with_response_headers(Some(retry_after_headers()))
.with_response_headers(Some(Box::new(later.clone())));
assert_eq!(
error
.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
"the first capture must win",
);
}
}
#[test]
fn attaching_headers_to_a_slotless_variant_is_a_no_op() {
let error = crate::completion::CompletionError::ProviderError("rig diagnostic".to_string())
.with_response_headers(Some(retry_after_headers()));
assert!(matches!(
error,
crate::completion::CompletionError::ProviderError(_)
));
assert!(error.provider_response_headers().is_none());
assert_eq!(error.to_string(), "ProviderError: rig diagnostic");
}
#[test]
fn display_goldens_for_error_shapes() {
let with_id = crate::completion::CompletionError::from_http_response_with_request_id(
StatusCode::NOT_FOUND,
r#"{"error":"nope"}"#,
Some("req_abc".to_string()),
);
assert_eq!(
with_id.to_string(),
r#"ProviderResponseError: status 404 Not Found: {"error":"nope"} (request id: req_abc)"#
);
let without_id = crate::completion::CompletionError::from_http_response_with_request_id(
StatusCode::NOT_FOUND,
r#"{"error":"nope"}"#,
None,
);
assert_eq!(
without_id.to_string(),
r#"ProviderResponseError: status 404 Not Found: {"error":"nope"}"#
);
let contract_less = crate::completion::CompletionError::from_http_response(
StatusCode::NOT_FOUND,
r#"{"error":"nope"}"#,
);
assert_eq!(
contract_less.to_string(),
r#"HttpError: Invalid status code 404 Not Found with message: {"error":"nope"}"#
);
let details = crate::http_client::Error::InvalidStatusCodeWithDetails {
status: StatusCode::NOT_FOUND,
body: "x".to_string(),
headers: Box::new(http::HeaderMap::new()),
};
let message = crate::http_client::Error::InvalidStatusCodeWithMessage(
StatusCode::NOT_FOUND,
"x".to_string(),
);
assert_eq!(details.to_string(), message.to_string());
for build in [
crate::completion::CompletionError::from_http_response,
|status, body| {
crate::completion::CompletionError::from_http_response_with_request_id(
status,
body,
Some("req_abc".to_string()),
)
},
] {
let bare = build(StatusCode::TOO_MANY_REQUESTS, r#"{"error":"slow down"}"#);
let bare_text = bare.to_string();
let with_headers = build(StatusCode::TOO_MANY_REQUESTS, r#"{"error":"slow down"}"#)
.with_response_headers(Some(retry_after_headers()));
assert_eq!(with_headers.to_string(), bare_text);
}
}
}