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());
}