Developers Errors & refusals

Errors and refusals

Every way a governed call can come back refused, what caused it, and how to handle it in code instead of retrying blind.

Status codes

StatusMeansBody
401No credential, or the credential is wrong, rotated, or revoked.{"detail":"unknown api key"}
402A budget gate fired — tenant, per-key, or per-agent — before the call reached the provider.{"detail":{"code":"budget_exceeded",...}} — see below
403Authenticated, but the key's scope, model allowlist, or tool binding does not cover this request.{"detail":{"error":"model_not_allowed","model":...,"allowed_models":[...]}}
400The request shape itself is invalid (e.g. streaming an image-generation call).{"detail":{"code":"invalid_request",...}}
429A rate limit or tool-call ceiling was hit — a different axis from budget, capped on requests/min or calls/window rather than dollars.{"detail":{"code":"rate_limit_exceeded",...}}
409The request is valid and authorized, but the resource isn't in the right state yet — e.g. deleting something that must be revoked or disabled first. Fix the resource's state, don't retry the identical call.{"detail":"only a revoked server can be deleted — revoke it first"}

401 — bad credential

{"detail":"unknown api key"}

The key is missing, malformed, was rotated (the old secret stops working the instant a new one is minted), or was revoked. Mint or rotate a key on API keys and spend.

402 — budget exceeded

{
  "detail": {
    "code": "budget_exceeded",
    "message": "monthly budget exceeded",
    "limit_usd": "0.000100",
    "spent_usd": "0.000385",
    "attempted_cost_usd": "0.005000"
  }
}

The gate refuses on the projected total — spent + attempted crossing the cap — not once spend alone has already crossed it. You can be refused while the dashboard still shows headroom: spend of $0.00542 against a $0.01 cap still refuses a call that would cost $0.005, because $0.01042 is over. See Budgets for why that is deliberate.

The same shape, with a different code, covers the per-key and per-agent variants: key_daily_limit_exceeded, key_monthly_limit_exceeded also carry a resets_at. A license_blocked/plan_required 402 means the deployment or the module itself is not entitled — a licensing gate, not a spend gate, but the same status code because both mean "refused before the call, not after".

A budget pool adds pool_budget_exceeded (the pool's limit) and pool_member_share_exceeded (the calling key's own share) to the 402 codes. The body says which limit was hit and who owns it (limit_kind, pool_owner, key_owner). Bodies are in Pool API and errors.

403 — scope, model, or tool not authorized

Three distinct causes share this status:

  • Model allowlist: {"detail":{"error":"model_not_allowed","model":...,"allowed_models":[...]}} — the key's model list does not include the one requested.
  • Tool not authorized: {"detail":{"code":"tool_not_authorized","tool_name":...}} — the model tried to call a tool the agent is not bound to. Repeated attempts against the same unauthorized tool escalate to a 429 circuit-breaker (tool_repeatedly_unauthorized) rather than refusing the same way forever.
  • Role/scope: {"detail":"requires role '<role>' or one of scopes: <scopes>"} — a console/API action the caller's role or granted scopes do not cover (e.g. reading audit without the audit:read scope or an admin role).

None of these are retryable without changing the request or the key's configuration — retrying the identical call produces the identical refusal.

Refusals are recorded

A refused call is not a dropped call. Every refusal — budget, rate limit, license, model-not-allowed, guardrail, tool-not-authorized, agent-paused — is written to the Refusals log with its cause, so a burst of refusals is visible rather than silent. It is the first place to look when an integration "stopped working" the day after someone tightened a cap or narrowed a model allowlist.

The refusal log — one row per refused call, with its cause
The refusal log — one row per refused call, with its cause

Each row's cause deep-links to the screen that governs it: a budget or rate_limit cause links to Budgets, tool_not_authorized links to the tool registry, and agent_paused links to the agent itself.

Handling refusals in code

Both SDKs map every non-2xx response to a typed error keyed off the status code — BudgetExceededError for 402, AuthenticationError for 401, PermissionDeniedError for 403, RateLimitError for 429 — so you branch on the exception type rather than string-matching a message. Retrying a 402 or 403 immediately just produces the identical refusal; degrade deliberately instead:

from forgebench import Forgebench, BudgetExceededError, PermissionDeniedError, AuthenticationError

client = Forgebench(api_key="sk_...", base_url="https://api.forgebench.ai")
messages = [{"role": "user", "content": "..."}]

try:
  resp = client.chat.completions.create(model="gpt-4o", messages=messages)
except BudgetExceededError as e:
  log.warning("budget gate refused the call: %s", e)
except PermissionDeniedError as e:
  log.error("not authorized: %s", e)
except AuthenticationError as e:
  log.error("auth failed: %s", e)

Next