Skip to content

feat: add TENANT_CLOSED + LIMIT_EXCEEDED error-code support (runtime spec v0.1.25.13 / v0.1.25.12)#78

Merged
amavashev merged 4 commits into
mainfrom
feat/tenant-closed-error-code
Jul 10, 2026
Merged

feat: add TENANT_CLOSED + LIMIT_EXCEEDED error-code support (runtime spec v0.1.25.13 / v0.1.25.12)#78
amavashev merged 4 commits into
mainfrom
feat/tenant-closed-error-code

Conversation

@amavashev

@amavashev amavashev commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

Additive support for two runtime error codes:

  • TENANT_CLOSED — runtime spec v0.1.25.13 (runcycles/cycles-protocol#125): servers return HTTP 409 error=TENANT_CLOSED on 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) carry error=LIMIT_EXCEEDED plus Retry-After / X-RateLimit-Reset headers.

TENANT_CLOSED (per-code pattern)

  • ErrorCode.TENANT_CLOSED enum member (runcycles/models.py)
  • TenantClosedError(CyclesProtocolError) (runcycles/exceptions.py), exported from runcycles; raised at reservation-creation time by the @cycles decorator, CyclesLifecycle, and streaming reserve paths (the _build_protocol_exception surfaces). Commit/release-time TENANT_CLOSED follows the existing commit-failure policy (handled/released internally); the programmatic client surfaces the code on CyclesResponse as usual.
  • CyclesProtocolError.is_tenant_closed() helper; README table row; CHANGELOG + AUDIT entries.

LIMIT_EXCEEDED (enum-only, deliberate)

  • ErrorCode.LIMIT_EXCEEDED 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, 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).
  • Shape: enum-only — matches the BUDGET_FROZEN/BUDGET_CLOSED sibling 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_string falls back to UNKNOWN. For TENANT_CLOSED that made a 409 look retryable (bug-adjacent; now typed and non-retryable). For LIMIT_EXCEEDED the accidental UNKNOWN → retryable happened 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 . and mypy runcycles (strict) clean

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.
@amavashev

Copy link
Copy Markdown
Contributor Author

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.
@amavashev amavashev changed the title feat: add TENANT_CLOSED error-code support (runtime spec v0.1.25.13) feat: add TENANT_CLOSED + LIMIT_EXCEEDED error-code support (runtime spec v0.1.25.13 / v0.1.25.12) Jul 10, 2026
@amavashev

Copy link
Copy Markdown
Contributor Author

Codex round-2 review applied — apply 3 / modify 0 / skip 0, plus the shared LIMIT_EXCEEDED follow-up.

# Finding Disposition
1 CHANGELOG swallowed the ## [0.4.3] header Applied — header restored; 0.4.3 is its own section again
2 CHANGELOG/README over-implied typed raising on commit/release/extend Applied — reworded to scope TenantClosedError to the reservation-creation surfaces (_build_protocol_exception call sites); commit/release-time codes follow the internal commit-failure policy
3 AUDIT typed-exceptions list omitted TenantClosedError Applied — list updated; also cited runcycles/cycles-protocol#125 explicitly in AUDIT + CHANGELOG instead of "PR pending"

LIMIT_EXCEEDED gap closed (separate commit, runtime spec v0.1.25.12, revision 2026-07-04): ErrorCode.LIMIT_EXCEEDED added in spec declaration order (TENANT_CLOSED relocated after it to mirror the spec exactly). Retryability: retryable at both layers (ErrorCode.is_retryable + CyclesProtocolError.is_retryable()) — 429 is transient, the spec instructs retry after the indicated delay, and the status-based rule only covers ≥500; this also preserves the prior UNKNOWN → retryable fallback behavior. Shape: enum-only, matching the BUDGET_FROZEN/BUDGET_CLOSED sibling pattern (not a lifecycle denial → no exception subclass or mapping).

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.
@amavashev

Copy link
Copy Markdown
Contributor Author

Codex round-3 applied — apply 2 / modify 0 / skip 0.

# Finding Disposition
1 Retry-After header not SDK-visible Appliedretry-after added to _RESPONSE_HEADERS; new CyclesResponse.retry_after_ms_header (seconds → ms, non-integer/HTTP-date forms ignored gracefully); _build_protocol_exception falls back to it when the body retry_after_ms is absent (body wins when both 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.py), fallback + body precedence (test_lifecycle.py), end-to-end 429 LIMIT_EXCEEDED with a real Retry-After header through the client (test_client.py)
2 Stale AUDIT placement line Applied — now states the actual spec order (… MAX_EXTENSIONS_EXCEEDED, LIMIT_EXCEEDED, TENANT_CLOSED, INTERNAL_ERROR)

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).

@amavashev
amavashev merged commit b0a521a into main Jul 10, 2026
7 checks passed
@amavashev
amavashev deleted the feat/tenant-closed-error-code branch July 10, 2026 18:02
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