Honor Retry-After and preserve server retry deadlines (#47641)

## Why

HTTP retries used local backoff even when the server supplied `Retry-After`. Relative retry delays also failed to account for time spent propagating errors or reporting retries.

## What changed

- Parse `Retry-After` delay seconds and HTTP dates into a monotonic deadline captured when response headers arrive.
- Honor that deadline for HTTP retries, falling back to local backoff when valid advice is absent.
- Preserve deadlines through API and Bedrock error mapping, stream retries, and exhausted retry state. Expired advice remains a zero delay instead of triggering local backoff.

## Testing

Add coverage for header parsing, deadline countdown and expiry, error mapping after exhausted HTTP retries, and delayed stream retry notifications. Update Responses and remote compaction tests to verify that HTTP retries honor `Retry-After`.

GitOrigin-RevId: a2eff02946e5524f29e06f7ba1431e3d339261a4
This commit is contained in:
Adam Perry @ OpenAI
2026-09-23 19:53:11 +00:00
committed by copyberry
parent ab021e9f13
commit 9d8de19674
28 changed files with 430 additions and 87 deletions
+31 -14
View File
@@ -19,26 +19,42 @@ use serde::Deserialize;
use serde_json::Value;
pub fn map_api_error(err: ApiError) -> CodexErr {
let retry_after = match &err {
ApiError::Retryable { retry_after, .. }
| ApiError::RateLimitExceeded { retry_after, .. }
| ApiError::ServerOverloaded { retry_after }
| ApiError::Transport(TransportError::Http { retry_after, .. }) => *retry_after,
ApiError::Transport(_)
| ApiError::Api { .. }
| ApiError::Stream(_)
| ApiError::ContextWindowExceeded
| ApiError::QuotaExceeded
| ApiError::UsageNotIncluded
| ApiError::RateLimit(_)
| ApiError::InvalidRequest { .. }
| ApiError::InvalidPrompt { .. }
| ApiError::CyberPolicy { .. }
| ApiError::BioPolicy { .. }
| ApiError::MisalignmentPolicyViolation { .. } => None,
};
let error = map_api_error_details(err);
match retry_after {
Some(retry_after) => error.with_retry_after(retry_after),
None => error,
}
}
fn map_api_error_details(err: ApiError) -> CodexErr {
match err {
ApiError::ContextWindowExceeded => CodexErr::ContextWindowExceeded,
ApiError::QuotaExceeded => CodexErr::QuotaExceeded,
ApiError::UsageNotIncluded => CodexErr::UsageNotIncluded,
ApiError::Retryable { message, delay } => {
let error = CodexErr::Stream(message);
match delay {
Some(delay) => error.with_retry_delay(delay),
None => error,
}
}
ApiError::RateLimitExceeded { message, delay } => {
let error = CodexErr::new(CodexErrorDetails::RateLimitExceeded(message));
match delay {
Some(delay) => error.with_retry_delay(delay),
None => error,
}
ApiError::Retryable { message, .. } => CodexErr::Stream(message),
ApiError::RateLimitExceeded { message, .. } => {
CodexErr::new(CodexErrorDetails::RateLimitExceeded(message))
}
ApiError::Stream(msg) => CodexErr::Stream(msg),
ApiError::ServerOverloaded => CodexErr::ServerOverloaded,
ApiError::ServerOverloaded { .. } => CodexErr::ServerOverloaded,
ApiError::Api { status, message } => {
let user_message = api_error_user_message(status, &message);
CodexErr::UnexpectedStatus(UnexpectedResponseError {
@@ -73,6 +89,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
url,
headers,
body,
..
} => {
let body_text = body.unwrap_or_default();
+25 -5
View File
@@ -1,23 +1,25 @@
use super::*;
use base64::Engine;
use codex_http_client::RetryAfter;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::RateLimitReachedType;
use pretty_assertions::assert_eq;
#[test]
fn map_api_error_maps_server_overloaded() {
let err = map_api_error(ApiError::ServerOverloaded);
let err = map_api_error(ApiError::ServerOverloaded { retry_after: None });
assert!(matches!(err.details(), CodexErrorDetails::ServerOverloaded));
}
#[test]
fn map_api_error_preserves_retry_delay() {
#[tokio::test(start_paused = true)]
async fn map_api_error_preserves_retry_delay() {
let retry_delay = std::time::Duration::from_secs(17);
let retry_after = RetryAfter::from_delay(retry_delay).expect("retry advice");
for (error, expected_code, expected_message) in [
(
ApiError::Retryable {
message: "retry later".to_string(),
delay: Some(retry_delay),
retry_after: Some(retry_after),
},
CodexErrorInfo::Other,
"stream disconnected before completion: retry later",
@@ -25,7 +27,7 @@ fn map_api_error_preserves_retry_delay() {
(
ApiError::RateLimitExceeded {
message: "retry later".to_string(),
delay: Some(retry_delay),
retry_after: Some(retry_after),
},
CodexErrorInfo::RateLimitExceeded,
"rate limit exceeded: retry later",
@@ -36,6 +38,7 @@ fn map_api_error_preserves_retry_delay() {
(
err.to_codex_protocol_error(),
err.retry_delay(/*retry_count*/ 1),
err.retry_after(),
err.server_retry_delay(),
err.http_status_code_value(),
err.to_string(),
@@ -43,6 +46,7 @@ fn map_api_error_preserves_retry_delay() {
(
expected_code,
Some(retry_delay),
Some(retry_after),
Some(retry_delay),
None,
expected_message.to_string(),
@@ -63,6 +67,7 @@ fn map_api_error_distinguishes_capacity_from_slow_down() {
("unknown_error", CodexErrorInfo::Other, true),
] {
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::SERVICE_UNAVAILABLE,
url: None,
headers: None,
@@ -85,6 +90,7 @@ fn map_api_error_maps_cloudflare_blocked_response_to_user_message() {
let mut headers = HeaderMap::new();
headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-id"));
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::FORBIDDEN,
url: Some("http://example.com/blocked".to_string()),
headers: Some(headers),
@@ -120,6 +126,7 @@ fn map_api_error_maps_cyber_policy_from_400_body() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::BAD_REQUEST,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
@@ -148,6 +155,7 @@ fn map_api_error_maps_wrapped_websocket_cyber_policy_from_400_body() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::BAD_REQUEST,
url: Some("ws://example.com/v1/responses".to_string()),
headers: None,
@@ -169,6 +177,7 @@ fn map_api_error_uses_cyber_policy_fallback_for_missing_message() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::BAD_REQUEST,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
@@ -241,6 +250,7 @@ fn map_api_error_maps_http_and_wrapped_websocket_typed_errors() {
body["status"] = serde_json::json!(400);
}
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::BAD_REQUEST,
url: None,
headers: None,
@@ -275,6 +285,7 @@ fn assert_misalignment_policy_violation_from_http_body(status: http::StatusCode)
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
@@ -308,6 +319,7 @@ fn map_api_error_preserves_misalignment_details_from_403_body() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::FORBIDDEN,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
@@ -352,6 +364,7 @@ fn map_api_error_preserves_misalignment_details_from_wrapped_websocket_error() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::FORBIDDEN,
url: Some("ws://example.com/v1/responses".to_string()),
headers: None,
@@ -393,6 +406,7 @@ fn map_api_error_keeps_other_400_errors_generic() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::BAD_REQUEST,
url: Some("http://example.com/v1/responses".to_string()),
headers: None,
@@ -426,6 +440,7 @@ fn map_api_error_distinguishes_http_quota_errors_from_rate_limits() {
CodexErrorInfo::UsageLimitExceeded
};
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::TOO_MANY_REQUESTS,
url: None,
headers: None,
@@ -455,6 +470,7 @@ fn map_api_error_maps_usage_limit_limit_name_header() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
@@ -488,6 +504,7 @@ fn map_api_error_does_not_fallback_limit_name_to_limit_id() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
@@ -537,6 +554,7 @@ fn map_api_error_copies_rate_limit_reached_type_to_usage_limit_snapshot() {
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
@@ -588,6 +606,7 @@ fn map_api_error_ignores_unparseable_rate_limit_reached_type_headers() {
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::TOO_MANY_REQUESTS,
url: Some("http://example.com/v1/responses".to_string()),
headers: Some(headers),
@@ -618,6 +637,7 @@ fn map_api_error_extracts_identity_auth_details_from_headers() {
);
let err = map_api_error(ApiError::Transport(TransportError::Http {
retry_after: None,
status: http::StatusCode::UNAUTHORIZED,
url: Some("https://chatgpt.com/backend-api/codex/models".to_string()),
headers: Some(headers),
@@ -575,6 +575,7 @@ fn map_ws_error(err: WsError, url: &Url) -> ApiError {
url: Some(url.to_string()),
headers: Some(headers),
body,
retry_after: None,
})
}
WsError::ConnectionClosed | WsError::AlreadyClosed => {
@@ -637,7 +638,7 @@ fn map_wrapped_websocket_error_event(
.message
.clone()
.unwrap_or_else(|| fallback_message.to_string()),
delay: None,
retry_after: None,
});
}
@@ -651,6 +652,7 @@ fn map_wrapped_websocket_error_event(
url: None,
headers: headers.as_ref().map(json_headers_to_http_headers),
body: Some(original_payload),
retry_after: None,
}))
}
@@ -1107,11 +1109,15 @@ mod tests {
.expect("expected websocket error payload to be parsed");
let api_error = map_wrapped_websocket_error_event(wrapped_error, payload)
.expect("expected websocket error payload to map to ApiError");
let ApiError::Retryable { message, delay } = api_error else {
let ApiError::Retryable {
message,
retry_after,
} = api_error
else {
panic!("expected ApiError::Retryable");
};
assert_eq!(message, WEBSOCKET_CONNECTION_LIMIT_REACHED_MESSAGE);
assert_eq!(delay, None);
assert_eq!(retry_after, None);
}
#[test]
+4 -4
View File
@@ -1,8 +1,8 @@
use crate::rate_limits::RateLimitError;
use codex_client::TransportError;
use codex_http_client::RetryAfter;
use codex_protocol::protocol::MisalignmentErrorDetails;
use http::StatusCode;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
@@ -22,12 +22,12 @@ pub enum ApiError {
#[error("retryable error: {message}")]
Retryable {
message: String,
delay: Option<Duration>,
retry_after: Option<RetryAfter>,
},
#[error("rate limit exceeded: {message}")]
RateLimitExceeded {
message: String,
delay: Option<Duration>,
retry_after: Option<RetryAfter>,
},
#[error("rate limit: {0}")]
RateLimit(String),
@@ -45,7 +45,7 @@ pub enum ApiError {
misalignment: Option<MisalignmentErrorDetails>,
},
#[error("server overloaded")]
ServerOverloaded,
ServerOverloaded { retry_after: Option<RetryAfter> },
}
impl From<RateLimitError> for ApiError {
+31 -17
View File
@@ -8,6 +8,7 @@ use crate::safety_buffering::treatment_from_headers;
use crate::telemetry::SseTelemetry;
use codex_client::ByteStream;
use codex_client::StreamResponse;
use codex_http_client::RetryAfter;
use codex_protocol::ResponseUsageMetadata;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::MisalignmentErrorDetails;
@@ -457,15 +458,22 @@ pub fn process_responses_event(
.unwrap_or_else(|| "Invalid request.".to_string());
response_error = ApiError::InvalidPrompt { message };
} else if is_server_overloaded_error(&error) {
response_error = ApiError::ServerOverloaded;
response_error = ApiError::ServerOverloaded { retry_after: None };
} else {
let delay = try_parse_retry_after(&error);
let retry_after =
try_parse_retry_delay(&error).and_then(RetryAfter::from_delay);
let message = error.message.unwrap_or_default();
response_error = match error.code.as_deref() {
Some("rate_limit_exceeded" | "slow_down") => {
ApiError::RateLimitExceeded { message, delay }
ApiError::RateLimitExceeded {
message,
retry_after,
}
}
_ => ApiError::Retryable { message, delay },
_ => ApiError::Retryable {
message,
retry_after,
},
};
}
}
@@ -695,7 +703,7 @@ async fn process_sse_with_treatment(
}
}
fn try_parse_retry_after(err: &Error) -> Option<Duration> {
fn try_parse_retry_delay(err: &Error) -> Option<Duration> {
if !matches!(
err.code.as_deref(),
Some("rate_limit_exceeded" | "slow_down")
@@ -1115,7 +1123,7 @@ mod tests {
}
}
#[tokio::test]
#[tokio::test(start_paused = true)]
async fn rate_limit_error_preserves_retry_delay() {
let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_689bcf18d7f08194bf3440ba62fe05d803fee0cdac429894","object":"response","created_at":1755041560,"status":"failed","background":false,"error":{"code":"rate_limit_exceeded","message":"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."}, "usage":null,"user":null,"metadata":{}}}"#;
@@ -1126,12 +1134,18 @@ mod tests {
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::RateLimitExceeded { message, delay }) => {
Err(ApiError::RateLimitExceeded {
message,
retry_after,
}) => {
assert_eq!(
message,
"Rate limit reached for gpt-5.1 in organization org-AAA on tokens per min (TPM): Limit 30000, Used 22999, Requested 12528. Please try again in 11.054s. Visit https://platform.openai.com/account/rate-limits to learn more."
);
assert_eq!(*delay, Some(Duration::from_secs_f64(11.054)));
assert_eq!(
retry_after.map(RetryAfter::remaining_delay),
Some(Duration::from_secs_f64(11.054))
);
}
other => panic!("unexpected rate-limit event: {other:?}"),
}
@@ -1159,7 +1173,7 @@ mod tests {
[
Err(ApiError::RateLimitExceeded {
message: actual,
delay,
retry_after,
}),
],
)
@@ -1168,11 +1182,11 @@ mod tests {
[
Err(ApiError::Retryable {
message: actual,
delay,
retry_after,
}),
],
) => {
assert_eq!((actual.as_str(), *delay), (message, None));
assert_eq!((actual.as_str(), *retry_after), (message, None));
}
_ => panic!("unexpected events for {code}: {events:?}"),
}
@@ -2078,7 +2092,7 @@ mod tests {
}
#[test]
fn test_try_parse_retry_after() {
fn test_try_parse_retry_delay() {
let err = Error {
r#type: None,
message: Some("Rate limit reached for gpt-5.1 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
@@ -2088,12 +2102,12 @@ mod tests {
misalignment: None,
};
let delay = try_parse_retry_after(&err);
let delay = try_parse_retry_delay(&err);
assert_eq!(delay, Some(Duration::from_millis(28)));
}
#[test]
fn test_try_parse_retry_after_no_delay() {
fn test_try_parse_retry_delay_no_delay() {
let err = Error {
r#type: None,
message: Some("Rate limit reached for gpt-5.1 in organization <ORG> on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()),
@@ -2102,12 +2116,12 @@ mod tests {
resets_at: None,
misalignment: None,
};
let delay = try_parse_retry_after(&err);
let delay = try_parse_retry_delay(&err);
assert_eq!(delay, Some(Duration::from_secs_f64(1.898)));
}
#[test]
fn test_try_parse_retry_after_azure() {
fn test_try_parse_retry_delay_azure() {
let err = Error {
r#type: None,
message: Some("Rate limit exceeded. Try again in 35 seconds.".to_string()),
@@ -2116,7 +2130,7 @@ mod tests {
resets_at: None,
misalignment: None,
};
let delay = try_parse_retry_after(&err);
let delay = try_parse_retry_delay(&err);
assert_eq!(delay, Some(Duration::from_secs(35)));
}