Skip to content

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.

Where errors appear in the dashboard

Every error the SDK raises lands in Governance → Decision History and Governance → Audit log:

  • Decision History — the most recent N decisions per workflow, with the reason (BUDGET_HARD_BLOCKED, TOOL_BLOCKED, RATE_LIMIT_EXCEEDED, etc.). Useful for "what just happened?"
  • Audit log — every decision ever, hash-chained and filterable by workflow, time range, decision type, tool name. Useful for compliance review and 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 BUDGET_HARD_BLOCKED, RATE_LIMIT_EXCEEDED, TOOL_BLOCKED
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#BUDGET_HARD_BLOCKED

Error codes are uppercase snake-case enum values per Reference → Errors. The full catalog lives in that reference page; the canonical set per positioning §13 includes BUDGET_HARD_BLOCKED, BUDGET_SOFT_BLOCKED, BUDGET_OVERDRAFT_EXCEEDED, BUDGET_ANTI_DOS_RESERVED_CAP, BUDGET_PERIOD_NOT_STARTED, REDIS_UNAVAILABLE, CHAIN_MAX_DURATION_EXCEEDED, TOOL_BLOCKED, CHAIN_CROSS_ORG, CHAIN_ORG_MISMATCH, WORKFLOW_INACTIVE, RATE_LIMIT_EXCEEDED, RATE_LIMIT_REDIS_UNAVAILABLE, BUDGET_DATA_UNAVAILABLE, API_KEY_REVOKED, PROTOCOL_TOO_OLD, PROTOCOL_TOO_NEW, CONSUME_OVERBUDGET.

You catch a specific exception type and inspect the fields:

from nullrun import RateLimitError

@nullrun.protect
def my_agent(prompt):
    try:
        return call_llm(prompt)
    except RateLimitError as exc:
        # exc.error_code = "RATE_LIMIT_EXCEEDED"
        # 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. It does not fire for WorkflowKilledInterrupt (a BaseException — kill is a signal, not an error).

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:

$ python my_agent.py
Too many requests. Please wait a moment and try again.
$ echo $?
1

@guarded catches every NullRunError, prints the catalog wording to stderr, and exits with code 1. WorkflowKilledInterrupt still propagates — kill is final, even with @guarded.

@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(
    "BUDGET_HARD_BLOCKED",
    "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 gate decision — allow, block, require_approval — is written to audit_events with hash-chained content_hash + previous_hash. The chain is recompute-verifiable on demand via GET /api/v1/orgs/{org_id}/audit-log/verify.

Row-level immutability for the runtime role is enforced by a BEFORE UPDATE/DELETE trigger reject_audit_event_mutation, an event trigger prevent_audit_ddl, and RLS FORCE on audit_events with REVOKE UPDATE/DELETE/TRUNCATE/REFERENCES/TRIGGER. This is the integrity anchor today.

Honest disclosure (positioning §5.2):

  • The reader trusts Postgres only — there is no external attestation. An attacker with DB write access can rewrite the chain because rows are mutable at the SQL level outside the runtime role's triggers.
  • The since-cursor semantics limit the re-walked window.
  • The HTTP verify response is unsigned (no HMAC over the verify result).
  • The live JSONL export is unsigned (the S3 path uses presigned URLs but no body HMAC).

Signed exports, signed HTTP verify responses, and externally anchored proofs are roadmap items.

What is NOT stored

NullRun never persists:

  • Prompt content or LLM response payloads. The gate receives only model, tool, tools, estimated_tokens, optional business_impact typed payload, and (Разрыв 2) the tool_params argument bag for BusinessImpact::ToolCall impacts.
  • Tool arguments beyond the typed BusinessImpact extraction. 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. billing_subscriptions carries only payment_method_brand and payment_method_last4.
  • OAuth refresh tokens — the IdP owns session lifetime.

src/pii.rs::EmailHash and proxy/redaction.rs redact at the log boundary and the trace-span boundary so plaintext email and raw prompts do not reach the structured log store. The SecretScrubberLayer + ScrubWriter (mounted at startup in main.rs) rewrite any uppercase KEY=VALUE pair on the line to KEY=[REDACTED] before bytes reach stdout.

Kill signal — special case

WorkflowKilledInterrupt is a BaseException, not an Exception. This is deliberate — kill signals must propagate even if your code catches everything:

try:
    my_agent(prompt)
except Exception:
    # Operator clicked Kill. Don't swallow this.
    pass
# WorkflowKilledInterrupt is NOT caught here.

If you want a clean shutdown on kill, catch WorkflowKilledInterrupt explicitly before any except Exception:

try:
    my_agent(prompt)
except WorkflowKilledInterrupt:
    persist_state()  # save checkpoint
    raise           # re-raise — kill must reach the top
except Exception:
    log.error("agent failed", exc_info=True)

@guarded follows this rule — it catches NullRunError (Exception subclasses) and lets BaseException (kill, pause, KeyboardInterrupt) propagate.

See also