Errors come in two shapes. Most are FastAPI's default. A detail string meant for a log. The ones a client has to branch on carry an object instead, with a stable code.
// Unstructured: read it, log it, show it. { "detail": "Too many failed login attempts. Try again later." } // Structured: branch on detail.code, render detail.message. { "detail": { "code": "feature_limit_reached", "feature": "tcc", "used": 40, "limit": 40, "tier": "basic" } }
The codes#
code | HTTP | Meaning and what to do |
|---|---|---|
email_unverified | 403 | The account has not confirmed its email. Includes the email it was sent to. Resend with POST /auth/resend-verification. |
payment_past_due | 403 | The last invoice failed. Send the customer to POST /billing/portal. |
allowance_exhausted | 403 | The plan's generation allowance is spent for this period. Top up or wait. Not retryable. |
feature_limit_reached | 403 | A per-feature cap. Carries feature, used, limit and tier, so the message can be exact. |
database_locked | 403 | /db/* requires Unlimited. Carries upgrade: true. |
contracts_locked | 403 | Lead Scanner and contract endpoints require Unlimited. |
premium_required | 403 | Pre-Job Analyzer and coordination require Premium or above. |
ai_busy | 503 | Generation capacity is full. The request never started. Retryable: honour Retry-After. |
Status codes#
| Code | Means |
|---|---|
200 / 201 / 204 | Success. 201 on create, 204 on delete with no body. |
401 | Not authenticated, or the session has ended. See why a request returns 401. |
403 | Authenticated but not permitted: plan, role, verification or billing. Re-authenticating will not help. |
404 | No such resource for this organization. Also what you get for another tenant's id. |
422 | The body failed schema validation. detail is FastAPI's array of field errors. |
429 | A credential throttle. Wait out the window; do not retry immediately. |
503 | Generation capacity full. The only status on this list worth an automatic retry. |
// detail is a string for unstructured errors and an object for the // ones worth branching on, so narrow before reading .code. function errorCode(body: unknown): string | null { const d = (body as any)?.detail return d && typeof d === 'object' ? d.code ?? null : null } switch (errorCode(await res.json())) { case 'ai_busy': return retryAfter(res) case 'database_locked': case 'premium_required': return promptUpgrade() case 'payment_past_due': return openBillingPortal() case 'allowance_exhausted': return promptTopUp() default: throw new Error('unhandled') }
def error_code(resp): """None for unstructured errors, detail is a str there.""" try: detail = resp.json().get("detail") except ValueError: return None return detail.get("code") if isinstance(detail, dict) else None code = error_code(resp) if code == "ai_busy": # Never started, so a retry cannot duplicate work. time.sleep(int(resp.headers.get("Retry-After", 10))) elif code in ("database_locked", "premium_required"): prompt_upgrade() elif code == "allowance_exhausted": prompt_top_up() # retrying will not help