From 102fc57e4ac485f7a669d3bc6a8994335c3fbe46 Mon Sep 17 00:00:00 2001 From: jif Date: Thu, 10 Sep 2026 10:21:38 +0000 Subject: [PATCH] Distinguish HTTP quota errors from rate limits (#44492) ## Why HTTP 429 responses for exhausted quota, credit balances, and spending or usage limits were reported as retry-limit failures instead of usage-limit errors. ## What changed Parse the API error's `code` and map `insufficient_quota`, `credit_balance_exhausted`, `organization_spend_limit_exceeded`, `project_spend_limit_exceeded`, and `organization_usage_limit_exceeded` to `CodexErr::QuotaExceeded`. Also recognize `insufficient_quota` in the error's `type` field. ## Testing Add a regression test covering all recognized quota errors and verifying that `rate_limit_exceeded` and `slow_down` HTTP 429 errors retain their existing retry-limit mapping. GitOrigin-RevId: b075dba199e08d29563630f5ada846e3e7fd11ff --- codex-rs/codex-api/src/api_bridge.rs | 14 ++++++++++ codex-rs/codex-api/src/api_bridge_tests.rs | 30 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index e8d7c4743c..6b79b4754a 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -159,6 +159,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr { }); } else if err.error.error_type.as_deref() == Some("usage_not_included") { return CodexErr::UsageNotIncluded; + } else if err.error.error_type.as_deref() == Some("insufficient_quota") + || matches!( + err.error.code.as_deref(), + Some( + "insufficient_quota" + | "credit_balance_exhausted" + | "organization_spend_limit_exceeded" + | "project_spend_limit_exceeded" + | "organization_usage_limit_exceeded" + ) + ) + { + return CodexErr::QuotaExceeded; } } @@ -263,6 +276,7 @@ struct UsageErrorResponse { #[derive(Debug, Deserialize)] struct UsageErrorBody { + code: Option, #[serde(rename = "type")] error_type: Option, plan_type: Option, diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 666dea8864..51fb5a28b2 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -322,6 +322,36 @@ fn map_api_error_keeps_unknown_400_errors_generic() { assert_eq!(message, &body); } +#[test] +fn map_api_error_distinguishes_http_quota_errors_from_rate_limits() { + for error in [ + serde_json::json!({"type": "insufficient_quota"}), + serde_json::json!({"code": "insufficient_quota"}), + serde_json::json!({"code": "credit_balance_exhausted"}), + serde_json::json!({"code": "organization_spend_limit_exceeded"}), + serde_json::json!({"code": "project_spend_limit_exceeded"}), + serde_json::json!({"code": "organization_usage_limit_exceeded"}), + serde_json::json!({"type": "rate_limit_error", "code": "rate_limit_exceeded"}), + serde_json::json!({"type": "rate_limit_error", "code": "slow_down"}), + ] { + let expected = if error["type"] == "rate_limit_error" { + CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(429), + } + } else { + CodexErrorInfo::UsageLimitExceeded + }; + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::TOO_MANY_REQUESTS, + url: None, + headers: None, + body: Some(serde_json::json!({"error": error}).to_string()), + })); + + assert_eq!(err.to_codex_protocol_error(), expected, "{error}"); + } +} + #[test] fn map_api_error_maps_usage_limit_limit_name_header() { let mut headers = HeaderMap::new();