mirror of
https://github.com/openai/codex.git
synced 2026-10-21 17:48:44 +00:00
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:
@@ -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 } => {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user