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
| Status | Means | Body |
|---|---|---|
401 | No credential, or the credential is wrong, rotated, or revoked. | {"detail":"unknown api key"} |
402 | A budget gate fired — tenant, per-key, or per-agent — before the call reached the provider. | {"detail":{"code":"budget_exceeded",...}} — see below |
403 | Authenticated, but the key's scope, model allowlist, or tool binding does not cover this request. | {"detail":{"error":"model_not_allowed","model":...,"allowed_models":[...]}} |
400 | The request shape itself is invalid (e.g. streaming an image-generation call). | {"detail":{"code":"invalid_request",...}} |
429 | A 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",...}} |
409 | The 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 a429circuit-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 theaudit:readscope 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.

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)import { Forgebench, BudgetExceededError, PermissionDeniedError, AuthenticationError } from "@seedlinglabs/forgebench-sdk";
const forgebench = new Forgebench({ apiKey: "sk_...", baseUrl: "https://api.forgebench.ai" });
const messages = [{ role: "user" as const, content: "..." }];
try {
const res = await forgebench.chat.create({ model: "gpt-4o", messages });
} catch (e) {
if (e instanceof BudgetExceededError) {
console.warn("budget gate refused the call", e.body);
} else if (e instanceof PermissionDeniedError) {
console.error("not authorized", e.body);
} else if (e instanceof AuthenticationError) {
console.error("auth failed", e.body);
} else {
throw e;
}
}
