The status tells you where to look. The code tells you what to do.

Do not turn every 403 into a payment prompt or every 429 into an endless retry loop.

Recovery table

HTTPCodeMeaningRecovery
400invalid_requestMalformed or unsupported requestFix the request
401varies by legacy envelopeMissing, malformed, revoked, or invalid tokenFix Authorization; do not retry unchanged
402payment_required, if emittedPayment action requiredCheck Console balance; do not retry unchanged
403insufficient_user_quotaObserved balance branchCheck balance or reduce request cost
403other codeModel, group, or endpoint permissionCheck access; do not show a funding prompt
404model_not_foundUnknown or unavailable model IDRead GET /v1/models
408timeoutRequest did not finish in timeRetry only safe work, with a cap
429rate_limit_exceededThroughput limitHonor Retry-After when present; add jitter
500 / 502 / 503upstream or gateway failureTransient service failureBounded backoff; save request ID; use a tested fallback

Bounded retry skeleton

Python

retryable = {408, 429, 500, 502, 503}
if response.status_code == 429 and "Retry-After" in response.headers:
    wait_seconds = min(float(response.headers["Retry-After"]), 30)
# Add jitter, cap attempts, and retry only idempotent work.

Current balance observation.

The verified negative-balance response is 403 with insufficient_user_quota. Keep support for 402 as a payment branch only if the gateway emits it; never relabel an unrelated 403.

What to keep for support

Send the request timestamp, model ID, HTTP status, error code, and request ID when available. Remove prompts and secrets. Never include an API key.