Error handling
Errors in NullRun come in three layers, designed for three audiences: your code, your monitoring, and your end users. The SDK does most of the work — you pick how much of each layer to use.
Quick reference
| Audience | Hook / Class | Catches |
|---|---|---|
| Your code | except NullRunDecision |
Expected policy outcomes (budget, tool block, pause) |
| Your code | except NullRunInfrastructureError |
Transport / 5xx / auth / config failures |
| Your code | except NullRunWorkflowKilledError (or WorkflowKilledInterrupt) |
Operator kill — terminal; caught by except Exception:, handle explicitly if you need to checkpoint before exit |
| Your monitoring | @nullrun.on_error hook |
Every NullRunError, fired before propagation |
| Your end user | @guarded / format_user_message |
Friendly text from the catalog |
Where errors appear in the dashboard
Every error the SDK raises lands in Governance → Audit log —
every decision ever recorded, hash-chained and filterable by
workflow, time range, decision type, and tool name. The reason
column shows BUDGET_HARD_BLOCKED, TOOL_BLOCKED,
RATE_LIMIT_EXCEEDED, etc. Useful both for "what just happened?"
and for compliance review / incident forensics.
The audit log is the source of truth for "did the agent call the right thing?". Pair it with Traces for full context.
The three layers
| Layer | Who consumes it | What they see | Purpose |
|---|---|---|---|
| 1. Structured exception | Your Python code | Exception type, error code, what to do next | Your code decides: retry, fail, surface to UI |
2. on_error hook |
Sentry / Datadog / logs | Same exception + context (workflow, tool, stage) | Observability: you see every error in your existing dashboards |
3. @guarded / format_user_message |
End user | One friendly sentence from a catalog | The user gets a clean message, not a stack trace |
The SDK ships all three. You decide how much to use.
Layer 1 — the structured exception
Every NullRun exception carries four fields your code can branch on:
| Field | What it is | Example |
|---|---|---|
error_code |
Stable machine-readable identifier | NR-B004, NR-R001, NR-T001 |
user_action |
What to do next | Wait 30s, then retry |
retryable |
True if retry-after-backoff makes sense | True for rate limit, False for budget |
docs_url |
URL to the per-code docs page | https://docs.nullrun.io/reference/errors#sdk-exception-hierarchy-python |
The full catalog lives in that reference page; the standard set is:
NR-B004— workflow budget exhaustedNR-B002— gateway 5xxNR-B006— post-approval budget re-check failed on the same envelope as the original/gate. The SDK raisesNullRunBudgetRecheckFailedError. Operator must re-approve or the workflow can no longer run.NR-R001— per-workflow rate limitNR-R002— rate-limit Redis unavailableNR-T001— tool block list hitNR-CH001— chain context invalidNR-W004— workflow soft-deleted or killedNR-A003— API key rejectedNR-A010— approval row exists, statusPENDING— operator has not decided yetNR-A011— operator explicitly denied the approval — terminal, request a fresh grantNR-A012— approval expired (expires_atis in the past)NR-A013— business-impact digest drifted since operator approval — re-approval requiredNR-A014— capability digest drifted (silent capability-gain attack surface) — re-approval requiredNR-A015— grant already consumed by a prior/execute(replay rejected)NR-P001— wire-protocol version mismatchNR-O001— actual cost > reservation + ε (HTTP 422)NR-X001— generic catch-all raised when a policy block matches a code the SDK does not have a dedicated class for. Match onNullRunBlockedExceptionand read.error_codeif you want specific handling.
For the exception classes used to surface these codes, see Reference → Errors → SDK exception hierarchy.
The wire code is still available via the response body or .status_code
when you need it for metrics / dashboards.
You catch a specific exception type and inspect the fields:
from nullrun.breaker.exceptions import RateLimitError
@nullrun.protect
def my_agent(prompt):
try:
return call_llm(prompt)
except RateLimitError as exc:
# exc.error_code = "NR-R001"
# exc.retryable = True
# exc.retry_after = 30 (seconds)
# exc.upgrade_url = "..." (link to upgrade plan)
time.sleep(exc.retry_after)
return call_llm(prompt)
For most cases you don't need to import specific types — catching
the parent NullRunError and reading error_code is enough.
Layer 2 — the on_error hook
For Sentry / Datadog / your log aggregator, register a hook that fires
for every NullRunError before it propagates:
import nullrun
import sentry_sdk
@nullrun.on_error
def _to_sentry(err, ctx):
sentry_sdk.capture_exception(err, extra={
"code": err.error_code,
"retryable": err.retryable,
"stage": ctx.stage,
"workflow_id": ctx.workflow_id,
"tool_name": ctx.tool_name,
})
The hook fires once per error, in registration order. Hook exceptions are caught and logged at DEBUG — a misbehaving Sentry can't break your agent.
The context object (ctx) carries: stage (init / transport /
track / gate), workflow_id, tool_name, api_key_prefix (first
12 chars of the API key, never the full value), correlation_id
(per-request UUID), timestamp, extra (vendor-specific dict).
Multiple hooks are supported:
@nullrun.on_error
def _to_sentry(err, ctx): ...
@nullrun.on_error
def _to_log(err, ctx):
log.warning("NullRun error", extra={"code": err.error_code})
The hook fires for every NullRunError subclass — including the
kill signal (WorkflowKilledInterrupt and its typed alias
NullRunWorkflowKilledError). If you want to skip kill inside the
hook, filter on error_code ("NR-W002").
Layer 3 — @guarded and format_user_message
For scripts that just want "run the agent and print a friendly message on failure", use the zero-boilerplate helpers:
from nullrun import init_or_die, guarded, protect, shutdown
init_or_die()
@guarded
@protect
def my_agent(prompt):
return call_llm(prompt)
if __name__ == "__main__":
try:
print(my_agent("What does NullRun do?"))
finally:
shutdown()
What your terminal looks like on a rate-limit hit:
@guarded catches every NullRunError — which now includes the
kill signal (WorkflowKilledInterrupt / NullRunWorkflowKilledError
both inherit from NullRunError) — prints the catalog wording to
stderr, and exits with code 1. To handle kill distinctly (for
example, checkpoint state before exit), use the un-@guarded
protect() form and add your own except NullRunWorkflowKilledError:
arm.
@guarded is for scripts and one-shots. For long-running services
you want explicit handling — see Server frameworks
below.
Branded wording
If you want your own error messages (e.g. "You've used all your
support credits" instead of the default wording), call
set_user_message once at startup:
import nullrun
nullrun.set_user_message(
"NR-B004",
"You've used all your support credits. Upgrade to keep chatting.",
)
Overrides live in a per-process dict. They don't persist across processes and aren't synced to the gateway — they're presentation sugar on top of the catalog.
Server frameworks
For FastAPI / aiohttp / Flask / Django, you don't want @guarded
(it's a CLI helper). Instead, catch the exception in your request
handler and return an appropriate HTTP status:
from nullrun import NullRunError
@app.post("/chat")
async def chat(req: ChatRequest):
try:
return await run_agent(req.message)
except NullRunError as exc:
# Return the catalog wording as the user-facing message,
# log the structured fields server-side.
raise HTTPException(
status_code=exc.status_code or 503,
detail={"message": nullrun.format_user_message(exc), "code": exc.error_code}
)
The mapping from exception to HTTP status is documented in Reference → Errors → Decision subclasses to HTTP.
Audit trail
Every decision is recorded in the audit log; you can fetch the full log via the API. The audit log is the source of truth for "did the agent call the right thing?". Pair it with Traces for full context.
What is NOT stored
NullRun never persists:
- Prompt content or LLM response payloads. The gate
receives only
model,tool,tools,estimated_tokens, and optionalbusiness_impacttyped payload. - Tool arguments beyond the typed
BusinessImpactextraction. Operators do not write JSONPath rules over tool payloads. - MCP interaction payloads — only the canonical tool name is logged.
- Card numbers, CVC, expiry month/year — Polar is the
merchant of record. Subscriptions carry only
payment_method_brandandpayment_method_last4. - OAuth refresh tokens — the IdP owns session lifetime.
Email addresses and prompts are hashed or redacted at the log and
trace-span boundary so plaintext does not reach the structured log
store. Uppercase KEY=VALUE pairs are rewritten to KEY=[REDACTED]
before bytes reach stdout.
Kill signal
The operator kill signal arrives as WorkflowKilledInterrupt or its
typed alias NullRunWorkflowKilledError (recommended). Both inherit
from NullRunError, so a bare except Exception: arm catches the
kill alongside every other SDK error:
try:
my_agent(prompt)
except Exception:
log.error("agent failed", exc_info=True)
# WorkflowKilledInterrupt IS caught here.
If you want kill-specific handling — checkpointing state, notifying a supervisor, exiting with a clean reason — catch the typed alias explicitly and re-raise it after handling (the kill contract is "operator's word is final"):
from nullrun import NullRunWorkflowKilledError
try:
my_agent(prompt)
except NullRunWorkflowKilledError:
persist_state()
raise
except NullRunError:
log.error("agent failed", exc_info=True)
@guarded catches kill via the standard NullRunError arm — it
prints the catalog wording and exits 1. To keep the process alive
on kill (checkpoint, notify a supervisor, then exit), use the
un-@guarded protect() form with your own
except NullRunWorkflowKilledError: arm above.
See also
- Reference → Errors — full catalog
- Troubleshooting — common questions and their fixes
- Use with FastAPI — exception handling inside ASGI handlers
- Tracing — how errors map to spans