feat: add TENANT_CLOSED + LIMIT_EXCEEDED error-code support (runtime spec v0.1.25.13 / v0.1.25.12)#78
Conversation
Runtime spec v0.1.25.13 of cycles-protocol-v0.yaml (PR pending in runcycles/cycles-protocol) adds TENANT_CLOSED to the ErrorCode enum: servers return HTTP 409 error=TENANT_CLOSED on reservation create/commit/release/extend when the owning tenant is CLOSED (mirrors governance spec Rule 2). Additive client support following the existing per-code patterns: - ErrorCode.TENANT_CLOSED enum member (models.py) - TenantClosedError subclass of CyclesProtocolError (exceptions.py), exported from the package root - lifecycle _build_protocol_exception maps TENANT_CLOSED to TenantClosedError (feeds @cycles, CyclesLifecycle, streaming) - CyclesProtocolError.is_tenant_closed() helper - README exception-table row, CHANGELOG [Unreleased] entry, AUDIT.md rolling entry Forward compat before this change (verified): the unrecognized string fell through ErrorCode.from_string to UNKNOWN, so surfaces raised plain CyclesProtocolError with error_code == "UNKNOWN", which is_retryable() treats as retryable. Now typed, non-retryable. Vendored spec fixture (pinned v0.1.24) intentionally untouched; the contract suite validates the fixture, not the client enum. 396 tests pass at 100% coverage (gate >= 95%); ruff + mypy clean.
|
Spec reference: the runtime spec revision adding TENANT_CLOSED (v0.1.25.13) is now open as runcycles/cycles-protocol#125. The matching server-side Rule 2 guard on reservation create/commit/release/extend is in progress in cycles-server. Suggest merging the spec PR first, then this one; vendored spec fixtures can be refreshed once #125 merges. |
Apply 4 / modify 0 / skip 0: - CHANGELOG: restore the swallowed "## [0.4.3] - 2026-05-22" header so the 0.4.3 release notes no longer sit under [Unreleased] - CHANGELOG + README: scope typed TenantClosedError raising to the reservation-creation surfaces (_build_protocol_exception call sites); commit/release-time TENANT_CLOSED follows the existing commit-failure policy (handled/released internally, not raised) - AUDIT: add TenantClosedError to the typed-exceptions list so the audit is self-consistent, and clarify the mapping's invocation scope in the dated entry - AUDIT + CHANGELOG: cite runcycles/cycles-protocol#125 explicitly instead of "PR pending" Docs-only; no code change. 396 tests still pass.
Runtime spec revision v0.1.25.12 (2026-07-04) of cycles-protocol-v0.yaml added LIMIT_EXCEEDED to the runtime ErrorCode enum: HTTP 429 rate-limit responses (public evidence/JWKS endpoints) carry error=LIMIT_EXCEEDED plus Retry-After / X-RateLimit-Reset headers. Closes the same class of gap as the TENANT_CLOSED commit on this branch. - ErrorCode.LIMIT_EXCEEDED added in spec declaration order (after MAX_EXTENSIONS_EXCEEDED); TENANT_CLOSED relocated after it so the client enum mirrors the spec order exactly - Retryability: retryable at both layers (ErrorCode.is_retryable and CyclesProtocolError.is_retryable()) — 429 is transient and the spec instructs retry after the indicated delay; the status-based rule (>=500) does not cover 429, so the code-based classification carries it. Preserves prior fallback behavior (UNKNOWN, retryable). - Shape: enum-only, matching the BUDGET_FROZEN/BUDGET_CLOSED sibling pattern — not a reservation-lifecycle denial, so no exception subclass or lifecycle mapping - Tests for both layers; CHANGELOG + AUDIT updated 398 tests pass at 100% coverage; ruff + mypy clean.
|
Codex round-2 review applied — apply 3 / modify 0 / skip 0, plus the shared LIMIT_EXCEEDED follow-up.
LIMIT_EXCEEDED gap closed (separate commit, runtime spec v0.1.25.12, revision 2026-07-04): On the open question (BUDGET_CLOSED): it stays enum-only deliberately. There is no retry-semantics bug there (it was never misclassified), and backfilling typed siblings for the whole enum-only family is a possible follow-up — out of scope for this PR. Round results: 398 passed, 5 skipped, 100% coverage (gate ≥95%); ruff + mypy strict clean. CI status will be reported on the head commit. |
Apply 2 / modify 0 / skip 0:
- Retry-After exposure: the client dropped the HTTP Retry-After
header and _build_protocol_exception populated retry_after_ms only
from the body field, so the spec's "retry after the indicated
delay" was not SDK-visible for header-carried 429s (runtime spec
v0.1.25.12 sends the 429 delay via Retry-After). Now:
* "retry-after" added to _RESPONSE_HEADERS (client.py)
* CyclesResponse.retry_after_ms_header property (seconds -> ms,
non-integer forms such as HTTP-date ignored gracefully)
* _build_protocol_exception falls back to the header when the body
retry_after_ms is absent; body wins when both are present
* No auto-retry behavior change: no internal path consumes
code-level retryability; the delay is surfaced only
Tests: header conversion + non-numeric + absent (test_response),
fallback + body precedence (test_lifecycle), end-to-end 429
LIMIT_EXCEEDED with a real Retry-After header (test_client)
- AUDIT: corrected the stale TENANT_CLOSED placement line to the
actual spec order (... MAX_EXTENSIONS_EXCEEDED, LIMIT_EXCEEDED,
TENANT_CLOSED, INTERNAL_ERROR)
403 tests pass at 100% coverage; ruff + mypy clean.
|
Codex round-3 applied — apply 2 / modify 0 / skip 0.
Results: 403 passed, 5 skipped, 100% coverage (gate ≥95%); ruff + mypy strict clean. Codex verdict: SHIP (round-3 fixes applied exactly as directed; no further codex round needed). |
Summary
Additive support for two runtime error codes:
TENANT_CLOSED— runtime spec v0.1.25.13 (runcycles/cycles-protocol#125): servers return HTTP 409error=TENANT_CLOSEDon reservation create/commit/release/extend when the owning tenant's status is CLOSED (runtime-plane mirror of governance spec Rule 2).LIMIT_EXCEEDED— runtime spec v0.1.25.12 (revision 2026-07-04, already on protocol main): HTTP 429 rate-limit responses (public evidence/JWKS endpoints) carryerror=LIMIT_EXCEEDEDplusRetry-After/X-RateLimit-Resetheaders.TENANT_CLOSED (per-code pattern)
ErrorCode.TENANT_CLOSEDenum member (runcycles/models.py)TenantClosedError(CyclesProtocolError)(runcycles/exceptions.py), exported fromruncycles; raised at reservation-creation time by the@cyclesdecorator,CyclesLifecycle, and streaming reserve paths (the_build_protocol_exceptionsurfaces). Commit/release-timeTENANT_CLOSEDfollows the existing commit-failure policy (handled/released internally); the programmatic client surfaces the code onCyclesResponseas usual.CyclesProtocolError.is_tenant_closed()helper; README table row; CHANGELOG + AUDIT entries.LIMIT_EXCEEDED (enum-only, deliberate)
ErrorCode.LIMIT_EXCEEDEDin spec declaration order (afterMAX_EXTENSIONS_EXCEEDED);TENANT_CLOSEDrelocated after it so the client enum mirrors the spec order exactly.ErrorCode.is_retryable,CyclesProtocolError.is_retryable()) — 429 is transient and the spec instructs retry after the indicated delay; the repo's status-based rule only covers>= 500, so the code-based classification carries 429. This preserves prior fallback behavior (unknown →UNKNOWN→ retryable).BUDGET_FROZEN/BUDGET_CLOSEDsibling pattern; not a reservation-lifecycle denial, so no exception subclass or lifecycle mapping.Forward-compat behavior verified (before this PR)
Unknown codes never break deserialization:
ErrorCode.from_stringfalls back toUNKNOWN. ForTENANT_CLOSEDthat made a 409 look retryable (bug-adjacent; now typed and non-retryable). ForLIMIT_EXCEEDEDthe accidentalUNKNOWN → retryablehappened to be correct; it is now retryable by design.Fixture note
The vendored spec fixture (
tests/fixtures/cycles-protocol-v0.yaml, pinned at v0.1.24) is intentionally untouched — the contract suite validates the fixture, not the client enum (client superset is by design). Fixture refresh follows the #125 merge.Testing
pytest --cov: 398 passed, 5 skipped — 100% coverage (gate ≥ 95%)ruff check .andmypy runcycles(strict) clean