diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4be158cf97..6b5503ed1a 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -617,6 +617,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 84ab0860d6..9dc9e6a80f 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8013,6 +8013,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e104117e33..a3dd5c3d38 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3974,6 +3974,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 36721e3704..92dd60548c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -9,6 +9,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index a60b368f27..bd78ecf099 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -38,6 +38,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 49d88c6485..6abfafc209 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -119,6 +119,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 7ad29f2f9f..88c5f4f3cb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -41,6 +41,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 6324175b57..c2e0726459 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -41,6 +41,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 9c4af935ab..4e9b4b83b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -41,6 +41,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index b9b235b54e..2eb73c83d0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -119,6 +119,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json index 91a2f259d8..c876ba22e0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRevertResponse.json @@ -41,6 +41,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index b51410b6a9..d56f80ebd7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -41,6 +41,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 10664a9617..8ca47fd81c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -119,6 +119,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index b51428bd55..329167a240 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -41,6 +41,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 09482edb00..51ed4e037c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -38,6 +38,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 8eff63edc6..af5b9161fd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -41,6 +41,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 86b07a6c15..3293e05bd5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -38,6 +38,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index 824b5916eb..3da13ac0c4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -38,6 +38,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index a02a4d10b4..771c7821da 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -38,6 +38,7 @@ "contextWindowExceeded", "sessionBudgetExceeded", "usageLimitExceeded", + "rateLimitExceeded", "serverOverloaded", "cyberPolicy", "misalignmentPolicyViolation", diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index e1dfe6eaa4..77ebebfd7e 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst index 71ff2b933e..e859566915 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-stable.json.zst differ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 142048048f..e93d7fd59a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "sessionBudgetExceeded" | "usageLimitExceeded" | "rateLimitExceeded" | "serverOverloaded" | "cyberPolicy" | "misalignmentPolicyViolation" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs index ddcf232b47..b5a68a073a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -78,6 +78,7 @@ pub enum CodexErrorInfo { ContextWindowExceeded, SessionBudgetExceeded, UsageLimitExceeded, + RateLimitExceeded, ServerOverloaded, CyberPolicy, MisalignmentPolicyViolation, @@ -125,6 +126,7 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, CoreCodexErrorInfo::SessionBudgetExceeded => CodexErrorInfo::SessionBudgetExceeded, CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, + CoreCodexErrorInfo::RateLimitExceeded => CodexErrorInfo::RateLimitExceeded, CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, CoreCodexErrorInfo::MisalignmentPolicyViolation => { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 4e8b55fca7..372f0707ef 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -38,6 +38,7 @@ use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandbox use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; use codex_protocol::protocol::AgentStatus as CoreAgentStatus; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; @@ -4488,11 +4489,19 @@ fn codex_error_info_serializes_http_status_code_in_camel_case() { } #[test] -fn codex_error_info_serializes_cyber_policy_in_camel_case() { - assert_eq!( - serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), - json!("cyberPolicy") - ); +fn core_error_info_converts_to_camel_case() { + for (core, expected) in [ + (CoreCodexErrorInfo::CyberPolicy, json!("cyberPolicy")), + ( + CoreCodexErrorInfo::RateLimitExceeded, + json!("rateLimitExceeded"), + ), + ] { + assert_eq!( + serde_json::to_value(CodexErrorInfo::from(core)).unwrap(), + expected + ); + } } #[test] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index bda8322766..3f6c6b79e3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1767,6 +1767,7 @@ Ownership rejections for parent-owned Multi-Agent V2 subagents return JSON-RPC e - `ContextWindowExceeded` - `SessionBudgetExceeded` - `UsageLimitExceeded` +- `rateLimitExceeded`: an upstream rate limit received inside a streaming response; the turn fails with this category only after its existing stream retry budget is exhausted - `misalignmentPolicyViolation`: a non-retryable request blocked by the misalignment policy - `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx - `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index b43e3c3e5c..5735044303 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -2505,6 +2505,7 @@ fn websocket_error_detail(err: &ApiError) -> String { | ApiError::QuotaExceeded | ApiError::UsageNotIncluded | ApiError::Retryable { .. } + | ApiError::RateLimitExceeded { .. } | ApiError::RateLimit(_) | ApiError::InvalidRequest { .. } | ApiError::CyberPolicy { .. } diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index aa536e0a25..acb40613c7 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -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 } => { diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 62e153be6c..06f31c8682 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -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] diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index 7929326aad..5ac83ca1d4 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -23,6 +23,11 @@ pub enum ApiError { message: String, delay: Option, }, + #[error("rate limit exceeded: {message}")] + RateLimitExceeded { + message: String, + delay: Option, + }, #[error("rate limit: {0}")] RateLimit(String), #[error("invalid request: {message}")] diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 3712b28c78..b4cc3fa17d 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -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:?}"), + } } } diff --git a/codex-rs/core/tests/suite/retry_after.rs b/codex-rs/core/tests/suite/retry_after.rs index 32f9d362f7..4cba8fcbc5 100644 --- a/codex-rs/core/tests/suite/retry_after.rs +++ b/codex-rs/core/tests/suite/retry_after.rs @@ -587,14 +587,17 @@ async fn compact_v2_stream_failure_without_retry_after_exhausts_stream_retries() match wait_for_event(&test.codex, |_| true).await { EventMsg::Error(error) => { error_events += 1; - assert_eq!(error.codex_error_info, Some(CodexErrorInfo::Other)); + assert_eq!( + error.codex_error_info, + Some(CodexErrorInfo::RateLimitExceeded) + ); assert!(error.message.contains("Rate limit exceeded.")); } EventMsg::StreamError(_) => stream_error_events += 1, EventMsg::TurnComplete(event) => { assert_eq!( event.error.and_then(|error| error.codex_error_info), - Some(CodexErrorInfo::Other) + Some(CodexErrorInfo::RateLimitExceeded) ); break; } @@ -1001,14 +1004,17 @@ async fn sse_failure_without_retry_after_exhausts_stream_retries() -> Result<()> match wait_for_event(&test.codex, |_| true).await { EventMsg::Error(error) => { error_events += 1; - assert_eq!(error.codex_error_info, Some(CodexErrorInfo::Other)); + assert_eq!( + error.codex_error_info, + Some(CodexErrorInfo::RateLimitExceeded) + ); assert!(error.message.contains("Rate limit exceeded.")); } EventMsg::StreamError(_) => stream_error_events += 1, EventMsg::TurnComplete(event) => { assert_eq!( event.error.and_then(|error| error.codex_error_info), - Some(CodexErrorInfo::Other) + Some(CodexErrorInfo::RateLimitExceeded) ); break; } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs index 7dc331c255..d28515bd5d 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs @@ -359,7 +359,10 @@ impl LunaSampler { let retryable = match error { LunaSamplerError::ConnectionTimeout | LunaSamplerError::Api( - ApiError::Retryable { .. } | ApiError::Stream(_) | ApiError::ServerOverloaded, + ApiError::Retryable { .. } + | ApiError::RateLimitExceeded { .. } + | ApiError::Stream(_) + | ApiError::ServerOverloaded, ) | LunaSamplerError::Api(ApiError::Transport( TransportError::RetryLimit diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index 65ed48e035..c4a314e4d0 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -91,6 +91,9 @@ pub enum CodexErrorDetails { /// The Session loop treats this as a transient error and will automatically retry the turn. #[error("stream disconnected before completion: {0}")] Stream(String), + /// A retryable upstream rate limit received inside the response stream. + #[error("rate limit exceeded: {0}")] + RateLimitExceeded(String), #[error( "Codex ran out of room in the model's context window. Start a new thread or clear earlier history before retrying." )] @@ -388,6 +391,7 @@ impl CodexErr { | CodexErrorDetails::CyberPolicy { .. } | CodexErrorDetails::MisalignmentPolicyViolation { .. } => false, CodexErrorDetails::Stream(..) + | CodexErrorDetails::RateLimitExceeded(_) | CodexErrorDetails::Timeout | CodexErrorDetails::RequestTimeout | CodexErrorDetails::UnexpectedStatus(_) @@ -424,6 +428,7 @@ impl CodexErr { match &self.details { CodexErrorDetails::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, CodexErrorDetails::SessionBudgetExceeded => CodexErrorInfo::SessionBudgetExceeded, + CodexErrorDetails::RateLimitExceeded(_) => CodexErrorInfo::RateLimitExceeded, CodexErrorDetails::UsageLimitReached(_) | CodexErrorDetails::QuotaExceeded | CodexErrorDetails::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded, diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index 1020a8238b..2590994653 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -36,6 +36,10 @@ fn codex_err_debug_preserves_legacy_shape() { fn retryability_preserves_error_details_distinctions() { let errors = [ (CodexErr::ServerOverloaded, false), + ( + CodexErr::new(CodexErrorDetails::RateLimitExceeded("retry later".into())), + true, + ), ( CodexErr::RetryLimit(RetryLimitReachedError { status: StatusCode::TOO_MANY_REQUESTS, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 55b319f781..efabf5ff8b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1820,6 +1820,7 @@ pub enum CodexErrorInfo { ContextWindowExceeded, SessionBudgetExceeded, UsageLimitExceeded, + RateLimitExceeded, ServerOverloaded, CyberPolicy, MisalignmentPolicyViolation, @@ -1859,6 +1860,7 @@ impl CodexErrorInfo { Self::ContextWindowExceeded | Self::SessionBudgetExceeded | Self::UsageLimitExceeded + | Self::RateLimitExceeded | Self::ServerOverloaded | Self::CyberPolicy | Self::MisalignmentPolicyViolation diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index 822210d144..42825a7c98 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -80,6 +80,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { ApiError::QuotaExceeded => "quota exceeded".to_string(), ApiError::UsageNotIncluded => "usage not included".to_string(), ApiError::Retryable { .. } => "retryable error".to_string(), + ApiError::RateLimitExceeded { .. } => "rate limit exceeded".to_string(), ApiError::RateLimit(_) => "rate limit".to_string(), ApiError::InvalidRequest { .. } => "invalid request".to_string(), ApiError::CyberPolicy { .. } => "cyber policy".to_string(), @@ -134,7 +135,7 @@ mod tests { } #[test] - fn telemetry_error_messages_omit_http_bodies() { + fn telemetry_error_messages_omit_upstream_bodies() { let transport = TransportError::Http { status: StatusCode::UNAUTHORIZED, url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), @@ -147,6 +148,13 @@ mod tests { telemetry_api_error_message(&ApiError::Transport(transport)), "http 401" ); + assert_eq!( + telemetry_api_error_message(&ApiError::RateLimitExceeded { + message: "private upstream diagnostic".to_string(), + delay: Some(std::time::Duration::from_secs(1)), + }), + "rate limit exceeded" + ); } #[test] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_exceeded_error.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_exceeded_error.snap new file mode 100644 index 0000000000..dc9b434cd2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_exceeded_error.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: lines_to_single_string(&lines) +--- +■ rate limit exceeded: Please try again in 10s. diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 12d6efcc2b..7b99cda901 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1,4 +1,6 @@ use super::*; +use codex_protocol::error::CodexErr; +use codex_protocol::error::CodexErrorDetails; use pretty_assertions::assert_eq; const SAFETY_BUFFERING_HEADER_TEXT: &str = @@ -1361,6 +1363,26 @@ async fn live_app_server_stream_recovery_restores_previous_status_header() { assert!(chat.status_state.retry_status_header.is_none()); } +#[tokio::test] +async fn live_app_server_rate_limit_error_renders_upstream_message() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let error = CodexErr::from(CodexErrorDetails::RateLimitExceeded( + "Please try again in 10s.".to_string(), + )); + + handle_error( + &mut chat, + error.to_string(), + Some(error.to_codex_protocol_error().into()), + ); + + let lines = drain_insert_history(&mut rx) + .into_iter() + .flatten() + .collect::>(); + assert_chatwidget_snapshot!("rate_limit_exceeded_error", lines_to_single_string(&lines)); +} + #[tokio::test] async fn live_app_server_server_overloaded_error_renders_warning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;