Skip to content

fix(sdk): read approval_timeout_seconds from /gate response (Разрыв 1c SDK sync)#74

Merged
maltsev-dev merged 1 commit into
masterfrom
fix/gap-1c-approval-timeout
Jul 21, 2026
Merged

fix(sdk): read approval_timeout_seconds from /gate response (Разрыв 1c SDK sync)#74
maltsev-dev merged 1 commit into
masterfrom
fix/gap-1c-approval-timeout

Conversation

@maltsev-dev

Copy link
Copy Markdown
Member

Summary

Closes the SDK side of the Разрыв 1c contract: SDK now reads the server-authoritative approval_timeout_seconds from the /gate response when present, falling back to the NULLRUN_APPROVAL_TIMEOUT_SECONDS env default only on missing/non-positive/non-numeric values.

Pairs with backend commit 0ad03b9 (Разрыв 1c, gate hot-path trigger) which added approval_timeout_seconds: Option<i64> and approval_expires_at: Option<String> to the GateResponse wire format.

Why this matters

Before this fix, the approval wait path used the env default (300s) as the only source of wait duration. A backend approval rule configured with expires_in_seconds=20 (short-approval use case) would have the backend's expiry sweeper close the row at 20s, but the SDK would have timed out the parked gate call at 300s — a silent desync.

The 300s/300s coincidence worked only because no UI-1 yet exists to set non-default expirations, and because the env default matched the backend default. Разрыв 3 prevention on the backend side.

Changes

_wait_for_approval_resolution (runtime.py:1225)

New optional kwarg timeout_seconds: float | None = None:

  • When set to a positive number, used as the event.wait() timeout (server-authoritative, takes precedence over the env default).
  • When None (legacy backend without Разрыв 1c field, or malformed response), falls back to self._approval_timeout_seconds (env default) — pre-Разрыв 1c behaviour preserved.
  • When set to a non-positive number (0 or negative), also falls back to env default; we explicitly reject these because event.wait(timeout=0) deadlocks on the very first call.

When the server value diverges from the env default, a DEBUG log line is emitted ("approval {id}: using server timeout={X}s (env default would have been {Y}s)") for diagnostic visibility.

check_workflow_budget (runtime.py:1611)

Reads response["approval_timeout_seconds"] (server value), validates the type (must be a number) and sign (must be positive), and falls back to None on any validation failure. Existing env-default path preserved as the explicit fallback contract.

approval_expires_at is intentionally not parsed in the SDK (informational only; the SDK's wait math doesn't need it).

Tests (6 new in tests/test_approval_timeout_field.py)

  • test_server_timeout_used_when_response_has_valid_value
  • test_env_fallback_when_response_omits_field
  • test_env_fallback_when_server_value_is_zero (0 / 0.0 / -1 / -100.0)
  • test_env_fallback_when_server_value_is_non_numeric
  • test_timeout_sentinel_returned_when_no_ws_push
  • test_diverging_server_value_logs_at_debug

Verification

pytest tests/test_approval_timeout_field.py                              → 6 passed
pytest -n auto --cov=src/nullrun --cov-branch --cov-fail-under=0        → 1243 passed, 7 skipped, 28 warnings in 34.44s
                                                                          coverage 80.92%
ruff check src/ tests/                                                   → All checks passed
mypy src/                                                                → Success: no issues found in 34 source files

Compatibility

  • Backward compatible — the new timeout_seconds kwarg defaults to None, so existing callers are unaffected.
  • No SDK_MIN_VERSION bump — legacy backends without the Разрыв 1c field fall through to the env default, exactly as before.
  • No on-wire change — the SDK is a passive consumer of the new optional fields.

CHANGELOG entry (deferred to release PR)

### Fixed
- approval wait: SDK now uses server-authoritative
  `approval_timeout_seconds` from the /gate response when
  available, falling back to `NULLRUN_APPROVAL_TIMEOUT_SECONDS`
  env default only on missing/non-positive/non-numeric values
  (Разрыв 1c SDK sync; matches backend commit 0ad03b9).

CHANGELOG.md + version bump will land in a follow-up release PR — this commit is behaviour-only.

…c SDK sync)

