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
This commit is contained in:
jif
2026-09-10 10:21:38 +00:00
committed by copyberry
parent 537278c65f
commit 102fc57e4a
2 changed files with 44 additions and 0 deletions

View File

@@ -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<String>,
#[serde(rename = "type")]
error_type: Option<String>,
plan_type: Option<PlanType>,

View File

@@ -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();