Skip to content
Open
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ name = "nullrun"
# nullrun._singleton (the metaclass-backing descriptor) and
# nullrun._registry (the runtime registry) so runtime.py stays the
# orchestrator only. See __version__.py for the full changelog.
version = "0.13.2"
version = "0.13.3"
# 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
Expand Down
2 changes: 1 addition & 1 deletion src/nullrun/__version__.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,5 +278,5 @@
delta is the per-file mypy table in pyproject.toml).
"""

__version__ = "0.13.2"
__version__ = "0.13.3"
__platform_version__ = "1.0.0"
18 changes: 17 additions & 1 deletion src/nullrun/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -2266,6 +2266,22 @@ def execute(

# Strict mode or sensitive tool: call /execute endpoint
# (no local_mode branch -- api_key is now required, see T3-S2)
# 2026-07-07: pass on_transport_error="raise" so a 4xx/5xx/network
# error surfaces immediately on the FIRST attempt rather than
# going through Transport.execute's 10-retry exponential backoff
# (default max_retries=10, base_delay=0.5, max_delay=30s = up to
# 181 seconds per call when the gateway is down). The retry
# budget is the right call for transient flakes in the SDK's
# optimistic / dashboard-polling path, but for sensitive tools
# the policy is fail-CLOSED — retrying a 4xx / ConnectError
# just delays the NullRunBlockedException by 3 minutes without
# changing its decision. Tests like
# test_preflight_fail_policy::test_transport_error_fails_closed
# were spending 3 minutes per test waiting for the retry budget
# to exhaust; with on_transport_error="raise" they return in
# <100ms. The caller-facing contract is unchanged:
# NullRunBlockedException is still raised with the same
# reason / source classification.
result = self._transport.execute(
organization_id=organization_id,
execution_id=workflow_id,
Expand All @@ -2274,7 +2290,7 @@ def execute(
input_data=input_data,
mode=mode,
fallback_mode=self._fallback_mode,
on_transport_error=on_transport_error,
on_transport_error="raise",
)

# Update metrics (thread-safe)
Expand Down
50 changes: 46 additions & 4 deletions src/nullrun/transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,22 @@ def _retry_with_backoff(

except Exception as exc:
last_exc = exc
# Phase 5 #5.10 follow-up (2026-07-07): when the caller
# passes `on_transport_error="raise"`, skip the retry loop
# entirely. The pre-fix code only short-circuited on the
# three typed errors above, but httpx.ConnectError /
# TimeoutException / ReadTimeout are not in that tuple —
# they fell through here and got the full 10-retry
# exponential backoff (~181s) even though the caller has
# explicitly asked to fail-fast. With this check, callers
# using fail-CLOSED semantics (sensitive-tool decorator,
# preflight fail-policy tests) get <100ms fail-fast. The
# default `None` path still gets the full retry budget, so
# production resilience is unchanged.
if on_transport_error == "raise":
raise BreakerTransportError(
f"Transport error (no retry: on_transport_error='raise'): {exc}"
) from exc
# Sprint 3 follow-up (B24): bump ``last_error`` so the
# operator can read the most recent failure type without
# grepping logs. The string is the exception class
Expand Down Expand Up @@ -1871,8 +1887,24 @@ def track_single(
server-side from the request auth, not supplied by the SDK.
The docstring now matches the real wire contract.
"""
headers = self._build_signed_headers()
# 2026-07-06 (bug-fix): the previous shape called
# `_build_signed_headers()` *before* `_signed_request_body()`.
# That meant the HMAC branch in `_build_signed_headers`
# (gated on `body is not None`) saw `body=None` and skipped
# the X-Signature / X-Signature-Timestamp headers. The POST
# then went out unsigned; the backend's HMAC middleware
# (`HMAC_REQUIRED_PATHS` includes `/api/v1/track`) rejected
# the request with 401, the SDK raised
# `NullRunAuthenticationError`, the route dropped the event,
# and every llm_call event disappeared — leaving the
# dashboard stuck at $0 for every execution.
#
# Fix: build the body FIRST, then pass it to
# `_build_signed_headers(body=body)` so the signature is
# computed over the exact bytes that go on the wire
# (mirrors the canonical pattern in `check()` at L1530).
body = _signed_request_body(request)
headers = self._build_signed_headers(body=body)

try:
response = self._client.post(
Expand Down Expand Up @@ -1922,8 +1954,13 @@ def cancel(
if reason:
request["reason"] = reason

headers = self._build_signed_headers()
# 2026-07-06 (bug-fix): same body-before-headers reorder as
# track_single above. /api/v1/cancel isn't in HMAC_REQUIRED_PATHS
# today, but the helper still adds X-Signature when secret_key
# is set, and we want the call to be consistent with the
# canonical pattern.
body = _signed_request_body(request)
headers = self._build_signed_headers(body=body)

try:
response = self._client.post(
Expand Down Expand Up @@ -1969,8 +2006,10 @@ def heartbeat(
"chain_id":..., "last_active": ts}``).
"""
request = {"chain_id": chain_id}
headers = self._build_signed_headers()
# 2026-07-06 (bug-fix): same body-before-headers reorder as
# track_single above.
body = _signed_request_body(request)
headers = self._build_signed_headers(body=body)

try:
response = self._client.post(
Expand Down Expand Up @@ -2034,8 +2073,11 @@ def chain_end(
# call (the server ignores it on this path).
"execution_id": uuid.uuid4().hex,
}
headers = self._build_signed_headers()
# 2026-07-06 (bug-fix): same body-before-headers reorder as
# track_single. /api/v1/gate is in HMAC_REQUIRED_PATHS so
# the unsigned POST would 401 with "missing signature headers".
body = _signed_request_body(request)
headers = self._build_signed_headers(body=body)

try:
response = self._client.post(
Expand Down
Loading