Backend commit 0ad03b9 (Разрыв 1c, gate hot-path trigger) added
`approval_timeout_seconds: Option<i64>` and
`approval_expires_at: Option<String>` to the GateResponse wire
format. Before this SDK fix, the approval wait path used
`NULLRUN_APPROVAL_TIMEOUT_SECONDS` env default (default 300s) as
the ONLY source of wait duration — which is exactly the Разрыв 3
class of bug that the backend sweeper was written to prevent on
the backend side.

Concretely: a backend approval rule configured with
`expires_in_seconds=20` (short-approval use case) would
have the backend's expiry sweeper close the row at 20s, but the
SDK would have timed out the parked gate call at 300s — a silent
desync. The 300s/300s coincidence worked only because no UI-1
yet exists to set non-default expirations, and because the env
default matched the backend default.

This commit closes the SDK side of the Разрыв 1c contract:

## Changes

### `_wait_for_approval_resolution` (runtime.py:1225)

New optional kwarg `timeout_seconds: float | None = None`:

- When set to a positive number, used as the event.wait() timeout
  (server-authoritative, takes precedence over the env default).
- When `None` (legacy backend without Разрыв 1c field, or
  malformed response), falls back to
  `self._approval_timeout_seconds` (the env default) —
  pre-Разрыв 1c behaviour preserved.
- When set to a non-positive number (0 or negative), also falls
  back to env default; we explicitly reject these because
  `event.wait(timeout=0)` deadlocks on the very first call.

The effective timeout is also stored on the pending entry as
`entry["timeout_seconds"]` so callers / tests can inspect which
value actually drove the wait.

