diff --git a/pyproject.toml b/pyproject.toml index 1a0fac1..687ff26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/src/nullrun/__version__.py b/src/nullrun/__version__.py index 2b2954d..0068198 100644 --- a/src/nullrun/__version__.py +++ b/src/nullrun/__version__.py @@ -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" diff --git a/src/nullrun/runtime.py b/src/nullrun/runtime.py index e4b9abf..0886bd6 100644 --- a/src/nullrun/runtime.py +++ b/src/nullrun/runtime.py @@ -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, @@ -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) diff --git a/src/nullrun/transport.py b/src/nullrun/transport.py index 046aba5..4496f11 100644 --- a/src/nullrun/transport.py +++ b/src/nullrun/transport.py @@ -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 @@ -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( @@ -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( @@ -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( @@ -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(