Classify streaming rate-limit errors (#40931)

## What changed

- Classify `response.failed` events with the `rate_limit_exceeded` code as a distinct retryable error while preserving any parsed retry delay.
- Expose the error as `rateLimitExceeded` through the core protocol and app-server schemas after stream retries are exhausted.
- Preserve the upstream message for TUI display while keeping it out of telemetry summaries.

## Testing

- Cover SSE classification, retry metadata, protocol conversion and serialization, exhausted stream retries, telemetry redaction, and TUI rendering.

GitOrigin-RevId: 02dab4d3477dcd7653a58c49c4bd38687a616579
This commit is contained in:
Steve Coffey
2026-08-26 17:45:42 +00:00
committed by copyberry
parent 7625bd5665
commit e0c727de04
38 changed files with 198 additions and 26 deletions
+7
View File
@@ -29,6 +29,13 @@ pub fn map_api_error(err: ApiError) -> CodexErr {
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::Stream(msg) => CodexErr::Stream(msg),
ApiError::ServerOverloaded => CodexErr::ServerOverloaded,
ApiError::Api { status, message } => {
+37 -10
View File
@@ -1,5 +1,6 @@
use super::*;
use base64::Engine;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::RateLimitReachedType;
use pretty_assertions::assert_eq;
@@ -12,16 +13,42 @@ fn map_api_error_maps_server_overloaded() {
#[test]
fn map_api_error_preserves_retry_delay() {
let retry_delay = std::time::Duration::from_secs(17);
let err = map_api_error(ApiError::Retryable {
message: "retry later".to_string(),
delay: Some(retry_delay),
});
assert!(matches!(
err.details(),
CodexErrorDetails::Stream(message) if message == "retry later"
));
assert_eq!(err.retry_delay(), Some(retry_delay));
for (error, expected_code, expected_message) in [
(
ApiError::Retryable {
message: "retry later".to_string(),
delay: Some(retry_delay),
},
CodexErrorInfo::Other,
"stream disconnected before completion: retry later",
),
(
ApiError::RateLimitExceeded {
message: "retry later".to_string(),
delay: Some(retry_delay),
},
CodexErrorInfo::RateLimitExceeded,
"rate limit exceeded: retry later",
),
] {
let err = map_api_error(error);
assert_eq!(
(
err.to_codex_protocol_error(),
err.retry_delay(),
err.is_retryable(),
err.http_status_code_value(),
err.to_string(),
),
(
expected_code,
Some(retry_delay),
true,
None,
expected_message.to_string(),
)
);
}
}
#[test]
+5
View File
@@ -23,6 +23,11 @@ pub enum ApiError {
message: String,
delay: Option<Duration>,
},
#[error("rate limit exceeded: {message}")]
RateLimitExceeded {
message: String,
delay: Option<Duration>,
},
#[error("rate limit: {0}")]
RateLimit(String),
#[error("invalid request: {message}")]
+50 -4
View File
@@ -440,7 +440,12 @@ pub fn process_responses_event(
} else {
let delay = try_parse_retry_after(&error);
let message = error.message.unwrap_or_default();
response_error = ApiError::Retryable { message, delay };
response_error = match error.code.as_deref() {
Some("rate_limit_exceeded") => {
ApiError::RateLimitExceeded { message, delay }
}
_ => ApiError::Retryable { message, delay },
};
}
}
return Err(ResponsesEventError::Api(response_error));
@@ -1058,7 +1063,7 @@ mod tests {
}
#[tokio::test]
async fn error_when_error_event() {
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":{}}}"#;
let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n");
@@ -1068,14 +1073,55 @@ mod tests {
assert_eq!(events.len(), 1);
match &events[0] {
Err(ApiError::Retryable { message, delay }) => {
Err(ApiError::RateLimitExceeded { message, delay }) => {
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)));
}
other => panic!("unexpected second event: {other:?}"),
other => panic!("unexpected rate-limit event: {other:?}"),
}
}
#[tokio::test]
async fn failed_response_classification_uses_error_code() {
for (code, message) in [
("rate_limit_exceeded", "Temporary limit."),
(
"unknown_error",
"Rate limit reached. Please try again in 1s.",
),
] {
let event = json!({
"type": "response.failed",
"response": { "error": { "code": code, "message": message } },
});
let sse = format!("event: response.failed\ndata: {event}\n\n");
let events = collect_events(&[sse.as_bytes()]).await;
match (code, events.as_slice()) {
(
"rate_limit_exceeded",
[
Err(ApiError::RateLimitExceeded {
message: actual,
delay,
}),
],
)
| (
"unknown_error",
[
Err(ApiError::Retryable {
message: actual,
delay,
}),
],
) => {
assert_eq!((actual.as_str(), *delay), (message, None));
}
_ => panic!("unexpected events for {code}: {events:?}"),
}
}
}