Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,107 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)

---

## [0.7.6] - 2026-06-27

Additive patch on top of the 0.7.0 thin-client refactor. Brings a
FastAPI integration, a default user-facing message catalog, and
small transport consistency fixes. No breaking changes.

### Added

- **`nullrun.integrations.fastapi`** — one-line FastAPI integration
that turns every `NullRunDecision` / `NullRunInfrastructureError`
thrown by `@nullrun.protect` endpoints into a clean JSON
response with the right HTTP status code. No per-endpoint
`except` blocks required.
```python
from fastapi import FastAPI
import nullrun
from nullrun.integrations.fastapi import install

nullrun.init(api_key="nr_live_...")
app = FastAPI()
install(app)

@app.post("/chat")
@nullrun.protect
def chat(message: str) -> str:
return agent.run(message)
```
Response shape:
```json
{
"error_code": "NR-B004",
"user_message": "You've reached the usage limit...",
"category": "decision"
}
```
HTTP status mapping:
- `NR-B004` (budget), `NR-L001` (loop), `NR-R001` (rate) → **429**
with optional `Retry-After`.
- `NR-T001` (tool blocked), `NR-X001` (generic block) → **403**.
- `NR-W003` (paused) → **503** with `Retry-After`.
- `NR-W002` (killed) → **503**. `WorkflowKilledInterrupt` is a
`BaseException` subclass so Starlette's `add_exception_handler`
refuses it; the integration uses an ASGI middleware instead
(hybrid pattern documented in the module docstring).
- All `NullRunInfrastructureError` subclasses → **503**
(failure is on our side, not the user's).

- **`nullrun.messages`** — default user-facing message catalog.
Every `NR-*` error code has an English default message owned by
NULLRUN, not by customer code, so a Customer Support Bot hitting
a budget cap shows the same wording across every NullRun-backed
application.
- `format_user_message(exc)` — render an exception as a
user-facing string.
- `set_user_message(code, text)` — per-process override for
branded variants in a single deployment.
- `get_user_message(code)` — raw lookup.
- `reset_overrides()` — clear all overrides (for tests).

### Changed

- **`Transport._send_batch` canonical JSON serialization** —
route the `/track/batch` body through `_signed_request_body` for
consistent compact-separator serialisation (`,`/`:`). HMAC itself
is unaffected (it hashes the bytes either way), but consistent
serialisation removes a special-case from the wire-format contract
tests. Docstring invariant: "All three signed POST call sites
MUST serialise via this helper."

- **`Transport._send_batch` actions response handling** —
backend renamed `BatchTrackResponse.actions_taken` (debug names)
→ `BatchTrackResponse.actions` (`ActionTaken` structs with
human-readable strings moved to `messages`). Single `/track`
still uses `TrackResponse.actions_taken`. We read both for
forward-compat; per-element `try/except` so one malformed
entry doesn't abort the whole loop.

- **`pyproject.toml` metadata** — long-form description with
keyword coverage for search, `Maintainer:` populated via
`maintainers = [...]`, expanded classifiers
(`OS Independent` / Linux / Windows / macOS,
Python 3.13, `CPython`, `Security`, `AI`, `WWW/HTTP` topics),
project URL expander (Discussions / Releases / Source /
Security Policy).

### Tests

- `tests/test_messages.py` (new, 282 lines) — catalog completeness
(every NR-* code in `exceptions.py` has a default message),
override / reset behavior, render path.
- `tests/test_integrations_fastapi.py` (new, 289 lines) — HTTP
status mapping per error code, response shape, ASGI
middleware path for `WorkflowKilledInterrupt`, hybrid
(exception handlers + middleware) composition.
- `tests/test_decision_split.py` (new, 199 lines) — pins the
decision / infrastructure error split.
- Updates to `tests/test_runtime.py`, `tests/test_extractors.py`
reflecting transport canonical-JSON + actions-renamed changes.

---

## [0.7.0] - 2026-06-26

### BREAKING CHANGES
Expand Down
57 changes: 54 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ build-backend = "hatchling.build"

[project]
name = "nullrun"
version = "0.7.0"
description = "NullRun Python SDK — Enforcement gateway for AI agents."
version = "0.7.6"
# Long form used by PyPI page meta-description and search snippets.
# Kept under the 200-char preview threshold so the full line is visible
# without an "expand" click. Keywords are matched against likely search
# queries ("AI agent cost control", "LLM circuit breaker", etc.).
description = "NullRun Python SDK — enforcement gateway for AI agents. Circuit-breaker, policy enforcement and observability for OpenAI, Anthropic, LangGraph, LlamaIndex, CrewAI, AutoGen."
readme = "README.md"
license = { text = "Apache-2.0" }
requires-python = ">=3.10"
Expand All @@ -14,6 +18,15 @@ authors = [
{ name = "nullrun.io", email = "support@nullrun.io" }
]

# Maintainer populates the PKG-INFO `Maintainer:` / `Maintainer-email:`
# fields. PEP 621 maps the `authors` array to `Author-email:` but NOT
# to the legacy `Author:` field, which leaves `pip show` displaying an
# empty `Author:` line. Adding `maintainers` populates `Maintainer:`
# instead so every metadata viewer shows non-empty contact info.
maintainers = [
{ name = "Anatolii Maltsev", email = "support@nullrun.io" }
]

keywords = [
"circuit-breaker", "agent", "llm", "observability",
"ai-safety", "rate-limiting", "nullrun"
Expand All @@ -22,12 +35,25 @@ keywords = [
classifiers = [
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Intended Audience :: System Administrators",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Operating System :: POSIX :: Linux",
"Operating System :: Microsoft :: Windows",
"Operating System :: MacOS",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: Implementation :: CPython",
"Topic :: Software Development :: Libraries :: Python Modules",
# The SDK is positioned as a safety/cost-control layer for AI agents,
# so the Security and AI topics help PyPI search surface it for the
# queries developers actually run when shopping for these tools.
"Topic :: Security",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Internet :: WWW/HTTP",
"Typing :: Typed",
]

Expand Down Expand Up @@ -66,6 +92,15 @@ autogen = [
"autogen-agentchat>=0.4,<1.0",
"autogen-ext[openai]>=0.4,<1.0",
]
# Server-framework integrations. Each one pulls the framework so the
# corresponding ``nullrun.integrations.<framework>`` module can be
# imported. ``nullrun.integrations.__init__`` does NOT eager-import
# these (the submodules are loaded lazily on first ``from
# nullrun.integrations import <framework>``), so users who don't use
# a given framework don't pay its install cost.
fastapi = [
"fastapi>=0.100,<1.0",
]
all = [
"openai>=1.0,<2.0",
"anthropic>=0.20,<1.0",
Expand Down Expand Up @@ -98,14 +133,30 @@ dev = [
# the import succeed; the `langgraph` and `langchain` extras pull
# in heavier stacks that the unit tests don't need.
"langchain-core>=0.3,<1.0",
# `tests/test_integrations_fastapi.py` does `from fastapi import ...`
# at module top-level, so pytest collection aborts the entire suite
# with ModuleNotFoundError if FastAPI isn't installed. Same import-
# time contract as `langchain-core` above; pin to the same lower
# bound as the `[fastapi]` extra so CI and end users agree on the
# minimum. `httpx` (already a core dep) covers `TestClient`.
"fastapi>=0.100,<1.0",
]

[project.urls]
# The homepage, docs and repository are the three links PyPI surfaces on
# the project sidebar. The rest are convenience links that appear under
# the "Project links" expander — they help users jump straight to the
# issue tracker, release notes, or security policy without going through
# the GitHub repo root first.
Homepage = "https://nullrun.io"
Documentation = "https://docs.nullrun.io"
Repository = "https://github.com/nullrunio/nullrun-sdk-python"
Changelog = "https://github.com/nullrunio/nullrun-sdk-python/blob/master/CHANGELOG.md"
"Bug Tracker" = "https://github.com/nullrunio/nullrun-sdk-python/issues"
Discussions = "https://github.com/nullrunio/nullrun-sdk-python/discussions"
Changelog = "https://github.com/nullrunio/nullrun-sdk-python/blob/master/CHANGELOG.md"
Releases = "https://github.com/nullrunio/nullrun-sdk-python/releases"
"Source" = "https://github.com/nullrunio/nullrun-sdk-python"
"Security Policy" = "https://github.com/nullrunio/nullrun-sdk-python/security/policy"
Organization = "https://github.com/nullrunio"
Examples = "https://github.com/nullrunio/nullrun-examples"

Expand Down
14 changes: 14 additions & 0 deletions src/nullrun/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@ def my_agent():
"WorkflowPausedException": ("nullrun.breaker.exceptions", "WorkflowPausedException"),
"WorkflowKilledException": ("nullrun.breaker.exceptions", "WorkflowKilledException"),
"WorkflowKilledInterrupt": ("nullrun.breaker.exceptions", "WorkflowKilledInterrupt"),
# User-facing message catalog (NULLRUN owns the wording; see
# nullrun/messages.py for the design rationale). Eager in
# spirit — these are the "give the user a chance" surface that
# makes an SDK exception show up as a clean string instead of
# raw internal text.
"format_user_message": ("nullrun.messages", "format_user_message"),
"set_user_message": ("nullrun.messages", "set_user_message"),
"get_user_message": ("nullrun.messages", "get_user_message"),
}


Expand Down Expand Up @@ -456,6 +464,12 @@ def __dir__() -> list[str]:
"NullRunBudgetError",
"NullRunToolBlockedError",
"WorkflowKilledInterrupt",
# User-facing message catalog — the single entry point for
# turning an SDK exception into a string safe to display to
# end users. ``set_user_message`` lets a deployment brand its
# own wording per error_code without rewriting the SDK.
"format_user_message",
"set_user_message",
]

# Sprint 2.1: the SDK-side ``decision_history`` module was deleted.
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""NullRun Platform SDK."""

__version__ = "0.7.0"
__version__ = "0.7.6"
__platform_version__ = "1.0.0"
80 changes: 75 additions & 5 deletions src/nullrun/breaker/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,26 @@ class NullRunError(BreakerError):
subclass populates at least ``error_code``; ``user_action`` is
empty only when there is genuinely nothing to suggest (e.g. an
internal sanity check).

Two intermediate marker subclasses split the public hierarchy by
category so host code can ``except`` on the category without
enumerating individual codes:

* :class:`NullRunDecision` — expected policy outcomes (budget
cap, tool block, rate limit, loop detection, workflow pause).
The enforcement layer is doing its job; the UX is "what
happened" + (where applicable) "how to proceed".
* :class:`NullRunInfrastructureError` — system failures (network,
backend 5xx, auth rejection, config error). The SDK could not
reach or query the policy engine; the UX is a generic
"service unavailable" with operator triage info.

Both inherit from :class:`NullRunError`, so existing
``except NullRunError:`` clauses keep matching — the split is a
strict refinement, not a breaking change. ``WorkflowKilledInterrupt``
is **not** in either category: it remains a ``BaseException``
subclass so kill signals bypass any ``except Exception:`` that
might otherwise swallow them.
"""

#: Default error code when a subclass does not override it.
Expand Down Expand Up @@ -120,6 +140,56 @@ def __init__(
super().__init__(message)


# ---------------------------------------------------------------------------
# Category marker classes
# ---------------------------------------------------------------------------
# These two classes split the NullRunError hierarchy by what kind of
# event the exception represents. They are pure markers — no new fields,
# no constructor changes. Host code can use them as the catch-all for
# a category without enumerating individual codes:
#
# try:
# ...
# except NullRunDecision as d:
# # Budget, tool block, rate limit, loop, pause — expected
# return d.user_action_or_message()
# except NullRunInfrastructureError as e:
# # Network, 5xx, auth, config — system failure
# sentry.capture_exception(e)
# return "service unavailable"
#
# Both inherit from NullRunError so ``except NullRunError:`` keeps
# matching existing handlers — the split is additive.
class NullRunDecision(NullRunError):
"""Marker for expected policy outcomes.

Includes budget caps, tool blocks, rate limits, loop detection,
workflow pause, and the generic block fallback. These are NOT
system failures — the enforcement layer reached a deliberate
decision. UX should explain the decision and (where applicable)
offer an upgrade or alternative action.

End-user messaging for these exceptions is stable per ``error_code``
(see :mod:`nullrun.messages`) and rarely needs to mention the
decision mechanism.
"""


class NullRunInfrastructureError(NullRunError):
"""Marker for system failures (operator-facing).

Includes network errors reaching the policy engine, gateway 5xx,
authentication rejections, and configuration errors. End users see
a generic "service unavailable" message; operators see the
structured fields for triage (``error_code``, ``retryable``, and
for transport errors, ``source`` / ``endpoint``).

Host integrations (FastAPI middleware, Slack handler, etc.)
typically map these to HTTP 503 / 502 / 500 — NOT to 4xx, because
the failure is on our side, not the user's.
"""


# ---------------------------------------------------------------------------
# Transport / network failures
# ---------------------------------------------------------------------------
Expand All @@ -142,7 +212,7 @@ class TransportErrorSource(str, Enum):
AUTH_ERROR = "AUTH_ERROR" # 401 / 403 from the gateway


class NullRunTransportError(NullRunError):
class NullRunTransportError(NullRunInfrastructureError):
"""Raised by transport layer when the policy engine is unreachable.

The exception carries a `source` (TransportErrorSource) and the
Expand Down Expand Up @@ -337,7 +407,7 @@ class InsecureTransportError(BreakerTransportError):
# ---------------------------------------------------------------------------
# Configuration / authentication
# ---------------------------------------------------------------------------
class NullRunConfigError(NullRunError):
class NullRunConfigError(NullRunInfrastructureError):
"""Raised when the SDK is misconfigured: missing api_key, bad
key format, workflow not registered, etc.

Expand All @@ -354,7 +424,7 @@ class NullRunConfigError(NullRunError):
retryable = False


class NullRunAuthenticationError(NullRunError):
class NullRunAuthenticationError(NullRunInfrastructureError):
"""
Raised when authentication fails and safe mode is required.

Expand Down Expand Up @@ -402,7 +472,7 @@ class NullRunAuthError(NullRunAuthenticationError):
# ---------------------------------------------------------------------------
# Block decisions (budget, loop, rate, tool-block)
# ---------------------------------------------------------------------------
class NullRunBlockedException(NullRunError):
class NullRunBlockedException(NullRunDecision):
"""
Raised when NullRun circuit breaker trips.

Expand Down Expand Up @@ -525,7 +595,7 @@ class NullRunToolBlockedError(NullRunBlockedException):
# - RateLimitExceededException


class WorkflowPausedException(NullRunError):
class WorkflowPausedException(NullRunDecision):
"""
Raised when workflow is paused by NullRun.

Expand Down
Loading
Loading