chore(release): 0.16.3 — NR-006 + NR-007 closure - #94
Merged
Conversation
Closes NR-006 (audit 2026-08-24) and the SDK-side root cause of
the P0-2 / NR-018 fail-NO-CHECK defect class.
Pre-fix, `Transport.check` called `_client.post` directly
without going through `_retry_with_backoff`. A single transient
5xx (rolling deploy replica restart, gateway restart) caused the
SDK to short-circuit to a synthetic `decision: "block"` with
`decision_source: FALLBACK` — the agent caller never received a
real gate decision, violating CLAUDE.md §4 "fail-CLOSED ≠
fail-NO-CHECK". A malicious operator who could return 503 on
/gate would silently flip every agent to "budget blocked" even
though the budget was fine.
Two-part fix:
1. `_retry_with_backoff(..., retry_on_5xx: bool = False)` — new
parameter. When True, a 5xx response is converted to
`httpx.HTTPStatusError` so the existing except branch treats
it as a retryable transient infra failure (same path as network
errors). After retry exhaustion the LAST 5xx response is
returned (not raised) so the caller can synthesize a fallback
— `Transport.check` returns the legacy synthetic-block shape.
Default `retry_on_5xx=False` preserves the pre-existing
/track and /execute semantics: 5xx raises HTTPStatusError, the
helper retries up to its budget, and `Transport.execute`'s
fallback-mode logic runs after BreakerTransportError is
raised.
2. `Transport.check` — wraps the gate POST in
`_retry_with_backoff(..., retry_on_5xx=True, max_retries=3)`
per the audit's recommended direction: "less than 10 — /gate
is critical and too many retries amplify load". Three new
fallback branches translate `BreakerTransportError` (raised
by the helper after network-error retry exhaustion) into
either NullRunTransportError (`on_transport_error="raise"`
opt-in) or the legacy synthetic-block shape (default).
Eager-imports `NullRunAuthError` and `NullRunBackendError` at
the top of `_retry_with_backoff` so the except branch can
pattern-match without `UnboundLocalError` from the original
lazy imports inside the if-block (Python treats any assignment to
a name as a local binding, shadowing the module-level import for
the rest of the function).
3 new regression pins in tests/test_nr006_gate_retry_5xx.py:
- `test_check_retries_on_5xx_and_returns_real_decision` —
503 once, then 200 allow. Asserts real allow decision surfaces
after retry (was synthetic block pre-fix).
- `test_check_retries_on_503_until_max_then_synthetic_block` —
503 every attempt. Asserts retry budget is exhausted (2..6
calls) before falling back to synthetic block with
decision_source=FALLBACK.
- `test_check_4xx_is_not_retried` — 400 every attempt.
Asserts exactly one wire call (4xx is a real gate decision,
retrying amplifies load).
Test run on today's master (with the fix):
tests/test_nr006_gate_retry_5xx.py — 3 passed
tests/test_transport.py — 90 passed, 5 unrelated failures
(TestSensitiveToolsAPI requires langchain_core which is not
installed in this Python env; pre-existing dep issue).
Existing /track and /execute semantics preserved:
- test_check_network_error_with_raise_raises_classified PASSED
- test_check_network_error_without_raise_returns_block PASSED
- test_execute_fallback_cached_degrades_to_permissive PASSED
Verification of pre-fix failure (without the SDK change, the 3
new pins fail with the diagnostic the audit asks for):
AssertionError: NR-006: expected /gate to be retried after 503,
but only saw 1 call(s). The SDK short-circuited to synthetic
block on the first 5xx instead of going through
_retry_with_backoff.
Closes the parity gap flagged by NR-007 (audit 2026-08-24). The
backend `GateErrorCode::all()` enum had 41 variants; the SDK
`_V3_ERROR_CODE_MAP` only covered ~38 of them — unknown wire
codes fell through to generic `NullRunBackendError`, losing
diagnostic class. Cookbook recipes that branch on `error_code`
(e.g. "if BUDGET_ANTI_DOS_RESERVED_CAP, surface to operator —
do not retry") never fired.
Added entries (19 total) grouped at the end of the map with
a single comment block referencing NR-007 / the parity CI test:
BUDGET_ANTI_DOS_RESERVED_CAP -> NullRunBudgetError
BUDGET_REDIS_UNAVAILABLE -> NullRunBudgetError
CHAIN_ID_INVALID -> NullRunChainError
EXECUTION_KEY_MISMATCH -> NullRunAuthError
EXECUTION_ORG_MISMATCH -> NullRunAuthError
ORG_MISMATCH -> NullRunAuthError
PROTOCOL_HEADER_INVALID -> NullRunProtocolError
PROTOCOL_HEADER_REQUIRED -> NullRunProtocolError
TOOL_BLOCKED -> NullRunToolBlockedError (CLAUDE.md §8: dedicated class)
LOOP_DETECTED -> NullRunBlockedException
MODEL_REQUIRED -> NullRunBlockedException
POLICY_UNCONFIGURED -> NullRunBlockedException
TOO_MANY_PENDING_APPROVALS -> NullRunBlockedException
BUSINESS_IMPACT_INVALID -> NullRunBlockedException
VALIDATION_FAILED -> NullRunBlockedException
EXECUTION_ID_MALFORMED -> NullRunBackendError
EXECUTION_ID_REQUIRED -> NullRunBackendError
RATE_LIMIT_PLAN_LOOKUP_FAILED -> NullRunRateLimitRedisError
IDEMPOTENCY_REDIS_UNAVAILABLE -> NullRunBackendError
Family mapping rationale per code is in the inline comment block.
Side-effect: adds NullRunToolBlockedError to the import list
inside _build_v3_error_code_map (the dedicated class for
TOOL_BLOCKED that was already present in exceptions.py but not
imported here). Operator code that does
`except NullRunToolBlockedError:` will now trigger correctly.
Verification:
- `cargo test --test nr007_sdk_error_code_parity` PASSES
(was failing pre-fix with exactly these 19 missing keys).
- map size went from ~38 to 56 entries.
- SDK imports cleanly under PYTHONPATH=src — no ImportError.
Companion: backend commit `8dbeaf4d` added the parity CI test
that gates future drift between `GateErrorCode::all()` and
`_V3_ERROR_CODE_MAP`.
Release-prep commit on top of the two cherry-picks from release/0.16.2 (NR-006 + NR-007 already landed as 960a64f + 5e46ec1). What this commit adds: - pyproject.toml + src/nullrun/__version__.py: 0.16.2 → 0.16.3. - CHANGELOG.md: insert [0.16.3] - 2026-08-26 at the top with full descriptions of both fixes (NR-006 retry behavior + NR-007 parity table for the 19 new entries). - src/nullrun/transport.py: ruff --fix I001 reorder of the in-function exception imports block at line 2610 (alphabetical; no behavior change). - tests/test_nr006_gate_retry_5xx.py: ruff --fix F541 drops `f` prefix from continuation lines of a multi-line assertion message that has no placeholders (no behavior change — the string was always literal). Verified: pytest 1601 passed / 7 skipped (3 more than 0.16.2, accounting for the new NR-006 regression pins); ruff clean; mypy clean on src/nullrun (37 files). Out of scope: tests/conftest.py, tests/test_e2e_observation.py, tests/test_real_e2e_observation.py were already dirty on disk before this release branch was cut (HMAC secret_key mock setup, /auth/verify URL prefix tightening, cost_cents strip annotation). They are unrelated to NR-006 / NR-007 and were intentionally NOT included in this commit — they belong in their own focused PR so the 0.16.3 release notes stay scoped to the audit-driven fixes.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Both files were 100% skipped at every CI run — they consumed
collection time, added zero coverage, and polluted the test
directory with stale documentation.
- tests/test_e2e_observation.py (160 lines): skipped via
pytest.mark.skipif(not NULLRUN_E2E_BASE_URL and NULLRUN_E2E_API_KEY).
No CI environment sets these env vars (the respx-based unit
tests in test_runtime.py / test_ws_push.py are the in-CI
substitute per the module's own docstring).
- tests/test_real_e2e_observation.py (325 lines): sole test was
permanently skipped via @pytest.mark.skip("Re-enable when the
test is restructured to set up the mock server before
nullrun.init()"). The skip was added when the test broke against
0.4.0 and never lifted. The module docstring claimed "always runs
in CI; no env vars required" but the @pytest.mark.skip override
prevented that — the docstring was aspirational.
The conftest.py changes (secret_key in mock_api + make_runtime
defaults) are kept — they improve HMAC signing for any test using
those fixtures, independent of the e2e files.
Verification:
pytest -q: 1601 passed, 4 skipped (was 7 — 3 fewer skips from
the deleted files), 0 failed.
ruff check: all checks passed.
mypy src/nullrun: no issues found in 37 source files.
Coverage loss acknowledged: no respx/unit alternative exists for
the surface that test_real_e2e_observation.py was meant to cover
(auto-instrumented httpx → real-socket transport). If a future
release needs that surface covered, the test must be rewritten
from scratch with mock-server setup BEFORE nullrun.init(), not
after.
CHANGELOG.md 0.16.3 section updated with the deletion rationale.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
release/0.16.3 — NR-006 + NR-007 closure + dead-test removal
Patch release. No wire-format change. Reliability + SDK/backend parity hardening on top of 0.16.2, plus cleanup of two permanently-skipped test files.
What's in
Four commits on top of
origin/master(release branch cut frommasterper the release policy, not fromrelease/0.16.2):The two feature commits (NR-006 + NR-007) are cherry-picked from the unpushed tip of
release/0.16.2(where they were authored on 2026-08-24 but never made it into a release because 0.16.2 had already been tagged and merged via PR #93). They apply cleanly with no conflicts on top of 0.16.2 master.Fixes
NR-006 —
Transport.checknow retries transient 5xx instead of failing to a synthetic blockPre-fix,
_client.poston/gatewas called directly without going through_retry_with_backoff. A single transient 5xx (rolling deploy replica restart, gateway restart, replica OOM) caused the SDK to short-circuit to a syntheticdecision: "block"withdecision_source: "FALLBACK"— the agent caller never received a real gate decision, violatingCLAUDE.md §4("fail-CLOSED ≠ fail-NO-CHECK"). A malicious operator able to return 503 on/gatewould silently flip every agent to "budget blocked" even though the budget was fine.Two-part fix:
_retry_with_backoff(..., retry_on_5xx: bool = False)— new parameter. WhenTrue, a 5xx response is converted tohttpx.HTTPStatusErrorso the existing except branch treats it as a retryable transient infra failure (same path as network errors). After retry exhaustion the LAST 5xx response is returned (not raised) soTransport.checkcan synthesize the legacy fallback shape. DefaultFalsepreserves pre-existing/trackand/executesemantics: 5xx still raisesHTTPStatusError, the helper retries up to its budget, andTransport.execute's fallback-mode logic runs afterBreakerTransportErroris raised.Transport.check— wraps the gate POST in_retry_with_backoff(..., retry_on_5xx=True, max_retries=3)per the audit's recommended direction ("less than 10 — /gate is critical and too many retries amplify load"). Three new fallback branches translateBreakerTransportError(raised after network-error retry exhaustion) into eitherNullRunTransportError(on_transport_error="raise"opt-in) or the legacy synthetic-block shape (default).NullRunAuthErrorandNullRunBackendErrorat the top of_retry_with_backoffso the except branch can pattern-match withoutUnboundLocalErrorfrom the original lazy imports inside the if-block.NR-007 — closes the SDK-side parity gap in
_V3_ERROR_CODE_MAPBackend
GateErrorCode::all()had 41 variants; SDK_V3_ERROR_CODE_MAPonly covered ~38 — unknown wire codes fell through to genericNullRunBackendError, losing diagnostic class. Added 19 entries:BUDGET_ANTI_DOS_RESERVED_CAPNullRunBudgetErrorBUDGET_REDIS_UNAVAILABLENullRunBudgetErrorCHAIN_ID_INVALIDNullRunChainErrorEXECUTION_KEY_MISMATCHNullRunAuthErrorEXECUTION_ORG_MISMATCHNullRunAuthErrorORG_MISMATCHNullRunAuthErrorPROTOCOL_HEADER_INVALIDNullRunProtocolErrorPROTOCOL_HEADER_REQUIREDNullRunProtocolErrorTOOL_BLOCKEDNullRunToolBlockedError(CLAUDE.md §8: dedicated class)LOOP_DETECTEDNullRunBlockedExceptionMODEL_REQUIREDNullRunBlockedExceptionPOLICY_UNCONFIGUREDNullRunBlockedExceptionTOO_MANY_PENDING_APPROVALSNullRunBlockedExceptionBUSINESS_IMPACT_INVALIDNullRunBlockedExceptionVALIDATION_FAILEDNullRunBlockedExceptionEXECUTION_ID_MALFORMEDNullRunBackendErrorEXECUTION_ID_REQUIREDNullRunBackendErrorRATE_LIMIT_PLAN_LOOKUP_FAILEDNullRunRateLimitRedisErrorIDEMPOTENCY_REDIS_UNAVAILABLENullRunBackendErrorSide-effect:
NullRunToolBlockedErrornow imported by_build_v3_error_code_map— operatorexcept NullRunToolBlockedError:triggers correctly. Map size: ~38 → 56.Companion backend commit
8dbeaf4daddscargo test --test nr007_sdk_error_code_paritygating future drift.Cleanup — deleted two permanently-skipped test files
Both files were 100% skipped at every CI run — they consumed collection time and added zero coverage:
tests/test_e2e_observation.py(160 lines) —pytest.mark.skipif(not NULLRUN_E2E_BASE_URL and NULLRUN_E2E_API_KEY). No CI environment sets these vars (the respx-based unit tests intest_runtime.py/test_ws_push.pyare the in-CI substitute per the module's own docstring).tests/test_real_e2e_observation.py(325 lines) — sole test was@pytest.mark.skip("Re-enable when the test is restructured to set up the mock server before nullrun.init()"). Skip was added when the test broke against 0.4.0 and never lifted. Module docstring claimed "always runs in CI; no env vars required" but the@pytest.mark.skipoverride prevented that — the docstring was aspirational.Coverage loss acknowledged: no respx/unit alternative exists for the surface
test_real_e2e_observation.pywas meant to cover (auto-instrumented httpx → real-socket transport). If a future release needs that surface covered, the test must be rewritten from scratch with mock-server setup BEFOREnullrun.init(), not after.Fixture improvement (kept)
tests/conftest.py::mock_apiandtests/conftest.py::make_runtimewere already pairingsecret_key="test-secret-deterministic"into the mock auth/verify response and runtime defaults in a dirty-on-disk change pre-dating this release. That change is unrelated to the deletions — it makes_build_signed_headers(transport.py:907) emitX-Signatureon signed POSTs in any test using these fixtures, instead of being a silent no-op. Kept as-is.Verification
Skipped count dropped from 7 → 4 (3 fewer skips from the deleted files). Pass count unchanged (1601 → 1601) — the deleted tests were 100% skipped, not silently passing.
NR-006 regression pins (all pass):
tests/test_nr006_gate_retry_5xx.py::test_check_retries_on_5xx_and_returns_real_decisiontests/test_nr006_gate_retry_5xx.py::test_check_retries_on_503_until_max_then_synthetic_blocktests/test_nr006_gate_retry_5xx.py::test_check_4xx_is_not_retriedExisting
/trackand/executesemantics preserved:test_check_network_error_with_raise_raises_classified,test_check_network_error_without_raise_returns_block,test_execute_fallback_cached_degrades_to_permissiveall pass.Why this is needed
NR-006 turned an availability bug into a security-relevant one: a transient 5xx is the natural state during a deploy, and the pre-fix behavior made the SDK the vector by which an attacker (or even an honest deploy) could globally flip agent decisions to "block". NR-007 was a slow leak of diagnostic class: every wire code without a SDK mapping lost its type-specific handling, silently degrading cookbook branches and operator workflows. Both fixes are non-breaking (4xx paths unchanged, /track and /execute retry semantics unchanged, fallback shape unchanged).
Roll-out
Tag once green.
hatch_build.pyreads onlypyproject.toml [project].version, so the wheel METADATA will carry0.16.3automatically. CI uploads viapypa/gh-action-pypi-publishTrusted Publishing to TestPyPI on tag, then Production on thereleaseevent (see.github/workflows/publish.yml).