Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions crates/rig-agent/src/completion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ impl PromptError {
}
}

/// Returns the HTTP response headers exposed by a wrapped completion error,
/// when they were captured (e.g. `Retry-After` on a 429).
pub fn provider_response_headers(&self) -> Option<&http::HeaderMap> {
match self {
Self::CompletionError(error) => error.provider_response_headers(),
_ => None,
}
}

pub(crate) fn prompt_cancelled(
chat_history: impl IntoIterator<Item = Message>,
reason: impl Into<String>,
Expand Down Expand Up @@ -133,6 +142,15 @@ impl StructuredOutputError {
_ => None,
}
}

/// Returns the HTTP response headers exposed through the wrapped prompt
/// error, when they were captured (e.g. `Retry-After` on a 429).
pub fn provider_response_headers(&self) -> Option<&http::HeaderMap> {
match self {
Self::PromptError(error) => error.provider_response_headers(),
_ => None,
}
}
}

/// High-level one-shot prompting for the classic runtime.
Expand Down Expand Up @@ -201,6 +219,7 @@ mod provider_response_tests {
http_client::Error::InvalidStatusCodeWithMessage(
http::StatusCode::UNAUTHORIZED,
body.to_string(),
None,
),
));

Expand All @@ -217,6 +236,39 @@ mod provider_response_tests {
);
}

#[test]
fn prompt_error_provider_response_helpers_forward_captured_headers() {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::RETRY_AFTER,
http::HeaderValue::from_static("20"),
);
let error = PromptError::CompletionError(CompletionError::HttpError(
http_client::Error::InvalidStatusCodeWithMessage(
http::StatusCode::TOO_MANY_REQUESTS,
r#"{"error":{"message":"rate limited"}}"#.to_string(),
Some(Box::new(headers)),
),
));

assert_eq!(
error
.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
);

let error = StructuredOutputError::PromptError(Box::new(error));
assert_eq!(
error
.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
);
}

