Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 39 additions & 2 deletions AUDIT.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Cycles Protocol v0.1.25 — Client (Python) Audit

**Date:** 2026-07-09 (README + docstring transport-error documentation fix, no version bump — see the dated entry at the end of this file. `CyclesTransportError` is exported but never raised by the SDK; README and its docstring now describe the actual `status == -1` surfacing.),
**Date:** 2026-07-10 (unreleased — `TENANT_CLOSED` + `LIMIT_EXCEEDED` error-code support. `TENANT_CLOSED` per runtime spec v0.1.25.13 (`cycles-protocol-v0.yaml`, runcycles/cycles-protocol#125): `ErrorCode.TENANT_CLOSED` enum member, `TenantClosedError` subclass wired into the lifecycle error-code→exception mapping (reservation-creation surfaces), `CyclesProtocolError.is_tenant_closed()` helper. `LIMIT_EXCEEDED` per runtime spec v0.1.25.12 (revision 2026-07-04, HTTP 429 rate limiting): enum-only member matching the `BUDGET_FROZEN`/`BUDGET_CLOSED` pattern, classified retryable at both the enum and exception layers (429 is transient; previously it fell through to `UNKNOWN`, which happened to be retryable, so semantics are unchanged — now typed). Enum reordered to mirror spec declaration order. Both purely additive; previously both codes fell through the `ErrorCode.from_string` forward-compat path to `UNKNOWN`. See the dated entries at the end of this file. 398 tests pass at 100% coverage.),
2026-07-09 (README + docstring transport-error documentation fix, no version bump — see the dated entry at the end of this file. `CyclesTransportError` is exported but never raised by the SDK; README and its docstring now describe the actual `status == -1` surfacing.),
2026-07-03 (integration-test-only, no version bump — `test_health_check` now probes the public `/actuator/health/readiness` endpoint instead of aggregate `/actuator/health`, which requires `X-Admin-API-Key` since cycles-server v0.1.25.45 and fails closed with 500 when the server has no admin key configured. The old assertion had failed the org nightly Full-Stack Integration every night since 2026-06-28. No library code change.),
2026-05-22 (v0.4.3 — `expires_from`/`expires_to` and `finalized_from`/`finalized_to` ISO-8601 window-filter passthrough on `list_reservations` per `cycles-protocol-v0.yaml` revision 2026-05-22; closes the Python-client side of runcycles/cycles-server#162. No code change — `**query_params` already forwards arbitrary kwargs. Added sync + async regression tests; unlike `from`/`to` the new param names are plain kwargs (no Python-reserved-word workaround needed). 393 tests pass at 100% coverage.),
2026-05-21 (v0.4.2 — `from` / `to` ISO-8601 window-filter passthrough on `list_reservations` per `cycles-protocol-v0.yaml` revision 2026-05-21; closes the client side of runcycles/cycles-server#159. No code change — the existing `**query_params` signature already forwards arbitrary kwargs to the URL query string. Added sync + async regression tests that lock the passthrough in (using the `**{"from": ..., "to": ...}` dict-unpack form because `from` is a Python reserved keyword). 391 tests pass at 100% coverage.),
Expand Down Expand Up @@ -164,7 +165,7 @@ All spec constraints are validated both via Pydantic Field validators (on typed

- `is_success` correctly handles 2xx range (200 for most endpoints, 201 for events)
- Error responses parsed via `ErrorResponse.model_validate()` with `ErrorCode` mapping
- Typed exceptions: `BudgetExceededError`, `OverdraftLimitExceededError`, `DebtOutstandingError`, `ReservationExpiredError`, `ReservationFinalizedError`
- Typed exceptions: `BudgetExceededError`, `OverdraftLimitExceededError`, `DebtOutstandingError`, `ReservationExpiredError`, `ReservationFinalizedError`, `TenantClosedError` (added 2026-07-10, raised at reservation-creation time)

---

Expand Down Expand Up @@ -286,3 +287,39 @@ Documentation-only correction. The README's exception-hierarchy table described
The table row now states the class is exported for user code but never raised by the SDK, and a new "Transport errors" subsection documents the `status == -1` behavior for both API surfaces with a detection example. Wording matches the docs site (`cycles-docs/how-to/error-handling-patterns-in-python.md`). `CyclesTransportError` remains exported from `runcycles/__init__.py` for use in user code — no API change.

Protocol conformance: No protocol or wire-format changes. No SDK source touched. Test suite unaffected.

## TENANT_CLOSED Error-Code Support (added 2026-07-10)

**Files:** `runcycles/models.py`, `runcycles/exceptions.py`, `runcycles/lifecycle.py`, `runcycles/__init__.py`, `README.md`, `CHANGELOG.md`, tests
**Version:** unreleased

Additive support for the `TENANT_CLOSED` protocol error code, per runtime spec v0.1.25.13 of `cycles-protocol-v0.yaml` (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 — the runtime-surface mirror of governance spec Rule 2 (mutating operations on objects owned by a CLOSED tenant are rejected with 409 TENANT_CLOSED).

- `ErrorCode.TENANT_CLOSED` added to the enum in `models.py`, in spec declaration order: `… MAX_EXTENSIONS_EXCEEDED, LIMIT_EXCEEDED, TENANT_CLOSED, INTERNAL_ERROR` (initially placed after `BUDGET_CLOSED`; relocated when `LIMIT_EXCEEDED` landed so the enum mirrors the spec exactly).
- `TenantClosedError(CyclesProtocolError)` added to `exceptions.py` following the existing per-code subclass pattern; exported from `runcycles/__init__.py`.
- `_build_protocol_exception` in `lifecycle.py` maps `error == "TENANT_CLOSED"` to `TenantClosedError`. This mapping is invoked on the reservation-creation paths of the `@cycles` decorator, `CyclesLifecycle`, and streaming surfaces; commit/release-time error codes are handled by the existing commit-failure policy (logged + released) and are not raised as typed exceptions.
- `CyclesProtocolError.is_tenant_closed()` helper added alongside the existing `is_*` per-code helpers.
- README exception table gained a `TenantClosedError` row.

Forward-compat behavior before this change (verified): an unrecognized `TENANT_CLOSED` string was mapped to `ErrorCode.UNKNOWN` by `ErrorCode.from_string`, so lifecycle surfaces raised plain `CyclesProtocolError` with `error_code == "UNKNOWN"` — which `is_retryable()` reports as retryable. Deserialization never failed. With this change the code is recognized, typed, and correctly non-retryable.

The vendored spec fixture (`tests/fixtures/cycles-protocol-v0.yaml`, pinned at v0.1.24) is intentionally untouched; it will be refreshed when the v0.1.25.13 spec PR merges. The contract suite validates the fixture, not the client enum, so the client enum being a superset is by design.

Tests: new coverage for the enum member (`test_models.py`), the exception subclass + helper (`test_exceptions.py`), and the lifecycle mapping (`test_lifecycle.py`). 396 tests pass at 100% coverage (gate ≥95%); ruff + mypy --strict clean.

## LIMIT_EXCEEDED Error-Code Support (added 2026-07-10)

**Files:** `runcycles/models.py`, `runcycles/exceptions.py`, `CHANGELOG.md`, tests
**Version:** unreleased (same PR as the TENANT_CLOSED entry above)

Additive support for the `LIMIT_EXCEEDED` protocol error code, added to the runtime ErrorCode enum in spec revision v0.1.25.12 (2026-07-04). Per the spec's ERROR SEMANTICS, HTTP 429 is reserved for server-side throttling/rate limiting (declared on the public evidence/JWKS endpoints); 429 responses carry `error=LIMIT_EXCEEDED` plus the `Retry-After` and `X-RateLimit-Reset` headers, and clients retry after the indicated delay.

- `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 decision: **retryable at both layers** — added to `ErrorCode.is_retryable` and to the code tuple in `CyclesProtocolError.is_retryable()`. Rationale: a 429 rate limit is transient by definition and the spec instructs retry; this also preserves prior behavior, where the unrecognized string fell back to `ErrorCode.UNKNOWN` (retryable). The repo's status-based rule (`status >= 500`) does not cover 429, so the code-based classification carries it.
- Shape decision: **enum-only** — no `LimitExceededError` subclass, no lifecycle mapping. This matches the sibling pattern (`BUDGET_FROZEN`/`BUDGET_CLOSED` are enum-only): LIMIT_EXCEEDED is not a reservation-lifecycle denial, so a typed exception class is not warranted.

Forward-compat behavior before this change (verified): `ErrorCode.from_string("LIMIT_EXCEEDED")` returned `ErrorCode.UNKNOWN` — retryable by accident. Now typed and retryable by design; no semantic change.

Retry-After exposure (codex round-3): the client previously dropped the HTTP `Retry-After` header, so the spec's "retry after the indicated delay" was not SDK-visible for header-carried 429s. `retry-after` is now captured in `_RESPONSE_HEADERS` (`client.py`), exposed as `CyclesResponse.retry_after_ms_header` (seconds → ms, non-integer forms ignored), and `_build_protocol_exception` falls back to it for `retry_after_ms` when the body field 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: enum member + retryable (`test_models.py`), exception-layer retryable with `retry_after_ms` (`test_exceptions.py`), Retry-After header conversion + precedence (`test_response.py`, `test_lifecycle.py`), and an end-to-end 429 LIMIT_EXCEEDED with a real `Retry-After` header through the client (`test_client.py`). Full suite green at 100% coverage.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog 1.1.0](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

`TENANT_CLOSED` + `LIMIT_EXCEEDED` error-code support. `TENANT_CLOSED` implements the runtime spec v0.1.25.13 revision of `cycles-protocol-v0.yaml` ([runcycles/cycles-protocol#125](https://github.com/runcycles/cycles-protocol/pull/125)): servers return HTTP 409 `error=TENANT_CLOSED` on reservation create/commit/release/extend when the owning tenant is CLOSED (mirrors governance spec Rule 2). `LIMIT_EXCEEDED` closes the same class of gap for the runtime spec v0.1.25.12 revision (2026-07-04): HTTP 429 rate-limit responses carry `error=LIMIT_EXCEEDED` plus `Retry-After` / `X-RateLimit-Reset` headers.

### Added

- `ErrorCode.TENANT_CLOSED` enum member.
- `TenantClosedError` (subclass of `CyclesProtocolError`), raised at reservation-creation time by the `@cycles` decorator, `CyclesLifecycle`, and streaming reserve paths (the `_build_protocol_exception` surfaces) when the server returns `TENANT_CLOSED`; exported from `runcycles`. Commit/release-time `TENANT_CLOSED` responses follow the existing commit-failure policy (handled/released internally, not raised as typed exceptions); the programmatic client surfaces the code on `CyclesResponse` as usual.
- `CyclesProtocolError.is_tenant_closed()` helper.
- `ErrorCode.LIMIT_EXCEEDED` enum member (spec order, after `MAX_EXTENSIONS_EXCEEDED`), classified retryable by `ErrorCode.is_retryable` and `CyclesProtocolError.is_retryable()` — a 429 rate limit is transient and the spec instructs clients to retry after the indicated delay. Enum-only by design, matching the `BUDGET_FROZEN`/`BUDGET_CLOSED` pattern: it is not a reservation-lifecycle denial, so no exception subclass or lifecycle mapping is warranted.
- `Retry-After` header exposure: the client now captures the HTTP `Retry-After` header (how 429 rate-limit responses carry the delay per the spec) and exposes it as `CyclesResponse.retry_after_ms_header` (seconds → ms; non-integer forms ignored gracefully). `_build_protocol_exception` falls back to it for `retry_after_ms` when the body carries no `retry_after_ms` field (body wins when both are present). No auto-retry behavior change — the delay is surfaced, not consumed.

### Notes

- Purely additive; no wire-format change. Before this release, servers returning `TENANT_CLOSED` were handled via the existing forward-compat path: `ErrorCode.from_string` mapped the unrecognized string to `ErrorCode.UNKNOWN`, so the reservation-creation surfaces raised plain `CyclesProtocolError` with `error_code == "UNKNOWN"` — which `is_retryable()` treats as retryable. With this release the code is recognized, surfaces as `TenantClosedError` with `error_code == "TENANT_CLOSED"`, and is correctly non-retryable.
- `LIMIT_EXCEEDED` previously also fell through to `ErrorCode.UNKNOWN`, which happened to be retryable — so its retry semantics are unchanged; it is now typed instead of accidental. `TENANT_CLOSED` moved to sit after `LIMIT_EXCEEDED` in the enum to mirror the spec's declaration order exactly.

## [0.4.3] - 2026-05-22

Wire-passthrough verification for `expires_from`/`expires_to` and `finalized_from`/`finalized_to` query params on `list_reservations`. Implements `cycles-protocol-v0.yaml` revision 2026-05-22 ([runcycles/cycles-protocol#98](https://github.com/runcycles/cycles-protocol/pull/98)) on the client side; runcycles/cycles-server#163 ships the server impl.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ Exception hierarchy:
| `DebtOutstandingError` | Outstanding debt blocks new reservations |
| `ReservationExpiredError` | Operating on an expired reservation |
| `ReservationFinalizedError` | Operating on an already-committed/released reservation |
| `TenantClosedError` | The owning tenant is CLOSED (HTTP 409 `TENANT_CLOSED`, runtime spec v0.1.25.13); raised at reservation-creation time — commit/release failures are handled internally by the commit-retry/release policy |
| `CyclesTransportError` | Exported for use in your own code; never raised by the SDK — transport failures surface as `status == -1` (see below) |

### Transport errors
Expand Down
2 changes: 2 additions & 0 deletions runcycles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
OverdraftLimitExceededError,
ReservationExpiredError,
ReservationFinalizedError,
TenantClosedError,
)
from runcycles.models import (
Action,
Expand Down Expand Up @@ -81,6 +82,7 @@
"OverdraftLimitExceededError",
"ReservationExpiredError",
"ReservationFinalizedError",
"TenantClosedError",
# Models
"Unit",
"Amount",
Expand Down
2 changes: 1 addition & 1 deletion runcycles/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def _extract_idempotency_key(body: dict[str, Any]) -> str | None:
return body.get("idempotency_key")


_RESPONSE_HEADERS = ("x-request-id", "x-ratelimit-remaining", "x-ratelimit-reset", "x-cycles-tenant")
_RESPONSE_HEADERS = ("x-request-id", "x-ratelimit-remaining", "x-ratelimit-reset", "x-cycles-tenant", "retry-after")

_BALANCE_FILTER_PARAMS = {"tenant", "workspace", "app", "workflow", "agent", "toolset"}

Expand Down
17 changes: 16 additions & 1 deletion runcycles/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ def is_overdraft_limit_exceeded(self) -> bool:
def is_debt_outstanding(self) -> bool:
return self.error_code == "DEBT_OUTSTANDING"

def is_tenant_closed(self) -> bool:
return self.error_code == "TENANT_CLOSED"

def is_reservation_expired(self) -> bool:
return self.error_code == "RESERVATION_EXPIRED"

Expand All @@ -53,7 +56,9 @@ def is_unit_mismatch(self) -> bool:
return self.error_code == "UNIT_MISMATCH"

def is_retryable(self) -> bool:
return self.error_code in ("INTERNAL_ERROR", "UNKNOWN") or self.status >= 500
# LIMIT_EXCEEDED (HTTP 429 rate limiting, runtime spec v0.1.25.12)
# is transient: retry after retry_after_ms / Retry-After.
return self.error_code in ("INTERNAL_ERROR", "UNKNOWN", "LIMIT_EXCEEDED") or self.status >= 500


class BudgetExceededError(CyclesProtocolError):
Expand All @@ -68,6 +73,16 @@ class DebtOutstandingError(CyclesProtocolError):
"""Raised when outstanding debt blocks new reservations."""


class TenantClosedError(CyclesProtocolError):
"""Raised when the owning tenant is CLOSED (HTTP 409, ``TENANT_CLOSED``).

Servers return this on reservation create/commit/release/extend when the
owning tenant's status is CLOSED, per runtime spec v0.1.25.13 (mirrors
governance spec Rule 2). Not retryable — the tenant must be reopened
administratively before further budget operations succeed.
"""


class ReservationExpiredError(CyclesProtocolError):
"""Raised when operating on an expired reservation."""

Expand Down
8 changes: 8 additions & 0 deletions runcycles/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
OverdraftLimitExceededError,
ReservationExpiredError,
ReservationFinalizedError,
TenantClosedError,
)
from runcycles.models import (
CyclesMetrics,
Expand Down Expand Up @@ -201,9 +202,14 @@ def _build_protocol_exception(prefix: str, response: CyclesResponse) -> CyclesPr
if reason_code is None and error_code is not None:
reason_code = error_code

# Body field wins; otherwise fall back to the HTTP Retry-After header
# (seconds → ms), which is how 429 LIMIT_EXCEEDED responses carry the
# delay per runtime spec v0.1.25.12.
retry_raw = response.get_body_attribute("retry_after_ms")
if retry_raw is not None:
retry_after_ms = int(retry_raw)
else:
retry_after_ms = response.retry_after_ms_header

exc_class = CyclesProtocolError
if error_code == "BUDGET_EXCEEDED":
Expand All @@ -216,6 +222,8 @@ def _build_protocol_exception(prefix: str, response: CyclesResponse) -> CyclesPr
exc_class = ReservationExpiredError
elif error_code == "RESERVATION_FINALIZED":
exc_class = ReservationFinalizedError
elif error_code == "TENANT_CLOSED":
exc_class = TenantClosedError

return exc_class(
message,
Expand Down
7 changes: 6 additions & 1 deletion runcycles/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,17 @@ class ErrorCode(str, Enum):
OVERDRAFT_LIMIT_EXCEEDED = "OVERDRAFT_LIMIT_EXCEEDED"
DEBT_OUTSTANDING = "DEBT_OUTSTANDING"
MAX_EXTENSIONS_EXCEEDED = "MAX_EXTENSIONS_EXCEEDED"
LIMIT_EXCEEDED = "LIMIT_EXCEEDED"
TENANT_CLOSED = "TENANT_CLOSED"
INTERNAL_ERROR = "INTERNAL_ERROR"
UNKNOWN = "UNKNOWN"

@property
def is_retryable(self) -> bool:
return self in (ErrorCode.INTERNAL_ERROR, ErrorCode.UNKNOWN)
# LIMIT_EXCEEDED is HTTP 429 rate limiting (runtime spec
# v0.1.25.12): transient by definition — the spec instructs
# clients to retry after the indicated delay.
return self in (ErrorCode.INTERNAL_ERROR, ErrorCode.UNKNOWN, ErrorCode.LIMIT_EXCEEDED)

@classmethod
def from_string(cls, value: str | None) -> ErrorCode | None:
Expand Down
17 changes: 17 additions & 0 deletions runcycles/response.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,23 @@ def rate_limit_reset(self) -> int | None:
def cycles_tenant(self) -> str | None:
return self.headers.get("x-cycles-tenant")

@property
def retry_after_ms_header(self) -> int | None:
"""``Retry-After`` header (seconds, per spec) converted to milliseconds.

Servers send this on 429 ``LIMIT_EXCEEDED`` rate-limit responses
(runtime spec v0.1.25.12). Returns ``None`` when the header is absent
or not a plain integer (the HTTP-date form is not used by the spec
and is ignored gracefully).
"""
val = self.headers.get("retry-after")
if val is None:
return None
try:
return int(val) * 1000
except ValueError:
return None

@property
def is_success(self) -> bool:
return 200 <= self.status < 300
Expand Down
Loading