use super::ProviderResponseError;
use http::StatusCode;
#[test]
fn funnel_preserves_status_and_body() {
type E = crate::error::ProviderError;
let body = r#"{"error":{"message":"boom"}}"#;
let err = E::from_http_response(StatusCode::SERVICE_UNAVAILABLE, body);
assert!(
matches!(err, E::ProviderResponse(_)),
"a provider's reply is a ProviderResponse",
);
assert_eq!(
err.provider_response_status(),
Some(StatusCode::SERVICE_UNAVAILABLE),
"non-success status not preserved",
);
assert_eq!(
err.provider_response_body(),
Some(body),
"non-success body not preserved",
);
assert_eq!(
err.provider_response_json()
.expect("valid json")
.expect("present json")["error"]["message"],
"boom",
);
assert_eq!(err.provider_request_id(), None);
let err = E::from_http_response(StatusCode::OK, body);
assert_eq!(
err.provider_response_status(),
Some(StatusCode::OK),
"2xx envelope status not preserved",
);
assert_eq!(err.provider_response_body(), Some(body));
let err = E::from_provider_body(body);
assert_eq!(
err.provider_response_status(),
None,
"status should be None for provider body",
);
assert_eq!(err.provider_response_body(), Some(body));
let err = E::from_provider_body("");
assert_eq!(err.provider_response_body(), Some(""));
assert!(err.provider_response_json().expect("ok").is_none());
let err = E::from_transport_error(crate::http_client::Error::InvalidStatusCodeWithDetails {
status: StatusCode::TOO_MANY_REQUESTS,
body: body.to_string(),
headers: retry_after_headers(),
});
assert!(matches!(err, E::ProviderResponse(_)));
assert_eq!(err.provider_response_body(), Some(body));
assert_eq!(
err.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
);
let err = E::from_transport_error(crate::http_client::Error::StreamEnded);
assert!(matches!(err, E::Http(_)));
assert_eq!(err.provider_response_status(), None);
let err: E = crate::http_client::Error::non_success_with_details(
StatusCode::NOT_FOUND,
http::HeaderMap::new(),
body.to_string(),
)
.into();
assert!(matches!(err, E::ProviderResponse(_)));
for err in [
E::from_http_response(StatusCode::TOO_MANY_REQUESTS, body),
E::from_http_response(StatusCode::OK, body),
E::from_provider_body(body),
E::from_http_response(StatusCode::TOO_MANY_REQUESTS, body)
.with_provider_request_id(Some("req_abc".to_string())),
] {
assert!(
err.provider_response_headers().is_none(),
"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 without_id = E::from_http_response(StatusCode::TOO_MANY_REQUESTS, body)
.with_response_headers(Some(retry_after_headers()));
let with_id = E::from_http_response(StatusCode::TOO_MANY_REQUESTS, body)
.with_provider_request_id(Some("req_abc".to_string()))
.with_response_headers(Some(retry_after_headers()));
for (label, err) in [("without id", without_id), ("with id", with_id)] {
assert_eq!(
err.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
"{label}: captured Retry-After not surfaced",
);
assert_eq!(
err.provider_response_status(),
Some(StatusCode::TOO_MANY_REQUESTS),
"{label}: status lost when headers were attached",
);
assert_eq!(
err.provider_response_body(),
Some(body),
"{label}: body lost when headers were attached",
);
}
}
fn retry_after_headers() -> 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"));
headers
}
#[test]
fn stamping_a_request_id_keeps_status_body_and_names_the_id() {
let error = crate::error::ProviderError::from_http_response(
StatusCode::NOT_FOUND,
r#"{"error":"nope"}"#,
)
.with_provider_request_id(Some("req_abc".to_string()));
assert!(matches!(
error,
crate::error::ProviderError::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 an_absent_id_leaves_the_message_unchanged() {
for id in [None, Some(String::new())] {
let error = crate::error::ProviderError::from_http_response(StatusCode::BAD_REQUEST, "bad")
.with_provider_request_id(id);
assert_eq!(error.provider_request_id(), None);
assert!(!error.to_string().contains("request id"));
}
}
#[test]
fn stamping_never_overwrites_an_earlier_id_and_is_a_no_op_without_a_slot() {
let error = crate::error::ProviderError::from_http_response(StatusCode::BAD_REQUEST, "bad")
.with_provider_request_id(Some("first".to_string()))
.with_provider_request_id(Some("second".to_string()));
assert_eq!(error.provider_request_id(), Some("first"));
let error = crate::error::ProviderError::Provider("rig diagnostic".to_string())
.with_provider_request_id(Some("req_abc".to_string()));
assert!(matches!(error, crate::error::ProviderError::Provider(_)));
assert_eq!(error.provider_request_id(), None);
}
#[test]
fn request_id_and_headers_coexist_on_one_error() {
let error = crate::error::ProviderError::from_http_response(
StatusCode::TOO_MANY_REQUESTS,
r#"{"error":"slow down"}"#,
)
.with_provider_request_id(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_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::error::ProviderError::from_http_response,
|status, body| {
crate::error::ProviderError::from_http_response(status, body)
.with_provider_request_id(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(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::error::ProviderError::Provider("rig diagnostic".to_string())
.with_response_headers(Some(retry_after_headers()));
assert!(matches!(error, crate::error::ProviderError::Provider(_)));
assert!(error.provider_response_headers().is_none());
assert_eq!(error.to_string(), "ProviderError: rig diagnostic");
let error = crate::error::ProviderError::Http(crate::http_client::Error::StreamEnded.into())
.with_response_headers(Some(retry_after_headers()));
assert!(matches!(
error,
crate::error::ProviderError::Http(ref error)
if matches!(**error, crate::http_client::Error::StreamEnded)
));
assert!(error.provider_response_headers().is_none());
}
#[test]
fn display_goldens_for_error_shapes() {
let with_id = crate::error::ProviderError::from_http_response(
StatusCode::NOT_FOUND,
r#"{"error":"nope"}"#,
)
.with_provider_request_id(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::error::ProviderError::from_http_response(
StatusCode::NOT_FOUND,
r#"{"error":"nope"}"#,
);
assert_eq!(
without_id.to_string(),
r#"ProviderResponseError: status 404 Not Found: {"error":"nope"}"#
);
let dropped =
crate::error::ProviderError::from_transport_error(crate::http_client::Error::StreamEnded);
assert_eq!(dropped.to_string(), "HttpError: Stream ended");
let details = crate::http_client::Error::InvalidStatusCodeWithDetails {
status: StatusCode::NOT_FOUND,
body: "x".to_string(),
headers: http::HeaderMap::new(),
};
assert_eq!(
details.to_string(),
"Invalid status code 404 Not Found with message: x"
);
for build in [
crate::error::ProviderError::from_http_response,
|status, body| {
crate::error::ProviderError::from_http_response(status, body)
.with_provider_request_id(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);
}
}
#[test]
fn provider_response_error_round_trips_its_identity_and_not_its_headers() {
use super::ProviderResponseError;
let mut headers = http::HeaderMap::new();
headers.append("retry-after", http::HeaderValue::from_static("7"));
headers.append(
"date",
http::HeaderValue::from_static("Thu, 03 Sep 2026 21:39:09 GMT"),
);
let error = ProviderResponseError::new(http::StatusCode::TOO_MANY_REQUESTS, "slow")
.with_provider_request_id(Some("req-1".into()))
.with_headers(Some(headers));
let json = serde_json::to_string(&error).unwrap();
assert!(!json.contains("headers"), "{json}");
let back: ProviderResponseError = serde_json::from_str(&json).unwrap();
assert_eq!(back, error.clone().with_headers(None));
assert!(
back.headers.is_none(),
"a decoded error captured no headers"
);
assert!(
serde_json::from_str::<ProviderResponseError>(
r#"{"status":429,"body":"slow","provider_request_id":null,"headers":[]}"#
)
.is_err()
);
let bare = ProviderResponseError::without_status("gone");
let back: ProviderResponseError =
serde_json::from_str(&serde_json::to_string(&bare).unwrap()).unwrap();
assert_eq!(back, bare);
let bad = r#"{"status":99,"body":"","provider_request_id":null,"headers":null}"#;
assert!(serde_json::from_str::<ProviderResponseError>(bad).is_err());
}
#[test]
fn a_body_borne_transient_code_is_retryable_without_a_status() {
let overloaded = ProviderResponseError::without_status(
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
);
assert_eq!(
overloaded.machine_code().as_deref(),
Some("overloaded_error")
);
assert!(overloaded.is_retryable());
let throttled = ProviderResponseError::without_status(
r#"{"error":{"type":"rate_limit_error","message":"slow down"}}"#,
);
assert!(throttled.is_retryable());
let grpc = ProviderResponseError::without_status(
r#"{"error":{"status":"UNAVAILABLE","message":"backend unavailable"}}"#,
);
assert!(grpc.is_retryable(), "the same condition, spelled by gRPC");
let quota = ProviderResponseError::without_status(
r#"{"error":{"type":"insufficient_quota","message":"billing"}}"#,
);
assert!(!quota.is_retryable());
let unknown = ProviderResponseError::without_status(
r#"{"error":{"type":"invalid_request_error","message":"bad"}}"#,
);
assert!(!unknown.is_retryable());
let hinted = ProviderResponseError::without_status(
r#"{"error":{"type":"overloaded_error","message":"Overloaded"}}"#,
)
.with_transient(Some(false));
assert!(hinted.is_retryable());
let refusal = ProviderResponseError::without_status(
r#"{"error":{"type":"server_error","message":"blocked"}}"#,
)
.with_refusal(true);
assert!(!refusal.is_retryable());
}
#[test]
fn the_envelope_locator_reads_every_shape_once() {
let cases: &[(&str, Option<&str>, Option<u16>, bool)] = &[
(
r#"{"type":"response.failed","response":{"error":{"code":"server_error","message":"x"}}}"#,
Some("server_error"),
None,
true,
),
(
r#"{"type":"response.failed","response":{"error":{"code":"invalid_prompt","message":"x"}}}"#,
Some("invalid_prompt"),
None,
false,
),
(
r#"{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}"#,
Some("overloaded_error"),
None,
true,
),
(
r#"{"error":{"code":"rate_limit_exceeded","type":"requests"}}"#,
Some("rate_limit_exceeded"),
None,
true,
),
(
r#"{"error":{"code":503,"status":"UNAVAILABLE","message":"x"}}"#,
Some("UNAVAILABLE"),
Some(503),
true,
),
(
r#"{"error":{"code":500,"status":"INTERNAL","message":"x"}}"#,
Some("INTERNAL"),
Some(500),
true,
),
(r#"{"error":{"code":429}}"#, Some("429"), Some(429), true),
(r#"{"error":{"code":400}}"#, Some("400"), Some(400), false),
(r#"{"error":{"code":"503"}}"#, Some("503"), None, false),
(r#"{"error":{"code":14}}"#, Some("14"), None, false),
(r#"{"error":{"code":200}}"#, Some("200"), None, false),
(r#"{"error":"model failed to load"}"#, None, None, false),
(
r#"{"type":"error","code":"server_error","message":"x"}"#,
Some("server_error"),
None,
true,
),
(
r#"{"type":"error","code":503,"message":"x"}"#,
Some("503"),
None,
true,
),
(r#"{"type":"error","message":"x"}"#, None, None, false),
(r#"{"error":null}"#, None, None, false),
(r#"{"error":{}}"#, None, None, false),
(r#"{"error":""}"#, None, None, false),
(
r#"{"type":"response.failed","response":{"error":null}}"#,
None,
None,
false,
),
("plain text", None, None, false),
("", None, None, false),
];
for (body, code, status, retryable) in cases {
let reply = ProviderResponseError::from_body(*body);
assert_eq!(reply.machine_code().as_deref(), *code, "{body}");
assert_eq!(reply.status.map(|s| s.as_u16()), *status, "{body}");
assert_eq!(reply.is_retryable(), *retryable, "{body}");
let report = crate::error::ProviderError::from_provider_body(*body).report();
assert_eq!(report.http_status, *status, "{body}");
assert_eq!(report.retryable, *retryable, "{body}");
assert_eq!(report.code.as_deref(), *code, "{body}");
}
}
#[test]
fn the_code_table_decides_known_codes_in_any_case() {
for (code, retryable) in [
("UNAVAILABLE", true),
("RESOURCE_EXHAUSTED", true),
("DEADLINE_EXCEEDED", true),
("ABORTED", true),
("INTERNAL", false),
("ThrottlingException", true),
("ModelStreamErrorException", true),
("ValidationException", false),
("insufficient_quota", false),
("Insufficient_Quota", false),
("server_is_overloaded", true),
("slow_down", true),
] {
let reply = ProviderResponseError::without_status("opaque transport text")
.with_code(Some(code.to_owned()));
assert_eq!(reply.is_retryable(), retryable, "{code}");
}
}
#[test]
fn a_known_code_outranks_the_hint_and_a_status_outranks_the_code() {
let overloaded = r#"{"error":{"type":"overloaded_error"}}"#;
let quota = r#"{"error":{"type":"insufficient_quota"}}"#;
let unknown = r#"{"error":{"type":"mystery"}}"#;
assert!(
ProviderResponseError::without_status(overloaded)
.with_transient(Some(false))
.is_retryable()
);
assert!(
!ProviderResponseError::without_status(quota)
.with_transient(Some(true))
.is_retryable()
);
assert!(
ProviderResponseError::without_status(unknown)
.with_transient(Some(true))
.is_retryable()
);
assert!(!ProviderResponseError::without_status(unknown).is_retryable());
assert!(
!ProviderResponseError::without_status(overloaded)
.with_code(Some("mystery".to_owned()))
.is_retryable()
);
assert!(!ProviderResponseError::new(StatusCode::BAD_REQUEST, overloaded).is_retryable());
assert!(ProviderResponseError::new(StatusCode::SERVICE_UNAVAILABLE, quota).is_retryable());
assert!(ProviderResponseError::new(StatusCode::OK, overloaded).is_retryable());
assert!(
!ProviderResponseError::new(StatusCode::SERVICE_UNAVAILABLE, overloaded)
.with_refusal(true)
.is_retryable()
);
}
#[test]
fn a_deserialized_record_re_derives_its_verdict_from_the_body() {
let reply = ProviderResponseError::from_body(r#"{"error":{"type":"overloaded_error"}}"#);
let Ok(wire) = serde_json::to_string(&reply) else {
panic!("the record serializes");
};
assert!(!wire.contains("transient"), "{wire}");
let Ok(back) = serde_json::from_str::<ProviderResponseError>(&wire) else {
panic!("the record reads back: {wire}");
};
assert!(back.is_retryable());
assert_eq!(back, reply);
}
#[test]
fn the_envelope_json_is_the_located_error() {
use serde_json::json;
for (body, error) in [
(
r#"{"type":"response.failed","response":{"error":{"code":"x"}}}"#,
Some(json!({"error": {"code": "x"}})),
),
(
r#"{"type":"error","error":{"type":"overloaded_error"}}"#,
Some(json!({"error": {"type": "overloaded_error"}})),
),
(r#"{"error":"boom"}"#, Some(json!({"error": "boom"}))),
(
r#"{"type":"error","code":"x"}"#,
Some(json!({"type": "error", "code": "x"})),
),
(r#"{"error":null}"#, None),
("text", None),
] {
let reply = ProviderResponseError::without_status(body);
assert_eq!(reply.envelope_json(), error, "{body}");
}
}
#[test]
fn a_bare_error_object_or_an_array_body_is_still_classified() {
let cases: &[(&str, Option<&str>, Option<u16>, bool)] = &[
(
r#"{"type":"overloaded_error","message":"x"}"#,
Some("overloaded_error"),
None,
true,
),
(
r#"{"code":503,"status":"UNAVAILABLE","message":"x"}"#,
Some("UNAVAILABLE"),
None,
true,
),
(r#"{"code":429,"message":"x"}"#, Some("429"), None, true),
(
r#"{"code":"insufficient_quota","message":"x"}"#,
Some("insufficient_quota"),
None,
false,
),
(r#"[{"error":{"code":503}}]"#, Some("503"), Some(503), true),
(
r#"[{"candidates":[]},{"error":{"code":429,"status":"RESOURCE_EXHAUSTED"}}]"#,
Some("RESOURCE_EXHAUSTED"),
Some(429),
true,
),
(r#"{"type":"overloaded_error"}"#, None, None, false),
(
r#"{"type":"response.failed","message":"x","response":{"error":null}}"#,
None,
None,
false,
),
(
r#"{"type":"x","message":"x","error":null}"#,
None,
None,
false,
),
("[]", None, None, false),
];
for (body, code, status, retryable) in cases {
let reply = ProviderResponseError::from_body(*body);
assert_eq!(reply.machine_code().as_deref(), *code, "{body}");
assert_eq!(reply.status.map(|s| s.as_u16()), *status, "{body}");
assert_eq!(reply.is_retryable(), *retryable, "{body}");
}
let bare = ProviderResponseError::from_body(r#"{"type":"overloaded_error","message":"x"}"#);
assert_eq!(bare.envelope_json(), None);
let hinted = crate::error::ProviderError::from_provider_body(
r#"{"type":"overloaded_error","message":"x"}"#,
)
.with_transient(Some(false));
assert!(hinted.report().retryable);
}