When the server value diverges from the env default, a DEBUG
log line is emitted ("approval {id}: using server timeout={X}s
(env default would have been {Y}s)") so an operator inspecting
logs can see which value actually drove the wait — useful for
diagnosing 'why did this approval time out earlier than I
configured' tickets.

### `check_workflow_budget` (runtime.py:1611)

Reads `response["approval_timeout_seconds"]` (server value),
validates the type (must be a number) and sign (must be positive),
and falls back to `None` (which then triggers env-default in
_wait_for_approval_resolution) on any validation failure. The
existing env-default path is preserved as the explicit fallback
contract.

`approval_expires_at` is intentionally not parsed in the SDK:
the field is documented as informational (UI/logs) and isn't
required for the SDK's wait math. If a future backend draft
sends only the ISO8601 string, the SDK will fall through to the
env default — same behaviour as the field being absent.

The approval-required INFO log line now includes which timeout
drove the wait ("env-default" vs the server value) for
diagnostic visibility.

## Tests (6 new in tests/test_approval_timeout_field.py)

- `test_server_timeout_used_when_response_has_valid_value`:
  server timeout=15s is stored on the entry, env default 300s
  is NOT used.
- `test_env_fallback_when_response_omits_field`: pre-Разрыв 1c
  behaviour preserved when server sends no timeout field.
- `test_env_fallback_when_server_value_is_zero`: 0/0.0/-1/-100.0
  all fall back to env default (regression guard against the
  `event.wait(timeout=0)` deadlock footgun).
- `test_env_fallback_when_server_value_is_non_numeric`: pre-
  validated None falls back to env default.
- `test_timeout_sentinel_returned_when_no_ws_push`: timeout
  fires at the SERVER timeout, not the env default (0.1s
  server, 300s env, 300s timeout would mean a 5-min test run).
- `test_diverging_server_value_logs_at_debug`: caplog captures
  the 'using server timeout=Xs (env default would have been Ys)'
  DEBUG line when values diverge.

## Verification

```
pytest tests/test_approval_timeout_field.py → 6 passed
pytest -n auto --cov=src/nullrun --cov-branch
       --cov-report=xml --cov-fail-under=0
                                       → 1243 passed, 7 skipped, 28 warnings
                                         in 34.44s (coverage 80.92%)
```

No public API change (new optional kwarg, backward compatible).
No SDK_MIN_VERSION bump. No on-wire change.

## SDK release note (for CHANGELOG.md follow-up)

When the SDK version is bumped, add:

```
### Fixed
- approval wait: SDK now uses server-authoritative
  `approval_timeout_seconds` from the /gate response when
  available, falling back to `NULLRUN_APPROVAL_TIMEOUT_SECONDS`
  env default only on missing/non-positive/non-numeric values
  (Разрыв 1c SDK sync; matches backend commit 0ad03b9).
```

(CHANGELOG.md edit deferred to release PR — this commit is
behaviour-only.)
@codecov

codecov Bot commented Jul 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 28.57143% with 10 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/nullrun/runtime.py 28.57% 10 Missing ⚠️

📢 Thoughts on this report? Let us know!

@maltsev-dev
maltsev-dev merged commit bde45c3 into master Jul 21, 2026
4 of 5 checks passed
maltsev-dev added a commit that referenced this pull request Jul 21, 2026
Bump SDK 0.13.12 -> 0.13.13. Behaviour change (backward compatible):
the approval wait now uses the server-authoritative
approval_timeout_seconds from the /gate response when present,
falling back to the NULLRUN_APPROVAL_TIMEOUT_SECONDS env default
only on missing/non-positive/non-numeric values. Pairs with
backend commit 0ad03b9 (Разрыв 1c, gate hot-path trigger).

Changes:
- pyproject.toml version + comment history bumped to 0.13.13.
- src/nullrun/__version__.py docstring adds the v3.27 / 0.13.13
  entry with full Разрыв 1c rationale, fix details, tests
  summary, and verification commands.
- CHANGELOG.md adds the ## [0.13.13] - 2026-07-21 section
  (Fixed / Tests / Compatibility) ahead of 0.13.12.

The runtime fix (src/nullrun/runtime.py:_wait_for_approval_resolution,
_check_workflow_budget) and the 6-test contract suite landed
in PR #74 (commit bde45c3). This release commit is the
version-bump + changelog half.

Verified: pytest -n auto --cov=src/nullrun --cov-branch
--cov-report=xml --cov-fail-under=0
  → 1243 passed, 7 skipped, 29 warnings in 33.15s, cov 80.92%
ruff check src/ tests/ → All checks passed
mypy src/ → Success: no issues found in 34 source files
maltsev-dev added a commit that referenced this pull request Jul 21, 2026
* fix(sdk): read approval_timeout_seconds from /gate response (Разрыв 1c SDK sync)

Backend commit 0ad03b9 (Разрыв 1c, gate hot-path trigger) added
`approval_timeout_seconds: Option<i64>` and
`approval_expires_at: Option<String>` to the GateResponse wire
format. Before this SDK fix, the approval wait path used
`NULLRUN_APPROVAL_TIMEOUT_SECONDS` env default (default 300s) as
the ONLY source of wait duration — which is exactly the Разрыв 3
class of bug that the backend sweeper was written to prevent on
the backend side.

Concretely: a backend approval rule configured with
`expires_in_seconds=20` (short-approval use case) would
have the backend's expiry sweeper close the row at 20s, but the
SDK would have timed out the parked gate call at 300s — a silent
desync. The 300s/300s coincidence worked only because no UI-1
yet exists to set non-default expirations, and because the env
default matched the backend default.

This commit closes the SDK side of the Разрыв 1c contract:

## Changes

### `_wait_for_approval_resolution` (runtime.py:1225)

New optional kwarg `timeout_seconds: float | None = None`:

- When set to a positive number, used as the event.wait() timeout
  (server-authoritative, takes precedence over the env default).
- When `None` (legacy backend without Разрыв 1c field, or
  malformed response), falls back to
  `self._approval_timeout_seconds` (the env default) —
  pre-Разрыв 1c behaviour preserved.
- When set to a non-positive number (0 or negative), also falls
  back to env default; we explicitly reject these because
  `event.wait(timeout=0)` deadlocks on the very first call.

The effective timeout is also stored on the pending entry as
`entry["timeout_seconds"]` so callers / tests can inspect which
value actually drove the wait.

When the server value diverges from the env default, a DEBUG
log line is emitted ("approval {id}: using server timeout={X}s
(env default would have been {Y}s)") so an operator inspecting
logs can see which value actually drove the wait — useful for
diagnosing 'why did this approval time out earlier than I
configured' tickets.

### `check_workflow_budget` (runtime.py:1611)

Reads `response["approval_timeout_seconds"]` (server value),
validates the type (must be a number) and sign (must be positive),
and falls back to `None` (which then triggers env-default in
_wait_for_approval_resolution) on any validation failure. The
existing env-default path is preserved as the explicit fallback
contract.

`approval_expires_at` is intentionally not parsed in the SDK:
the field is documented as informational (UI/logs) and isn't
required for the SDK's wait math. If a future backend draft
sends only the ISO8601 string, the SDK will fall through to the
env default — same behaviour as the field being absent.

The approval-required INFO log line now includes which timeout
drove the wait ("env-default" vs the server value) for
diagnostic visibility.

## Tests (6 new in tests/test_approval_timeout_field.py)

- `test_server_timeout_used_when_response_has_valid_value`:
  server timeout=15s is stored on the entry, env default 300s
  is NOT used.
- `test_env_fallback_when_response_omits_field`: pre-Разрыв 1c
  behaviour preserved when server sends no timeout field.
- `test_env_fallback_when_server_value_is_zero`: 0/0.0/-1/-100.0
  all fall back to env default (regression guard against the
  `event.wait(timeout=0)` deadlock footgun).
- `test_env_fallback_when_server_value_is_non_numeric`: pre-
  validated None falls back to env default.
- `test_timeout_sentinel_returned_when_no_ws_push`: timeout
  fires at the SERVER timeout, not the env default (0.1s
  server, 300s env, 300s timeout would mean a 5-min test run).
- `test_diverging_server_value_logs_at_debug`: caplog captures
  the 'using server timeout=Xs (env default would have been Ys)'
  DEBUG line when values diverge.

## Verification

```
pytest tests/test_approval_timeout_field.py → 6 passed
pytest -n auto --cov=src/nullrun --cov-branch
       --cov-report=xml --cov-fail-under=0
                                       → 1243 passed, 7 skipped, 28 warnings
                                         in 34.44s (coverage 80.92%)
```

No public API change (new optional kwarg, backward compatible).
No SDK_MIN_VERSION bump. No on-wire change.

## SDK release note (for CHANGELOG.md follow-up)

When the SDK version is bumped, add:

```
### Fixed
- approval wait: SDK now uses server-authoritative
  `approval_timeout_seconds` from the /gate response when
  available, falling back to `NULLRUN_APPROVAL_TIMEOUT_SECONDS`
  env default only on missing/non-positive/non-numeric values
  (Разрыв 1c SDK sync; matches backend commit 0ad03b9).
```

(CHANGELOG.md edit deferred to release PR — this commit is
behaviour-only.)

* chore(release): 0.13.13 — Разрыв 1c SDK sync

Bump SDK 0.13.12 -> 0.13.13. Behaviour change (backward compatible):
the approval wait now uses the server-authoritative
approval_timeout_seconds from the /gate response when present,
falling back to the NULLRUN_APPROVAL_TIMEOUT_SECONDS env default
only on missing/non-positive/non-numeric values. Pairs with
backend commit 0ad03b9 (Разрыв 1c, gate hot-path trigger).

Changes:
- pyproject.toml version + comment history bumped to 0.13.13.
- src/nullrun/__version__.py docstring adds the v3.27 / 0.13.13
  entry with full Разрыв 1c rationale, fix details, tests
  summary, and verification commands.
- CHANGELOG.md adds the ## [0.13.13] - 2026-07-21 section
  (Fixed / Tests / Compatibility) ahead of 0.13.12.

The runtime fix (src/nullrun/runtime.py:_wait_for_approval_resolution,
_check_workflow_budget) and the 6-test contract suite landed
in PR #74 (commit bde45c3). This release commit is the
version-bump + changelog half.

Verified: pytest -n auto --cov=src/nullrun --cov-branch
--cov-report=xml --cov-fail-under=0
  → 1243 passed, 7 skipped, 29 warnings in 33.15s, cov 80.92%
ruff check src/ tests/ → All checks passed
mypy src/ → Success: no issues found in 34 source files
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant