Skip to content

Commit ee270ba

Browse files
committed
release: 0.7.6 — FastAPI integration + user-facing message catalog
Additive patch on top of the 0.7.0 thin-client refactor. 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. Response shape: {"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 — handled via ASGI middleware instead (hybrid pattern, documented in module docstring). * NullRunInfrastructureError subclasses -> 503 (our side, not user's). * nullrun.messages — default user-facing message catalog. Every NR-* error code has an English default message owned by NULLRUN, not customer code. Customer Support Bots hitting a budget cap show the same wording across every NullRun-backed application. * format_user_message(exc) — render exception as user-facing string * set_user_message(code, text) — per-process override for branded variants * 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 serialization. HMAC itself is unaffected, but consistent serialization removes a special-case from the wire-format contract tests. * Transport._send_batch actions response handling — backend renamed BatchTrackResponse.actions_taken (debug names) -> BatchTrackResponse.actions (ActionTaken structs). 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 search keywords, Maintainer: populated via maintainers=[...], expanded classifiers (Linux / Windows / macOS, Python 3.13, CPython, Security / AI / WWW/HTTP topics), project URL expander. Tests ----- * tests/test_messages.py (new, 282 lines) — catalog completeness (every NR-* code 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 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. Release plumbing ---------------- * pyproject.toml: version bumped 0.7.0 -> 0.7.6 * src/nullrun/__version__.py: __version__ = "0.7.6" * CHANGELOG.md: full 0.7.6 entry covering additions, transport changes, metadata improvements Tests pass locally (per session log) — pytest on Windows / Python 3.14.2 is green.
1 parent 199b686 commit ee270ba

17 files changed

Lines changed: 2370 additions & 25 deletions

CHANGELOG.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,107 @@ Versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html)
77

88
---
99

10+
## [0.7.6] - 2026-06-27
11+
12+
Additive patch on top of the 0.7.0 thin-client refactor. Brings a
13+
FastAPI integration, a default user-facing message catalog, and
14+
small transport consistency fixes. No breaking changes.
15+
16+
### Added
17+
18+
- **`nullrun.integrations.fastapi`** — one-line FastAPI integration
19+
that turns every `NullRunDecision` / `NullRunInfrastructureError`
20+
thrown by `@nullrun.protect` endpoints into a clean JSON
21+
response with the right HTTP status code. No per-endpoint
22+
`except` blocks required.
23+
```python
24+
from fastapi import FastAPI
25+
import nullrun
26+
from nullrun.integrations.fastapi import install
27+
28+
nullrun.init(api_key="nr_live_...")
29+
app = FastAPI()
30+
install(app)
31+
32+
@app.post("/chat")
33+
@nullrun.protect
34+
def chat(message: str) -> str:
35+
return agent.run(message)
36+
```
37+
Response shape:
38+
```json
39+
{
40+
"error_code": "NR-B004",
41+
"user_message": "You've reached the usage limit...",
42+
"category": "decision"
43+
}
44+
```
45+
HTTP status mapping:
46+
- `NR-B004` (budget), `NR-L001` (loop), `NR-R001` (rate) → **429**
47+
with optional `Retry-After`.
48+
- `NR-T001` (tool blocked), `NR-X001` (generic block) → **403**.
49+
- `NR-W003` (paused) → **503** with `Retry-After`.
50+
- `NR-W002` (killed) → **503**. `WorkflowKilledInterrupt` is a
51+
`BaseException` subclass so Starlette's `add_exception_handler`
52+
refuses it; the integration uses an ASGI middleware instead
53+
(hybrid pattern documented in the module docstring).
54+
- All `NullRunInfrastructureError` subclasses → **503**
55+
(failure is on our side, not the user's).
56+
57+
- **`nullrun.messages`** — default user-facing message catalog.
58+
Every `NR-*` error code has an English default message owned by
59+
NULLRUN, not by customer code, so a Customer Support Bot hitting
60+
a budget cap shows the same wording across every NullRun-backed
61+
application.
62+
- `format_user_message(exc)` — render an exception as a
63+
user-facing string.
64+
- `set_user_message(code, text)` — per-process override for
65+
branded variants in a single deployment.
66+
- `get_user_message(code)` — raw lookup.
67+
- `reset_overrides()` — clear all overrides (for tests).
68+
69+
### Changed
70+
71+
- **`Transport._send_batch` canonical JSON serialization**
72+
route the `/track/batch` body through `_signed_request_body` for
73+
consistent compact-separator serialisation (`,`/`:`). HMAC itself
74+
is unaffected (it hashes the bytes either way), but consistent
75+
serialisation removes a special-case from the wire-format contract
76+
tests. Docstring invariant: "All three signed POST call sites
77+
MUST serialise via this helper."
78+
79+
- **`Transport._send_batch` actions response handling**
80+
backend renamed `BatchTrackResponse.actions_taken` (debug names)
81+
`BatchTrackResponse.actions` (`ActionTaken` structs with
82+
human-readable strings moved to `messages`). Single `/track`
83+
still uses `TrackResponse.actions_taken`. We read both for
84+
forward-compat; per-element `try/except` so one malformed
85+
entry doesn't abort the whole loop.
86+
87+
- **`pyproject.toml` metadata** — long-form description with
88+
keyword coverage for search, `Maintainer:` populated via
89+
`maintainers = [...]`, expanded classifiers
90+
(`OS Independent` / Linux / Windows / macOS,
91+
Python 3.13, `CPython`, `Security`, `AI`, `WWW/HTTP` topics),
92+
project URL expander (Discussions / Releases / Source /
93+
Security Policy).
94+
95+
### Tests
96+
97+
- `tests/test_messages.py` (new, 282 lines) — catalog completeness
98+
(every NR-* code in `exceptions.py` has a default message),
99+
override / reset behavior, render path.
100+
- `tests/test_integrations_fastapi.py` (new, 289 lines) — HTTP
101+
status mapping per error code, response shape, ASGI
102+
middleware path for `WorkflowKilledInterrupt`, hybrid
103+
(exception handlers + middleware) composition.
104+
- `tests/test_decision_split.py` (new, 199 lines) — pins the
105+
decision / infrastructure error split.
106+
- Updates to `tests/test_runtime.py`, `tests/test_extractors.py`
107+
reflecting transport canonical-JSON + actions-renamed changes.
108+
109+
---
110+
10111
## [0.7.0] - 2026-06-26
11112

12113
### BREAKING CHANGES

pyproject.toml

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ build-backend = "hatchling.build"
44

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

21+
# Maintainer populates the PKG-INFO `Maintainer:` / `Maintainer-email:`
22+
# fields. PEP 621 maps the `authors` array to `Author-email:` but NOT
23+
# to the legacy `Author:` field, which leaves `pip show` displaying an
24+
# empty `Author:` line. Adding `maintainers` populates `Maintainer:`
25+
# instead so every metadata viewer shows non-empty contact info.
26+
maintainers = [
27+
{ name = "Anatolii Maltsev", email = "support@nullrun.io" }
28+
]
29+
1730
keywords = [
1831
"circuit-breaker", "agent", "llm", "observability",
1932
"ai-safety", "rate-limiting", "nullrun"
@@ -22,12 +35,25 @@ keywords = [
2235
classifiers = [
2336
"Development Status :: 3 - Alpha",
2437
"Intended Audience :: Developers",
38+
"Intended Audience :: System Administrators",
2539
"License :: OSI Approved :: Apache Software License",
40+
"Operating System :: OS Independent",
41+
"Operating System :: POSIX :: Linux",
42+
"Operating System :: Microsoft :: Windows",
43+
"Operating System :: MacOS",
2644
"Programming Language :: Python :: 3",
2745
"Programming Language :: Python :: 3.10",
2846
"Programming Language :: Python :: 3.11",
2947
"Programming Language :: Python :: 3.12",
48+
"Programming Language :: Python :: 3.13",
49+
"Programming Language :: Python :: Implementation :: CPython",
3050
"Topic :: Software Development :: Libraries :: Python Modules",
51+
# The SDK is positioned as a safety/cost-control layer for AI agents,
52+
# so the Security and AI topics help PyPI search surface it for the
53+
# queries developers actually run when shopping for these tools.
54+
"Topic :: Security",
55+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
56+
"Topic :: Internet :: WWW/HTTP",
3157
"Typing :: Typed",
3258
]
3359

@@ -101,11 +127,20 @@ dev = [
101127
]
102128

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

src/nullrun/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,14 @@ def my_agent():
390390
"WorkflowPausedException": ("nullrun.breaker.exceptions", "WorkflowPausedException"),
391391
"WorkflowKilledException": ("nullrun.breaker.exceptions", "WorkflowKilledException"),
392392
"WorkflowKilledInterrupt": ("nullrun.breaker.exceptions", "WorkflowKilledInterrupt"),
393+
# User-facing message catalog (NULLRUN owns the wording; see
394+
# nullrun/messages.py for the design rationale). Eager in
395+
# spirit — these are the "give the user a chance" surface that
396+
# makes an SDK exception show up as a clean string instead of
397+
# raw internal text.
398+
"format_user_message": ("nullrun.messages", "format_user_message"),
399+
"set_user_message": ("nullrun.messages", "set_user_message"),
400+
"get_user_message": ("nullrun.messages", "get_user_message"),
393401
}
394402

395403

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

461475
# Sprint 2.1: the SDK-side ``decision_history`` module was deleted.

src/nullrun/__version__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
"""NullRun Platform SDK."""
22

3-
__version__ = "0.7.0"
3+
__version__ = "0.7.6"
44
__platform_version__ = "1.0.0"

src/nullrun/breaker/exceptions.py

Lines changed: 75 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,26 @@ class NullRunError(BreakerError):
5757
subclass populates at least ``error_code``; ``user_action`` is
5858
empty only when there is genuinely nothing to suggest (e.g. an
5959
internal sanity check).
60+
61+
Two intermediate marker subclasses split the public hierarchy by
62+
category so host code can ``except`` on the category without
63+
enumerating individual codes:
64+
65+
* :class:`NullRunDecision` — expected policy outcomes (budget
66+
cap, tool block, rate limit, loop detection, workflow pause).
67+
The enforcement layer is doing its job; the UX is "what
68+
happened" + (where applicable) "how to proceed".
69+
* :class:`NullRunInfrastructureError` — system failures (network,
70+
backend 5xx, auth rejection, config error). The SDK could not
71+
reach or query the policy engine; the UX is a generic
72+
"service unavailable" with operator triage info.
73+
74+
Both inherit from :class:`NullRunError`, so existing
75+
``except NullRunError:`` clauses keep matching — the split is a
76+
strict refinement, not a breaking change. ``WorkflowKilledInterrupt``
77+
is **not** in either category: it remains a ``BaseException``
78+
subclass so kill signals bypass any ``except Exception:`` that
79+
might otherwise swallow them.
6080
"""
6181

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

122142

143+
# ---------------------------------------------------------------------------
144+
# Category marker classes
145+
# ---------------------------------------------------------------------------
146+
# These two classes split the NullRunError hierarchy by what kind of
147+
# event the exception represents. They are pure markers — no new fields,
148+
# no constructor changes. Host code can use them as the catch-all for
149+
# a category without enumerating individual codes:
150+
#
151+
# try:
152+
# ...
153+
# except NullRunDecision as d:
154+
# # Budget, tool block, rate limit, loop, pause — expected
155+
# return d.user_action_or_message()
156+
# except NullRunInfrastructureError as e:
157+
# # Network, 5xx, auth, config — system failure
158+
# sentry.capture_exception(e)
159+
# return "service unavailable"
160+
#
161+
# Both inherit from NullRunError so ``except NullRunError:`` keeps
162+
# matching existing handlers — the split is additive.
163+
class NullRunDecision(NullRunError):
164+
"""Marker for expected policy outcomes.
165+
166+
Includes budget caps, tool blocks, rate limits, loop detection,
167+
workflow pause, and the generic block fallback. These are NOT
168+
system failures — the enforcement layer reached a deliberate
169+
decision. UX should explain the decision and (where applicable)
170+
offer an upgrade or alternative action.
171+
172+
End-user messaging for these exceptions is stable per ``error_code``
173+
(see :mod:`nullrun.messages`) and rarely needs to mention the
174+
decision mechanism.
175+
"""
176+
177+
178+
class NullRunInfrastructureError(NullRunError):
179+
"""Marker for system failures (operator-facing).
180+
181+
Includes network errors reaching the policy engine, gateway 5xx,
182+
authentication rejections, and configuration errors. End users see
183+
a generic "service unavailable" message; operators see the
184+
structured fields for triage (``error_code``, ``retryable``, and
185+
for transport errors, ``source`` / ``endpoint``).
186+
187+
Host integrations (FastAPI middleware, Slack handler, etc.)
188+
typically map these to HTTP 503 / 502 / 500 — NOT to 4xx, because
189+
the failure is on our side, not the user's.
190+
"""
191+
192+
123193
# ---------------------------------------------------------------------------
124194
# Transport / network failures
125195
# ---------------------------------------------------------------------------
@@ -142,7 +212,7 @@ class TransportErrorSource(str, Enum):
142212
AUTH_ERROR = "AUTH_ERROR" # 401 / 403 from the gateway
143213

144214

145-
class NullRunTransportError(NullRunError):
215+
class NullRunTransportError(NullRunInfrastructureError):
146216
"""Raised by transport layer when the policy engine is unreachable.
147217
148218
The exception carries a `source` (TransportErrorSource) and the
@@ -337,7 +407,7 @@ class InsecureTransportError(BreakerTransportError):
337407
# ---------------------------------------------------------------------------
338408
# Configuration / authentication
339409
# ---------------------------------------------------------------------------
340-
class NullRunConfigError(NullRunError):
410+
class NullRunConfigError(NullRunInfrastructureError):
341411
"""Raised when the SDK is misconfigured: missing api_key, bad
342412
key format, workflow not registered, etc.
343413
@@ -354,7 +424,7 @@ class NullRunConfigError(NullRunError):
354424
retryable = False
355425

356426

357-
class NullRunAuthenticationError(NullRunError):
427+
class NullRunAuthenticationError(NullRunInfrastructureError):
358428
"""
359429
Raised when authentication fails and safe mode is required.
360430
@@ -402,7 +472,7 @@ class NullRunAuthError(NullRunAuthenticationError):
402472
# ---------------------------------------------------------------------------
403473
# Block decisions (budget, loop, rate, tool-block)
404474
# ---------------------------------------------------------------------------
405-
class NullRunBlockedException(NullRunError):
475+
class NullRunBlockedException(NullRunDecision):
406476
"""
407477
Raised when NullRun circuit breaker trips.
408478
@@ -525,7 +595,7 @@ class NullRunToolBlockedError(NullRunBlockedException):
525595
# - RateLimitExceededException
526596

527597

528-
class WorkflowPausedException(NullRunError):
598+
class WorkflowPausedException(NullRunDecision):
529599
"""
530600
Raised when workflow is paused by NullRun.
531601

0 commit comments

Comments
 (0)