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:
Adam Perry @ OpenAI
2026-10-06 18:17:48 +00:00
committed by copyberry
parent bc3ebcb77d
commit f6cf05af1d
2 changed files with 174 additions and 71 deletions
@@ -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)] {