Skip to content

chore(release): 0.16.3 — NR-006 + NR-007 closure - #94

Merged
maltsev-dev merged 4 commits into
masterfrom
release/0.16.3
Aug 26, 2026
Merged

chore(release): 0.16.3 — NR-006 + NR-007 closure#94
maltsev-dev merged 4 commits into
masterfrom
release/0.16.3

Conversation

@maltsev-dev

@maltsev-dev maltsev-dev commented Aug 26, 2026

Copy link
Copy Markdown
Member

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 from master per the release policy, not from release/0.16.2):

fade638 chore(tests): delete permanently-skipped e2e observation tests
c68ebae chore(release): 0.16.3 — NR-006 /gate retry + NR-007 error code parity
5e46ec1 fix(sdk): NR-007 add 19 missing entries to _V3_ERROR_CODE_MAP
960a64f fix(sdk): NR-006 /gate retries transient 5xx instead of fail-NO-CHECK

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.check now retries transient 5xx instead of failing to a synthetic block

Pre-fix, _client.post on /gate was 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 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 able to return 503 on /gate would 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. 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 Transport.check can synthesize the legacy fallback shape. Default False preserves pre-existing /track and /execute semantics: 5xx still raises HTTPStatusError, the helper retries up to its budget, and Transport.execute's fallback-mode logic runs after BreakerTransportError is 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 translate BreakerTransportError (raised 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.

NR-007 — closes the SDK-side parity gap in _V3_ERROR_CODE_MAP

Backend GateErrorCode::all() had 41 variants; SDK _V3_ERROR_CODE_MAP only covered ~38 — unknown wire codes fell through to generic NullRunBackendError, losing diagnostic class. Added 19 entries:

wire code SDK exception class
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

Side-effect: NullRunToolBlockedError now imported by _build_v3_error_code_map — operator except NullRunToolBlockedError: triggers correctly. Map size: ~38 → 56.

Companion backend commit 8dbeaf4d adds cargo test --test nr007_sdk_error_code_parity gating 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 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 @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.skip override prevented that — the docstring was aspirational.

Coverage loss acknowledged: no respx/unit alternative exists for the surface 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.

Fixture improvement (kept)

tests/conftest.py::mock_api and tests/conftest.py::make_runtime were already pairing secret_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) emit X-Signature on signed POSTs in any test using these fixtures, instead of being a silent no-op. Kept as-is.

Verification

pytest       1601 passed, 4 skipped, 0 failed   (101.13s)
ruff check   All checks passed!
mypy         Success: no issues found in 37 source files

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_decision
  • tests/test_nr006_gate_retry_5xx.py::test_check_retries_on_503_until_max_then_synthetic_block
  • tests/test_nr006_gate_retry_5xx.py::test_check_4xx_is_not_retried

Existing /track and /execute semantics preserved: test_check_network_error_with_raise_raises_classified, test_check_network_error_without_raise_returns_block, test_execute_fallback_cached_degrades_to_permissive all 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.py reads only pyproject.toml [project].version, so the wheel METADATA will carry 0.16.3 automatically. CI uploads via pypa/gh-action-pypi-publish Trusted Publishing to TestPyPI on tag, then Production on the release event (see .github/workflows/publish.yml).

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

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.33333% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/transport.py 82.60% 2 Missing and 2 partials ⚠️

📢 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.
@maltsev-dev
maltsev-dev merged commit f6d15f1 into master Aug 26, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant