mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Honor Retry-After in WebSocket error events (#51440)
## Why WebSocket rate-limit and overload errors ignored `Retry-After` advice and ended turns even when the server supplied a retry delay. ## What changed Extract retry advice from nested `error.headers`, falling back to top-level `headers` when nested advice is missing or invalid. Use the shared HTTP header validation and retry-delay parser, and preserve the delay when mapping WebSocket errors. ## Testing Add coverage for nested-header precedence, malformed headers, HTTP header validation, and recovery after an advised rate limit. Verify that overloads retry only while advice is present, without falling back to HTTP, and that headerless rate limits, usage limits, and `flex_unavailable` errors remain terminal. GitOrigin-RevId: 61add0c36197e10402d6d3c325332aac72a5a521
This commit is contained in:
committed by
copyberry
parent
bc3ebcb77d
commit
f6cf05af1d
@@ -584,6 +584,8 @@ fn map_ws_error(err: WsError, url: &Url) -> ApiError {
|
||||
struct WrappedWebsocketError {
|
||||
code: Option<String>,
|
||||
message: Option<String>,
|
||||
#[serde(default)]
|
||||
headers: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -616,6 +618,16 @@ fn map_wrapped_websocket_error_event(
|
||||
headers,
|
||||
..
|
||||
} = event;
|
||||
let retry_after = [
|
||||
error
|
||||
.as_ref()
|
||||
.and_then(|error| error.headers.as_ref())
|
||||
.and_then(Value::as_object),
|
||||
headers.as_ref(),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find_map(|headers| RetryAfter::from_headers(&json_headers_to_http_headers(headers)));
|
||||
|
||||
if let Some(error) = error.as_ref()
|
||||
&& let Some(code) = error.code.as_deref()
|
||||
@@ -632,7 +644,7 @@ fn map_wrapped_websocket_error_event(
|
||||
.message
|
||||
.clone()
|
||||
.unwrap_or_else(|| fallback_message.to_string()),
|
||||
retry_after: None,
|
||||
retry_after,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -646,7 +658,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,
|
||||
retry_after,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1051,6 +1063,35 @@ mod tests {
|
||||
assert!(body.contains("The usage limit has been reached"));
|
||||
}
|
||||
|
||||
/// Websocket error advice uses the shared HTTP value validation and duplicate handling.
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn wrapped_websocket_retry_after_uses_http_header_validation() {
|
||||
for (error_headers, expected_seconds) in [
|
||||
(json!("invalid"), 12),
|
||||
(json!({"retry-after": "\n5\n"}), 12),
|
||||
(json!({"retry-after": "\t5\t"}), 5),
|
||||
(json!({"Retry-After": "5", "retry-after": "30"}), 30),
|
||||
] {
|
||||
let payload = json!({
|
||||
"type": "error",
|
||||
"status": 429,
|
||||
"error": {"code": "rate_limit_exceeded", "headers": error_headers},
|
||||
"headers": {"retry-after": "12"}
|
||||
})
|
||||
.to_string();
|
||||
let wrapped = parse_wrapped_websocket_error_event(&payload).unwrap();
|
||||
let Some(ApiError::Transport(TransportError::Http { retry_after, .. })) =
|
||||
map_wrapped_websocket_error_event(wrapped, payload)
|
||||
else {
|
||||
panic!("expected a websocket HTTP error");
|
||||
};
|
||||
assert_eq!(
|
||||
retry_after,
|
||||
RetryAfter::from_delay(Duration::from_secs(expected_seconds))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_websocket_usage_limit_preserves_optional_window() {
|
||||
for (window, expected) in [(Some(10080), Some(10080)), (None, None)] {
|
||||
|
||||
Reference in New Issue
Block a user