#[test]
fn prompt_error_provider_response_helpers_forward_wrapped_completion_error() {
let body = r#"{"error":{"code":"invalid_request","message":"bad input"}}"#;
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/audio_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ mod provider_response_tests {
AudioGenerationError::HttpError(http_client::Error::InvalidStatusCodeWithMessage(
StatusCode::BAD_REQUEST,
body.to_string(),
None,
));

assert_eq!(error.provider_response_body(), Some(body));
Expand Down
12 changes: 10 additions & 2 deletions crates/rig-core/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -530,18 +530,21 @@ where
Err(VerifyError::InvalidAuthentication)
}
StatusCode::INTERNAL_SERVER_ERROR => {
let headers = Box::new(response.headers().clone());
let text = http_client::text(response).await?;
Err(VerifyError::HttpError(
http_client::Error::InvalidStatusCodeWithMessage(
StatusCode::INTERNAL_SERVER_ERROR,
text,
Some(headers),
),
))
}
status if status.as_u16() == 529 => {
let headers = Box::new(response.headers().clone());
let text = http_client::text(response).await?;
Err(VerifyError::HttpError(
http_client::Error::InvalidStatusCodeWithMessage(status, text),
http_client::Error::InvalidStatusCodeWithMessage(status, text, Some(headers)),
))
}
_ => {
Expand All @@ -550,9 +553,14 @@ where
if status.is_success() {
Ok(())
} else {
let headers = Box::new(response.headers().clone());
let text: String = String::from_utf8_lossy(&response.into_body().await?).into();
Err(VerifyError::HttpError(
http_client::Error::InvalidStatusCodeWithMessage(status, text),
http_client::Error::InvalidStatusCodeWithMessage(
status,
text,
Some(headers),
),
))
}
}
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/client/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ mod provider_response_tests {
let error = VerifyError::HttpError(http_client::Error::InvalidStatusCodeWithMessage(
StatusCode::BAD_REQUEST,
body.to_string(),
None,
));

assert_eq!(error.provider_response_body(), Some(body));
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/completion/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1346,6 +1346,7 @@ mod tests {
let error = CompletionError::HttpError(http_client::Error::InvalidStatusCodeWithMessage(
http::StatusCode::BAD_REQUEST,
body.to_string(),
None,
));

assert_eq!(error.provider_response_body(), Some(body));
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/embeddings/embedding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ mod provider_response_tests {
let error = EmbeddingError::HttpError(http_client::Error::InvalidStatusCodeWithMessage(
StatusCode::BAD_REQUEST,
body.to_string(),
None,
));

assert_eq!(error.provider_response_body(), Some(body));
Expand Down
80 changes: 76 additions & 4 deletions crates/rig-core/src/http_client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub enum Error {
#[error("Invalid status code: {0}")]
InvalidStatusCode(StatusCode),
#[error("Invalid status code {0} with message: {1}")]
InvalidStatusCodeWithMessage(StatusCode, String),
InvalidStatusCodeWithMessage(StatusCode, String, Option<Box<HeaderMap>>),
#[error("Header value outside of legal range: {0}")]
InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
#[error("Request in error state, cannot access headers")]
Expand All @@ -39,7 +39,7 @@ pub enum Error {
impl Error {
pub(crate) fn non_success_status(&self) -> Option<StatusCode> {
match self {
Self::InvalidStatusCode(status) | Self::InvalidStatusCodeWithMessage(status, _) => {
Self::InvalidStatusCode(status) | Self::InvalidStatusCodeWithMessage(status, _, _) => {
Some(*status)
}
_ => None,
Expand All @@ -48,7 +48,25 @@ impl Error {

pub(crate) fn non_success_body(&self) -> Option<&str> {
match self {
Self::InvalidStatusCodeWithMessage(_, body) => Some(body.as_str()),
Self::InvalidStatusCodeWithMessage(_, body, _) => Some(body.as_str()),
_ => None,
}
}

/// Returns the response headers of a non-success HTTP response, when they
/// were captured at error construction.
///
/// Rig's bundled HTTP clients capture the full response [`HeaderMap`]
/// whenever a non-success status error is built from a live response, so
/// rate-limit metadata such as `Retry-After` or `x-ratelimit-*` stays
/// available to callers (for example a [`retry::RetryPolicy`] deciding how
/// long to back off after a 429).
///
/// Returns `None` when the error was constructed without access to the
/// response headers (e.g. errors built from only a status and body).
pub fn non_success_headers(&self) -> Option<&HeaderMap> {
match self {
Self::InvalidStatusCodeWithMessage(_, _, headers) => headers.as_deref(),
_ => None,
}
}
Expand All @@ -68,11 +86,12 @@ fn instance_error<E: std::error::Error + 'static>(error: E) -> Error {

async fn non_success_status_error(response: reqwest::Response) -> Error {
let status = response.status();
let headers = Box::new(response.headers().clone());
let message = response
.text()
.await
.unwrap_or_else(|error| format!("failed to read error response body: {error}"));
Error::InvalidStatusCodeWithMessage(status, message)
Error::InvalidStatusCodeWithMessage(status, message, Some(headers))
}

pub type LazyBytes = WasmBoxedFuture<'static, Result<Bytes>>;
Expand Down Expand Up @@ -276,6 +295,59 @@ macro_rules! impl_http_client_ext {

impl_http_client_ext!(reqwest::Client);

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn non_success_status_error_preserves_response_headers() {
let response = http::Response::builder()
.status(StatusCode::TOO_MANY_REQUESTS)
.header("retry-after", "20")
.header("x-ratelimit-remaining", "0")
.body(r#"{"error":{"message":"rate limited"}}"#)
.expect("valid response");
let response = reqwest::Response::from(response);

let error = non_success_status_error(response).await;

assert_eq!(
error.non_success_status(),
Some(StatusCode::TOO_MANY_REQUESTS)
);
assert_eq!(
error.non_success_body(),
Some(r#"{"error":{"message":"rate limited"}}"#)
);
let headers = error
.non_success_headers()
.expect("headers captured at error construction");
assert_eq!(
headers.get("retry-after").and_then(|v| v.to_str().ok()),
Some("20")
);
assert_eq!(
headers
.get("x-ratelimit-remaining")
.and_then(|v| v.to_str().ok()),
Some("0")
);
}

#[test]
fn non_success_headers_absent_when_not_captured() {
let error = Error::InvalidStatusCodeWithMessage(
StatusCode::TOO_MANY_REQUESTS,
"rate limited".to_string(),
None,
);
assert!(error.non_success_headers().is_none());

let error = Error::InvalidStatusCode(StatusCode::TOO_MANY_REQUESTS);
assert!(error.non_success_headers().is_none());
}
}

impl_http_client_ext!(
#[cfg(feature = "reqwest-middleware")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest-middleware")))]
Expand Down
1 change: 1 addition & 0 deletions crates/rig-core/src/image_generation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ mod provider_response_tests {
ImageGenerationError::HttpError(http_client::Error::InvalidStatusCodeWithMessage(
StatusCode::BAD_REQUEST,
body.to_string(),
None,
));

assert_eq!(error.provider_response_body(), Some(body));
Expand Down
56 changes: 56 additions & 0 deletions crates/rig-core/src/provider_response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ macro_rules! impl_provider_response_helpers {
Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithMessage(
status,
body.into(),
None,
))
}
}
Expand Down Expand Up @@ -157,6 +158,25 @@ macro_rules! impl_provider_response_helpers {
_ => None,
}
}

/// Returns the HTTP response headers when this error preserved them.
///
/// Rig's bundled HTTP clients capture the response headers whenever a
/// non-success status error is built from a live response, so
/// rate-limit metadata such as `Retry-After` or `x-ratelimit-*`
/// stays available to backoff logic.
///
/// Returns `None` when the error carries no captured headers: the
/// `ProviderResponse` variant (2xx error envelopes and non-HTTP
/// transports), Rig-generated diagnostics, and HTTP errors that were
/// constructed from only a status and body (e.g. via
/// [`Self::from_http_response`]).
pub fn provider_response_headers(&self) -> Option<&http::HeaderMap> {
match self {
Self::HttpError(error) => error.non_success_headers(),
_ => None,
}
}
}
};
}
Expand Down Expand Up @@ -219,6 +239,42 @@ mod tests {
let err = <$err>::from_provider_body("");
assert_eq!(err.provider_response_body(), Some(""));
assert!(err.provider_response_json().expect("ok").is_none());

// Headers are only available when captured at construction: the
// status+body funnels never have them...
let err = <$err>::from_http_response(StatusCode::SERVICE_UNAVAILABLE, body);
assert!(
err.provider_response_headers().is_none(),
concat!(
stringify!($err),
": from_http_response cannot invent headers"
),
);

// ...but an HttpError built from a live response preserves them,
// keeping `Retry-After` recoverable for backoff logic.
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::RETRY_AFTER,
http::HeaderValue::from_static("20"),
);
let err = <$err>::HttpError($crate::http_client::Error::InvalidStatusCodeWithMessage(
StatusCode::TOO_MANY_REQUESTS,
body.to_string(),
Some(Box::new(headers)),
));
assert_eq!(
err.provider_response_headers()
.and_then(|headers| headers.get(http::header::RETRY_AFTER))
.and_then(|value| value.to_str().ok()),
Some("20"),
concat!(stringify!($err), ": captured Retry-After not surfaced"),
);
assert_eq!(
err.provider_response_status(),
Some(StatusCode::TOO_MANY_REQUESTS)
);
assert_eq!(err.provider_response_body(), Some(body));
}};
}

Expand Down
2 changes: 1 addition & 1 deletion crates/rig-core/src/providers/deepseek.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@ where
.send::<_, Vec<u8>>(req)
.await
.map_err(|error| match error {
http_client::Error::InvalidStatusCodeWithMessage(status, message) => {
http_client::Error::InvalidStatusCodeWithMessage(status, message, _) => {
ModelListingError::api_error_with_context(
"DeepSeek",
path,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,7 @@ mod tests {
Err(http_client::Error::InvalidStatusCodeWithMessage(
http::StatusCode::BAD_GATEWAY,
body.to_string(),
None,
)),
];
let client = SequencedStreamingHttpClient::new(chunks);
Expand Down
2 changes: 1 addition & 1 deletion crates/rig-core/src/providers/xiaomimimo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,7 @@ where
.send::<_, Vec<u8>>(req)
.await
.map_err(|error| match error {
http_client::Error::InvalidStatusCodeWithMessage(status, message) => {
http_client::Error::InvalidStatusCodeWithMessage(status, message, _) => {
ModelListingError::api_error_with_context(
"Xiaomi MiMo",
path,
Expand Down
Loading