Honor server retry advice across Responses retries and fallback (#49441)

## Why

Responses overload and exhausted-request errors could terminate despite server retry advice, and WebSocket-to-HTTP fallback could issue a request before the advised deadline.

## What changed

- Allow `ServerOverloaded` and `RetryLimit` errors to retry when server advice is present, within the configured retry budgets.
- Preserve `Retry-After` headers from rejected WebSocket upgrades and wait for the server deadline before falling back to HTTP.
- Keep quota, usage-limit, and policy failures terminal even when they include retry advice.

## Testing

Add coverage for HTTP overload and rate-limit recovery, WebSocket upgrade rejection and fallback timing, retry deadline preservation through error mapping, and retry limits for sampling and manual and automatic compaction.

GitOrigin-RevId: 72d30a9f078652664754dfa971fd7dc7a78f20e8
This commit is contained in:
Adam Perry @ OpenAI
2026-09-30 02:12:42 +00:00
committed by copyberry
parent fc81a7154b
commit 6ba4bf9e64
6 changed files with 434 additions and 26 deletions
@@ -74,6 +74,56 @@ async fn map_api_error_preserves_retry_delay() {
}
}
/// Mapping a new error category preserves elapsed advice without making hard failures retryable.
#[tokio::test(start_paused = true)]
async fn http_retry_deadline_survives_mapping_and_respects_terminal_errors() {
use std::time::Duration;
let advice = RetryAfter::from_delay(Duration::from_secs(10)).expect("retry advice");
tokio::time::advance(Duration::from_secs(4)).await;
for (status, code, retryable) in [
(503, "server_is_overloaded", true),
(429, "rate_limit_exceeded", true),
(429, "insufficient_quota", false),
(429, "usage_limit_reached", false),
(400, "cyber_policy", false),
] {
let error = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::from_u16(status).unwrap(),
url: None,
headers: None,
body: Some(serde_json::json!({"error": {"type": code, "code": code, "message": "sensitive upstream detail"}}).to_string()),
retry_after: Some(advice),
}));
assert_eq!(
(error.retry_after(), error.server_retry_delay()),
(Some(advice), Some(Duration::from_secs(6))),
"{code}",
);
assert_eq!(
error.retry_delay(/*retry_count*/ 1),
retryable.then_some(Duration::from_secs(6)),
"{code}",
);
if status == 429 && retryable {
assert_eq!(
(
error.to_codex_protocol_error(),
error.http_status_code_value(),
error.to_string()
),
(
CodexErrorInfo::ResponseTooManyFailedAttempts {
http_status_code: Some(429),
},
Some(429),
"exceeded retry limit, last status: 429 Too Many Requests".to_string(),
),
);
}
}
}
#[test]
fn map_api_error_distinguishes_capacity_from_slow_down() {
for (code, expected, retryable) in [
@@ -14,6 +14,7 @@ use crate::sse::process_responses_event;
use crate::telemetry::WebsocketTelemetry;
use codex_client::TransportError;
use codex_http_client::HttpClientFactory;
use codex_http_client::RetryAfter;
use codex_websocket_client::WebSocketConnection;
use codex_websocket_client::WebSocketConnector;
use futures::FutureExt;
@@ -570,6 +571,7 @@ fn map_ws_error(err: WsError, url: &Url) -> ApiError {
WsError::Http(response) => {
let status = response.status();
let headers = response.headers().clone();
let retry_after = RetryAfter::from_headers(&headers);
let body = response
.body()
.as_ref()
@@ -579,7 +581,7 @@ fn map_ws_error(err: WsError, url: &Url) -> ApiError {
url: Some(url.to_string()),
headers: Some(headers),
body,
retry_after: None,
retry_after,
})
}
WsError::ConnectionClosed | WsError::AlreadyClosed => {