Skip to content

feat(core)!: preserve response headers on non-success HTTP errors - #2211

Closed
mydreamdoctor wants to merge 1 commit into
0xPlaygrounds:mainfrom
mydreamdoctor:feat/preserve-error-response-headers
Closed

mydreamdoctor wants to merge 1 commit into
0xPlaygrounds:mainfrom
mydreamdoctor:feat/preserve-error-response-headers

Conversation

@mydreamdoctor

@mydreamdoctor mydreamdoctor commented Jul 25, 2026 •

Copy link
Copy Markdown

Description

When a provider returns a non-success HTTP status, Rig preserves the status and
the raw body (via #1859's provider_response_* helpers), but the response
headers are discarded at error construction: non_success_status_error()
reads only .status() and .text(), and
http_client::Error::InvalidStatusCodeWithMessage has no slot for them. That
makes rate-limit metadata — most importantly Retry-After on a 429, but also
x-ratelimit-* — structurally unrecoverable by the caller.

We hit this while wiring provider-aware backoff: a stub server returning
429 + Retry-After: 20 + a JSON error body surfaces as
CompletionError::HttpError(InvalidStatusCodeWithMessage(429, body)) with
provider_response_status() == Some(429) and the body intact, but there is no
route to the Retry-After value — it is gone before the error reaches caller
code. Notably, Rig's own http_client::retry::RetryPolicy::retry(&self, error: &Error, ..) receives the error and therefore also cannot honor
Retry-After today.

This PR captures the response HeaderMap wherever a non-success status error
is built from a live response, and exposes it through the same inspection
surface #1859 established:

  • http_client::Error::InvalidStatusCodeWithMessage gains a third
    Option<Box<HeaderMap>> field (boxed to keep the error size unchanged for
    clippy::result_large_err) and a public non_success_headers() accessor.
  • The bundled reqwest-based clients populate it in non_success_status_error()
    (both the unary and streaming paths); the generic client verify() error
    branches populate it from the response in hand.
  • Every capability error (CompletionError, EmbeddingError,
    TranscriptionError, VerifyError, RerankError, ImageGenerationError,
    AudioGenerationError) gains provider_response_headers() beside the
    existing provider_response_{status,body,json} helpers, via
    impl_provider_response_helpers!.
  • rig-agent's PromptError and StructuredOutputError forward
    provider_response_headers() like the other three helpers.

from_http_response(status, body) intentionally keeps its signature: funnels
that only ever had a status and body construct the error with headers: None,
and the accessor documents that None means "not captured", mirroring
#1859's Option<StatusCode> convention. Broadening capture to the 2xx
error-envelope path (ProviderResponse) is left as a follow-up in the spirit
of #1931 — the helpers' contract already accommodates it.

Fixes #2210

Type of change

  • New feature
  • Breaking change (mechanical: one variant gains a field, see Notes)

Testing

Original validation (upstream main @ 13bfadd):

  • cargo test -p rig-core --all-features --lib — 986 passed; 0 failed; 3 ignored
  • cargo test -p rig-agent --all-features — 485 passed; 0 failed; 2 ignored (487 collected)
  • cargo clippy -p rig-core -p rig-agent --all-features --all-targets — 0 warnings (identical to a clean main baseline; the header field is boxed specifically so result_large_err stays quiet)
  • cargo fmt --all --check — clean
  • cargo doc -p rig-core -p rig-agent --all-features --no-deps — no new warnings

Re-validated after rebase onto main @ 7f1a495: patch applies cleanly;
cargo test -p rig-core — 1000 passed; 0 failed. (rig-agent's test build
currently fails to compile for us on a clean 7f1a495 checkout — E0282/E0432,
identical with and without this patch, so it appears unrelated to this change;
happy to re-run once that builds again in case it's environmental.)

New tests:

  • http_client::tests::non_success_status_error_preserves_response_headers —
    drives the real construction path with a reqwest::Response carrying
    Retry-After: 20 / x-ratelimit-remaining: 0 on a 429 and asserts status,
    body, and both headers survive.
  • http_client::tests::non_success_headers_absent_when_not_captured —
    None for errors built without a response.
  • provider_response assert_funnel! extended for every capability
    error: from_http_response yields provider_response_headers() == None
    (funnels cannot invent headers), while an HttpError built with captured
    headers surfaces Retry-After alongside the preserved 429 status and body.
  • rig-agent:
    prompt_error_provider_response_helpers_forward_captured_headers — forwarding
    through PromptError and StructuredOutputError.

Checklist:

  • My code follows the style guidelines of this project
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (doc comments on the new accessors; happy to add a MIGRATING.md note if wanted)
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Notes

Breaking surface (mechanical). InvalidStatusCodeWithMessage is a tuple
variant with public fields, so adding the headers slot is source-breaking for
code that constructs or exhaustively matches it:

// before
Error::InvalidStatusCodeWithMessage(status, message)
// after
Error::InvalidStatusCodeWithMessage(status, message, None)          // construct
Error::InvalidStatusCodeWithMessage(status, message, _) => ...      // match

All in-tree sites (2 match arms, ~15 constructions, mostly tests) are updated
in this PR. Users who only consume errors through Display or the
provider_response_* helpers are unaffected. If you'd rather avoid the break,
the variant could instead become a #[non_exhaustive] struct variant with
constructors — larger diff, future-proof against the next field; happy to
rework it that way if that's the preferred direction.

Why Option<Box<HeaderMap>>: Option distinguishes "not captured" (e.g.
built via from_http_response, which never sees headers) from "captured and
empty"; the Box keeps http_client::Error at its previous size so the
crate-wide clippy::result_large_err profile stays clean (verified: 0 clippy
warnings, same as main).

Scope notes / possible follow-ups:

  • The 2xx error-envelope path (ProviderResponse variant) still reports
    provider_response_headers() == None; provider call sites hold the response
    there, so threading headers through is a mechanical broadening in the spirit
    of Follow-up to #1859: broaden provider error-response inspection coverage + guard against regressions #1931.
  • The SSE EventSource connect path still uses InvalidStatusCode (status
    only, no body or headers) — untouched here.
  • A convenience retry_after() -> Option<Duration> parser was deliberately
    left out to keep this minimal; RetryPolicy implementors can read the raw
    header value via non_success_headers().

🤖 Generated with Claude Code

Capture the response HeaderMap when a non-success status error is
constructed from a live HTTP response, so rate-limit metadata such as
Retry-After stays recoverable by callers' backoff logic.

- http_client::Error::InvalidStatusCodeWithMessage gains an
  Option<HeaderMap> third field and a non_success_headers() accessor.
- The bundled reqwest-based clients populate it in
  non_success_status_error(); the client verify() paths populate it from
  the response in hand.
- Capability errors gain provider_response_headers() beside the existing
  provider_response_{status,body,json} helpers; rig-agent's PromptError
  and StructuredOutputError forward it.

BREAKING CHANGE: matches on InvalidStatusCodeWithMessage need a third
pattern field; constructions need an Option<HeaderMap> argument.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gold-silver-copper

Copy link
Copy Markdown
Collaborator

Opened #2333 as a rebuilt version of this change, since main has moved in a way that changes the right fix. Not closing this one — that's the maintainers' and @mydreamdoctor's call. The diagnosis in this PR's description is what led me to it, and the design there follows yours.

What changed underneath. rig#2314 landed http_client::Error::InvalidStatusCodeWithDetails { status, body, headers } so providers could read their request-id header off a failed response, and non_success_status_error() now captures the full HeaderMap. So the headers are captured on main today — they are just discarded downstream. send_completion (the shared driver behind every provider's unary completion, which did not exist on this branch's base) destructures the headers-bearing variant, reads exactly one header out of it, and then either downgrades to InvalidStatusCodeWithMessage or funnels into ProviderResponse — neither of which had a headers slot.

That makes the bug narrower to state and, usefully, fixable without a breaking change: building on the details variant means no variant changes shape, from_http_response's signature is untouched, and ProviderResponseError grows a field it is already #[non_exhaustive] to permit. The "must land in a breaking window" urgency argument goes away with the breakage.

The file list here is also stale now — deepseek.rs and xiaomimimo.rs no longer construct the variant (that moved to internal/model_listing.rs), and the capture sites that matter most today (internal/completion_send.rs, internal/sse_transport.rs) postdate this branch.

#2333 keeps your public surface (non_success_headers(), provider_response_headers() on every capability error, rig-agent forwarding), extends capture to the three sibling shared drivers and the SSE handshake, and adds a recorded Anthropic cassette cell proving a real provider response's headers survive end to end.

gold-silver-copper added a commit that referenced this pull request Aug 15, 2026
The transport already captured the failed response's `HeaderMap` into
`http_client::Error::InvalidStatusCodeWithDetails` (rig#2314 needed it to
read each provider's request-id header), but every normalization path then
threw the map away, so `Retry-After` on a 429 was structurally unrecoverable
by the caller — including by rig's own `RetryPolicy::retry`, which is handed
the error and cannot honor a server-supplied backoff.

`send_completion` was the clearest case: it destructured the headers-bearing
variant, read exactly one header out of it, and then either downgraded to
`InvalidStatusCodeWithMessage` (contract-less providers) or funnelled into
`ProviderResponse`, which had no headers slot (contract providers).

Capture is now preserved end to end:

- `http_client::Error::non_success_headers()` — public, because a custom
  `RetryPolicy` receives the raw transport error.
- `ProviderResponseError` gains a `headers` slot and a `with_headers` setter;
  the type is `#[non_exhaustive]` precisely so transport metadata can grow.
- Every capability error gains `provider_response_headers()`, reading from
  both the `ProviderResponse` and `HttpError` routes, and `rig-agent`'s
  `PromptError` / `StructuredOutputError` forward it.
- `with_response_headers()` attaches headers after either funnel, upgrading a
  non-success `HttpError` in place; classification and Display are unchanged.
- Capture sites: `send_completion` (transport-error and non-success-response
  paths), the SSE handshake conversion, and all three `verify()` branches.

Headers are cloned only for a failed response, so the success path pays
nothing; a 2xx error envelope therefore reports no headers, pinned by test.
`None` means "not captured" and stays distinct from an empty map.

Supersedes #2211, whose design this follows; that PR predated
`InvalidStatusCodeWithDetails` and so had to add a field to
`InvalidStatusCodeWithMessage`, making it a breaking change. Building on the
details variant makes the same capability additive.

Fixes #2210

Co-authored-by: Lachlan <lachlan.fletcher@gmail.com>
gold-silver-copper added a commit that referenced this pull request Aug 15, 2026
The transport already captured the failed response's `HeaderMap` into
`http_client::Error::InvalidStatusCodeWithDetails` (rig#2314 needed it to
read each provider's request-id header), but every normalization path then
threw the map away, so `Retry-After` on a 429 was structurally unrecoverable
by the caller — including by rig's own `RetryPolicy::retry`, which is handed
the error and cannot honor a server-supplied backoff.

`send_completion` was the clearest case: it destructured the headers-bearing
variant, read exactly one header out of it, and then either downgraded to
`InvalidStatusCodeWithMessage` (contract-less providers) or funnelled into
`ProviderResponse`, which had no headers slot (contract providers).

Capture is now preserved end to end:

- `http_client::Error::non_success_headers()` — public, because a custom
  `RetryPolicy` receives the raw transport error.
- `ProviderResponseError` gains a `headers` slot and a `with_headers` setter;
  the type is `#[non_exhaustive]` precisely so transport metadata can grow.
- Every capability error gains `provider_response_headers()`, reading from
  both the `ProviderResponse` and `HttpError` routes, and `rig-agent`'s
  `PromptError` / `StructuredOutputError` forward it.
- `with_response_headers()` attaches headers after either funnel, upgrading a
  non-success `HttpError` in place; classification and Display are unchanged.
- Capture sites: `send_completion` (transport-error and non-success-response
  paths), the SSE handshake conversion, and all three `verify()` branches.

Headers are cloned only for a failed response, so the success path pays
nothing; a 2xx error envelope therefore reports no headers, pinned by test.
`None` means "not captured" and stays distinct from an empty map.

Supersedes #2211, whose design this follows; that PR predated
`InvalidStatusCodeWithDetails` and so had to add a field to
`InvalidStatusCodeWithMessage`, making it a breaking change. Building on the
details variant makes the same capability additive.

Fixes #2210

Co-authored-by: Lachlan <lachlan.fletcher@gmail.com>
gold-silver-copper added a commit that referenced this pull request Aug 15, 2026
The transport already captured the failed response's `HeaderMap` into
`http_client::Error::InvalidStatusCodeWithDetails` (rig#2314 needed it to
read each provider's request-id header), but every normalization path then
threw the map away, so `Retry-After` on a 429 was structurally unrecoverable
by the caller — including by rig's own `RetryPolicy::retry`, which is handed
the error and cannot honor a server-supplied backoff.

`send_completion` was the clearest case: it destructured the headers-bearing
variant, read exactly one header out of it, and then either downgraded to
`InvalidStatusCodeWithMessage` (contract-less providers) or funnelled into
`ProviderResponse`, which had no headers slot (contract providers).

Capture is now preserved end to end:

- `http_client::Error::non_success_headers()` — public, because a custom
  `RetryPolicy` receives the raw transport error.
- `ProviderResponseError` gains a `headers` slot and a `with_headers` setter;
  the type is `#[non_exhaustive]` precisely so transport metadata can grow.
- Every capability error gains `provider_response_headers()`, reading from
  both the `ProviderResponse` and `HttpError` routes, and `rig-agent`'s
  `PromptError` / `StructuredOutputError` forward it.
- `with_response_headers()` attaches headers after either funnel, upgrading a
  non-success `HttpError` in place; classification and Display are unchanged.
- Capture sites: `send_completion` (transport-error and non-success-response
  paths), the SSE handshake conversion, and all three `verify()` branches.

Headers are cloned only for a failed response, so the success path pays
nothing; a 2xx error envelope therefore reports no headers, pinned by test.
`None` means "not captured" and stays distinct from an empty map.

Supersedes #2211, whose design this follows; that PR predated
`InvalidStatusCodeWithDetails` and so had to add a field to
`InvalidStatusCodeWithMessage`, making it a breaking change. Building on the
details variant makes the same capability additive.

Fixes #2210
gold-silver-copper added a commit that referenced this pull request Aug 15, 2026
The transport already captured the failed response's `HeaderMap` into
`http_client::Error::InvalidStatusCodeWithDetails` (rig#2314 needed it to
read each provider's request-id header), but every normalization path then
threw the map away, so `Retry-After` on a 429 was structurally unrecoverable
by the caller — including by rig's own `RetryPolicy::retry`, which is handed
the error and cannot honor a server-supplied backoff.

`send_completion` was the clearest case: it destructured the headers-bearing
variant, read exactly one header out of it, and then either downgraded to
`InvalidStatusCodeWithMessage` (contract-less providers) or funnelled into
`ProviderResponse`, which had no headers slot (contract providers).

Capture is now preserved end to end:

- `http_client::Error::non_success_headers()` — public, because a custom
  `RetryPolicy` receives the raw transport error.
- `ProviderResponseError` gains a `headers` slot and a `with_headers` setter;
  the type is `#[non_exhaustive]` precisely so transport metadata can grow.
- Every capability error gains `provider_response_headers()`, reading from
  both the `ProviderResponse` and `HttpError` routes, and `rig-agent`'s
  `PromptError` / `StructuredOutputError` forward it.
- `with_response_headers()` attaches headers after either funnel, upgrading a
  non-success `HttpError` in place; classification and Display are unchanged.
- Capture sites: `send_completion` (transport-error and non-success-response
  paths), the SSE handshake conversion, and all three `verify()` branches.

Headers are cloned only for a failed response, so the success path pays
nothing; a 2xx error envelope therefore reports no headers, pinned by test.
`None` means "not captured" and stays distinct from an empty map.

Supersedes #2211, whose design this follows; that PR predated
`InvalidStatusCodeWithDetails` and so had to add a field to
`InvalidStatusCodeWithMessage`, making it a breaking change. Building on the
details variant makes the same capability additive.

Fixes #2210
@gold-silver-copper

Copy link
Copy Markdown
Collaborator

Merged as #2333 , you were added as coauthor there. Thanks!

@mydreamdoctor

Copy link
Copy Markdown
Author

Thanks @gold-silver-copper. That looks great. Much appreciated!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Response headers (incl. Retry-After) are discarded when non-success HTTP errors are constructed

2 participants