| title | Errors |
|---|---|
| description | Full NullRun error-code reference: NR-B004 budget blocks, NR-T001 transport errors, NR-R001 refusals, and decision vs infrastructure classes. |
The canonical ApiErrorCode enum is the source of truth for every
non-2xx response the gateway returns. This page maps each code to:
- the SDK exception the Python SDK raises when it sees that error
- the HTTP status code the gateway returns
- when it happens
There are two parallel taxonomies you may see:
- Gateway error slugs — short SCREAMING_SNAKE_CASE strings in the
errorfield of every non-2xx response. Listed below in Gateway error codes. - NR- codes* — the SDK's user-facing
error_codefield on every exception. The full catalog is below in NR- error code catalog*.
For the three-layer error model (structured exceptions →
on_error hook → format_user_message / with nullrun.guard():)
and the boundary between developer-facing and end-user-facing
wording, see Concepts → Error handling.
The canonical catalog lives in the gateway. The error slug is
the stable, machine-readable identifier; message is human-safe;
code carries the same value in SCREAMING_SNAKE_CASE.
error slug |
HTTP | When | SDK exception |
|---|---|---|---|
bad_request |
400 | Generic 400 — invalid input that isn't a validation failure | NullRunConfigError (or NullRunError) |
invalid_json |
400 | Request body could not be parsed as JSON (JsonSyntaxError). Surfaces as INVALID_JSON on exc.error_code |
NullRunBackendError |
unauthorized |
401 | Missing or invalid X-API-Key / expired session / HMAC mismatch |
NullRunAuthenticationError (NullRunAuthError for 401 specifically) |
forbidden |
403 | Authenticated but not allowed (incl. CSRF mismatch, org-mismatch on /orgs/*) |
NullRunAuthenticationError |
not_found |
404 | Resource doesn't exist or isn't visible | (no exception — caller handles) |
conflict |
409 | Idempotency conflict, duplicate, "already a member", "invite already pending", "cannot demote last owner", etc. | NullRunError |
validation_error |
422 | Request body parsed but failed schema validation (JsonDataError). Surfaces as INVALID_FIELD on exc.error_code |
NullRunBackendError |
plan_limit_exceeded |
422 | Generic plan cap hit (workflows, seats, api_keys). Body details.resource carries which dimension. |
NullRunBlockedException |
workflow_limit_reached |
422 | Workflow-specific active-workflow cap hit | NullRunBlockedException |
rate_limit_exceeded |
429 | Per-minute / per-day rate cap. Body carries retry_after (seconds). |
RateLimitError (carries .retry_after, .upgrade_url) |
internal_error |
500 | Server-side bug | NullRunBackendError (retryable) |
not_implemented |
501 | Feature not yet implemented | NullRunError |
(also internal_error) |
503 | ApiError::ServiceUnavailable — transient downstream failure on an enforcement path. Carries retry_after. |
NullRunBackendError (retryable) |
Plan limit slugs (api_keys / seats / policies / executions) all surface as
plan_limit_exceededwithdetails.resourceset to the dimension name ("api_keys","seats","workflows", …). There is no separate slug per dimension — readdetails.resource.
Every public SDK exception inherits from NullRunError and carries
four structured fields: error_code (machine-readable, e.g.
"NR-B004"), user_action (imperative hint), retryable
(bool), docs_url.
Every exception below is raised from nullrun.breaker.exceptions.
Only the names in nullrun.__all__ are importable from the top-level
nullrun package; the rest come from the module.
NullRunError (Exception)
├── NullRunDecision (marker — expected policy outcomes)
│ ├── NullRunBlockedException (policy / budget / loop / sensitive block)
│ │ ├── NullRunBudgetError (budget exhausted — NR-B004)
│ │ │ └── NullRunBudgetRecheckFailedError (NR-B006 — post-approval recheck)
│ │ ├── NullRunToolBlockedError (tool in block list — NR-T001)
│ │ └── NullRunApprovalDbUnavailableError (approval DB unavailable — NR-A016)
│ ├── NullRunConsumeOverbudgetError (actual cost > reservation + ε — NR-O001)
│ ├── NullRunChainError (chain-mode gate check failed — NR-CH001)
│ ├── WorkflowPausedException (paused via control plane — NR-W003)
│ └── NullRunWorkflowInactiveError (soft-deleted / inactive — NR-W004)
├── WorkflowKilledInterrupt (kill via control plane — NR-W002)
│ └── NullRunWorkflowKilledError (typed public name; same code NR-W002)
└── NullRunInfrastructureError (marker — system failures)
├── NullRunConfigError (misconfiguration, e.g. missing api_key)
├── NullRunAuthenticationError (401 / 403)
│ └── NullRunAuthError (401 specifically)
├── NullRunProtocolError (wire-protocol version mismatch — NR-P001)
├── NullRunRateLimitRedisError (rate-limit Redis down — NR-R002)
└── NullRunTransportError (transport failures)
├── NullRunBackendError (5xx — retryable; BREAKER_OPEN → NR-B005)
└── RateLimitError (gateway 429 — carries .retry_after)
Two branches sit outside the marker split, and both matter when
you write an except chain:
WorkflowKilledInterruptinherits fromNullRunErrordirectly, not fromNullRunDecision. A kill is not a policy decision, soexcept NullRunDecisiondoes not catch it — catch it by name.NullRunApprovalDbUnavailableErrorinherits fromNullRunBlockedException, so it lands in the decision branch even though an unavailable approval database is a system failure. Itserror_code(NR-A016) is what distinguishes it.
NullRunDecision and NullRunInfrastructureError are marker
classes, not exception classes themselves. They exist so host code
can except NullRunDecision to catch every expected policy outcome
(budget, tool block, pause) and except NullRunInfrastructureError to
catch every system failure (transport, backend 5xx, auth rejection,
config error) — see Decision vs. infrastructure
below for the recommended handling pattern.
NullRunBlockedException carries .workflow_id, .reason, .action
("block" / "kill" / "pause"), .tool_name (when the block is
tool-scoped), .status_code, and .details (free-form). There is
no .message attribute — use str(exc).
WorkflowKilledInterrupt (and its typed subclass NullRunWorkflowKilledError)
both inherit from NullRunError, so a bare
except Exception: catches the kill signal. For kill-specific
handling — checkpointing state, notifying a supervisor, etc. —
catch the typed exception explicitly.
For the common "run an agent and print a friendly message on failure"
case, the three public helpers do the work — no try/except NullRunError
required.
| Helper | Catches | For |
|---|---|---|
init(api_key=..., fail_on_exit=True) |
NullRunError raised by init() (typically a config / auth family code) |
Startup; one-shot script entry point |
with nullrun.guard(): |
Any NullRunError raised inside the block (kill signal re-raises) |
Region of code (e.g. a graph invoke) — recommended form |
Both helpers propagate non-NullRunError exceptions (anything
that isn't an SDK error) as honest tracebacks. NullRunError
subclasses raised inside with nullrun.guard(): are caught and
rendered as the structured four-line developer report
(catalog headline + [error_code] + what + where + why +
how to fix), then the process exits 1. The kill signal
(WorkflowKilledInterrupt / NullRunWorkflowKilledError) is the
one exception: guard() re-raises it unchanged so kill always
reaches the top of the agent loop. Catch kill explicitly with
except NullRunWorkflowKilledError: if you need to checkpoint
state before exit. For the full design rationale and the
boundary between "what NullRun tells the developer" and "what
the developer tells their end users", see
Concepts → Error handling.
The public exception hierarchy splits NullRunError into two marker
subclasses by what kind of event the exception represents. The
split is additive — every existing except NullRunError: and
except NullRunBlockedException: clause keeps matching. New code can
use the marker classes to write a two-branch handler that captures
the right behaviour for each category.
| Marker | What it covers | Why it matters |
|---|---|---|
NullRunDecision |
Expected policy outcomes — budget cap, tool block, chain-gate rejection, loop detection, workflow pause, over-budget commit | The enforcement layer is doing its job. UX explains the decision and (where applicable) offers an upgrade or alternative action. |
NullRunInfrastructureError |
System failures — network unreachable, gateway 5xx, gateway 429, auth rejection, config error, rate-limit Redis down | The SDK could not reach or query the policy engine. UX is a generic "service unavailable"; operators triage via error_code, retryable, and for transport errors, source / endpoint. |
Rate limits land on the infrastructure side, not the decision
side: a 429 from the gateway arrives as RateLimitError, a subclass
of NullRunTransportError → NullRunInfrastructureError. Handle it
as a system failure with a retry, and surface upgrade_url if the
user genuinely needs a higher plan.
WorkflowKilledInterrupt belongs to neither marker — catch it by
name ahead of both, as shown in the exception
hierarchy.
import nullrun
from nullrun.breaker.exceptions import (
NullRunDecision,
NullRunInfrastructureError,
)
try:
result = agent.run(message)
except NullRunDecision as d:
# Expected — surface to the user, log to product analytics,
# tag the conversation with d.error_code for cohort analysis.
analytics.track("nullrun_decision", code=d.error_code)
return nullrun.format_user_message(d)
except NullRunInfrastructureError as e:
# System failure — alert ops, retry with backoff, do NOT
# surface internal text to the end user. The catalog has a
# generic message for every infrastructure error code.
sentry.capture_exception(e)
return nullrun.format_user_message(e)When you build a server-framework integration (FastAPI, aiohttp,
Telegram bot, Slack handler), map each category to the right HTTP
status. The headline cases are below. Where the backend supplied a
wire status, the exception carries it as .status_code and you can
map the field directly instead of hard-coding.
| Category | HTTP status | Notes |
|---|---|---|
NullRunDecision — budget exhausted (NR-B004) |
402 |
NullRunBudgetError carries .status_code. Read .details for any retry_after the backend supplied |
NullRunDecision — tool blocked (NR-T001) |
403 |
User did nothing wrong, but the action is forbidden. Carries .status_code |
NullRunDecision — workflow paused (NR-W003) |
503 |
WorkflowPausedException has no .status_code; it carries .resume_after, which you pass as the Retry-After header |
NullRunDecision — chain gate failed (NR-CH001) |
402 or 403 |
NullRunChainError. One code covers both: CHAIN_MAX_DURATION_EXCEEDED → 402, CHAIN_ORG_MISMATCH / CHAIN_CROSS_ORG → 403. Read .status_code |
NullRunInfrastructureError — rate-limit Redis (NR-R002) |
503 |
NullRunRateLimitRedisError — the rate limiter is degraded |
NullRunInfrastructureError — gateway 429 |
503 |
RateLimitError carries .retry_after and .upgrade_url |
WorkflowKilledInterrupt (NR-W002) |
503 |
Special ASGI middleware required — see Use with FastAPI |
Other decision categories (CONSUME_OVERBUDGET → 422,
WORKFLOW_INACTIVE → 403) and infrastructure codes
(PROTOCOL_TOO_OLD → 400, generic NullRunInfrastructureError → 503)
follow the same pattern: read exc.status_code from the wire when the
attribute is present and map it directly.
.status_code is not on every exception — it exists only where
__init__ accepts one, which today is the NullRunBlockedException
family plus NullRunConsumeOverbudgetError and NullRunChainError.
WorkflowPausedException, WorkflowKilledInterrupt,
NullRunApprovalDbUnavailableError and the transport classes do not
carry it, so read it with
getattr(exc, "status_code", None) and fall back to your category
default. The FastAPI integration applies this mapping for you; in
custom integrations do the same rather than assuming the attribute is
there.
The NullRun SDK ships a reference FastAPI integration that applies this mapping for you — see Use with FastAPI for a one-line setup.
| Status | Meaning | SDK action |
|---|---|---|
| 200 | OK | — |
| 400 | Bad request | Inspect message, fix request |
| 401 | Bad API key / HMAC | Refresh key / check NULLRUN_SECRET_KEY |
| 403 | Forbidden | Check role / scope |
| 404 | Not found | Caller handles (workflow/policy may have been deleted) |
| 409 | Conflict | Inspect message (already-member, invite-already-pending, etc.) |
| 422 | Validation / plan limit | Inspect details (for plan limits, details.resource + details.current + details.limit) |
| 429 | Rate limit | Honour Retry-After; check upgrade_url |
| 5xx | Gateway error | Retry with backoff; sensitive tools fail-closed |
When the gateway is unreachable, the SDK raises
NullRunTransportError with source set to one of NETWORK_ERROR,
GATEWAY_ERROR, AUTH_ERROR.
Stable, machine-readable identifiers on every SDK exception. The catalog splits into two families:
- decision / enforcement codes — what the gate decided (block,
deny, require approval, …). Most are surfaced as
NullRunDecisionsubclasses in Python. - infrastructure codes — transport / backend / config failures.
Surfaced as
NullRunInfrastructureErrorsubclasses.
The three-layer error model and the boundary between developer-facing and end-user-facing wording lives in Concepts → Error handling.
error_code |
When | HTTP | SDK class |
|---|---|---|---|
NR-B004 |
Workflow budget exhausted | 402 | NullRunBudgetError |
NR-B006 |
Post-approval budget re-check failed on /execute (budget counter moved between /gate reserve and /execute) |
402 | NullRunBudgetRecheckFailedError |
NR-L001 |
Loop detected — the same tool repeated past the per-policy threshold. Wire slug LOOP_DETECTED |
429 | NullRunBlockedException |
NR-O001 |
Actual cost > reservation + ε | 422 | NullRunConsumeOverbudgetError |
NR-T001 |
Tool in block list | 403 | NullRunToolBlockedError |
NR-T003 |
An action-source (MCP) tool call was blocked by the gate before the client was invoked — raised only when MCPAdapter is constructed with an explicit runtime= |
403 | NullRunBlockedException |
NR-CH001 |
Chain context invalid (chain_id / parent_execution_id / max_duration exceeded) | 402 | NullRunChainError |
NR-EX01 |
/execute or /cancel called without a prior /gate that minted this execution_id (binding TTL expired or never bound) |
404 | NullRunExecutionNotFoundError |
NR-W002 |
Operator kill signal (via dashboard Kill button or WS push) | n/a (raised) | NullRunWorkflowKilledError (alias WorkflowKilledInterrupt) |
NR-W003 |
Workflow paused via dashboard or chain cooldown | 503 | WorkflowPausedException |
NR-W004 |
Workflow soft-deleted (inactive) — restore it to resume traffic | 403 | NullRunWorkflowInactiveError |
NR-A003 |
API key rejected | 401 | NullRunAuthError |
NR-A004 |
Approval response missing — gate returned require_approval but no row found on /execute |
403 | NullRunApprovalResponseMissingError |
NR-A010 |
Approval exists, status PENDING — operator has not decided yet |
403 | NullRunApprovalNotYetApprovedError |
NR-A011 |
Operator explicitly denied the approval | 403 | NullRunApprovalDeniedError |
NR-A012 |
Approval expired (expires_at in the past) |
403 | NullRunApprovalExpiredError |
NR-A013 |
Business-impact digest drifted since approval — re-approval required | 403 | NullRunApprovalDigestMismatchError |
NR-A014 |
Capability digest drifted since approval — re-approval required | 403 | NullRunApprovalToolDigestMismatchError |
NR-A015 |
Grant already consumed (replay rejected) | 403 | NullRunApprovalReplayRejectedError |
NR-A016 |
Approval database unavailable — transient 5xx on the approval row lookup | 503 | NullRunApprovalDbUnavailableError (fail-CLOSED — retry with backoff) |
NR-MCP01 |
Destructive MCP tool blocked by the mcp_destructive_policy umbrella. Wire slug MCP_DESTRUCTIVE_BLOCKED |
403 | NullRunMcpDestructiveBlockedError |
NR-MCP02 |
Read-only MCP tool blocked because the operator's bypass path is closed. Wire slug MCP_READONLY_BYPASS_BLOCKED |
403 | NullRunMcpReadonlyBypassBlockedError |
NR-MCP03 |
MCP tool requires operator approval — the MCP counterpart of NR-A010. Wire slug MCP_APPROVAL_REQUIRED |
403 | NullRunMcpApprovalRequiredError (retryable — the operator can still act) |
NR-X001 |
Generic policy block — no dedicated subclass | varies | NullRunBlockedException (default) |
Approval grant-consume codes (NR-A010..NR-A015) are most often seen
inside a running agent flow: the SDK has parked for human review and
the operator has acted (or the grant aged out). Match on the
specific subclass first; fall back to NullRunBlockedException if
you don't care about the exact cause.
error_code |
When | HTTP | SDK class |
|---|---|---|---|
NR-B001 |
Transport-layer network error (timeout, ConnectError, DNS failure) | 500 | NullRunTransportError (default) |
NR-B002 |
Gateway 5xx | 500/503 | NullRunBackendError |
NR-B005 |
Local SDK circuit breaker tripped — short-circuits before the wire call | 503 | NullRunBackendError (with source = BREAKER_OPEN) |
NR-R001 |
Per-workflow rate limit hit (gateway returned 429 with Retry-After) |
429 | RateLimitError (subclass of NullRunTransportError — infrastructure class despite the 429 status) |
NR-R002 |
Rate-limit Redis unavailable (aggregate per-org rate-limit fail-CLOSED) | 503 | NullRunRateLimitRedisError |
NR-C000 |
Misconfiguration (missing api_key, invalid setup) | n/a (raised) | NullRunConfigError (default) |
NR-C001 |
No API key available — NULLRUN_API_KEY unset and none passed to init() |
n/a (raised) | NullRunAuthenticationError via init(); NullRunConfigError via the runtime |
NR-A001 |
Auth rejected by backend (general) | 401/403 | NullRunAuthenticationError (default) |
NR-A002 |
/auth/verify returned 200 but the body carried no organization_id — a wire-shape mismatch between SDK and gateway |
n/a (raised) | NullRunAuthenticationError |
NR-S001 |
NULLRUN_SKIP_BUDGET_CHECK=1 is refused against the production gateway — the dev bypass is not available on production traffic |
n/a (raised) | NullRunConfigError |
NR-P001 |
Wire-protocol version mismatch | 400 | NullRunProtocolError |
NR-T-PARSE |
Gateway response body could not be parsed as JSON | 502 | NullRunTransportError |
INVALID_JSON |
Request body failed JSON parsing (JsonSyntaxError) — error slug invalid_json |
400 | NullRunBackendError |
INVALID_FIELD |
Request body parsed but failed schema validation (JsonDataError) — error slug validation_error |
422 | NullRunBackendError |
- Concepts → Error handling — the three-layer model, minimal-boilerplate helpers, dev/end-user boundary
- SDK API
- SDK API → User-facing messages
- Use with FastAPI
- HTTP API
- Circuit breaker
- Sensitive tools