diff --git a/AUDIT.md b/AUDIT.md index d95741a..9749894 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -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.), @@ -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) --- @@ -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. diff --git a/CHANGELOG.md b/CHANGELOG.md index b6feb85..1de47f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 63ce081..0285718 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/runcycles/__init__.py b/runcycles/__init__.py index 4d237ef..beca392 100644 --- a/runcycles/__init__.py +++ b/runcycles/__init__.py @@ -13,6 +13,7 @@ OverdraftLimitExceededError, ReservationExpiredError, ReservationFinalizedError, + TenantClosedError, ) from runcycles.models import ( Action, @@ -81,6 +82,7 @@ "OverdraftLimitExceededError", "ReservationExpiredError", "ReservationFinalizedError", + "TenantClosedError", # Models "Unit", "Amount", diff --git a/runcycles/client.py b/runcycles/client.py index 6c95771..4b8581b 100644 --- a/runcycles/client.py +++ b/runcycles/client.py @@ -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"} diff --git a/runcycles/exceptions.py b/runcycles/exceptions.py index d434ed6..2896433 100644 --- a/runcycles/exceptions.py +++ b/runcycles/exceptions.py @@ -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" @@ -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): @@ -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.""" diff --git a/runcycles/lifecycle.py b/runcycles/lifecycle.py index 0053e69..409d3ec 100644 --- a/runcycles/lifecycle.py +++ b/runcycles/lifecycle.py @@ -27,6 +27,7 @@ OverdraftLimitExceededError, ReservationExpiredError, ReservationFinalizedError, + TenantClosedError, ) from runcycles.models import ( CyclesMetrics, @@ -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": @@ -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, diff --git a/runcycles/models.py b/runcycles/models.py index 0e2183c..90b6dfe 100644 --- a/runcycles/models.py +++ b/runcycles/models.py @@ -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: diff --git a/runcycles/response.py b/runcycles/response.py index aebe24f..c65eed3 100644 --- a/runcycles/response.py +++ b/runcycles/response.py @@ -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 diff --git a/tests/test_client.py b/tests/test_client.py index d93f35a..9ab5c43 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -288,6 +288,28 @@ def test_response_headers_extracted(self, config: CyclesConfig, httpx_mock) -> N assert response.request_id == "req-abc" assert response.rate_limit_remaining == 42 + def test_retry_after_header_on_429_limit_exceeded(self, config: CyclesConfig, httpx_mock) -> None: # type: ignore[no-untyped-def] + # Runtime spec v0.1.25.12: 429 rate-limit responses carry + # error=LIMIT_EXCEEDED plus the Retry-After header (seconds). + httpx_mock.add_response( + method="POST", + url="http://localhost:7878/v1/reservations", + json={"error": "LIMIT_EXCEEDED", "message": "Rate limited", "request_id": "req-rl"}, + status_code=429, + headers={"Retry-After": "3", "X-RateLimit-Reset": "1700000000"}, + ) + + with CyclesClient(config) as client: + response = client.create_reservation({"idempotency_key": "rl-test", "subject": {"tenant": "acme"}}) + + assert response.status == 429 + assert response.retry_after_ms_header == 3000 + assert response.rate_limit_reset == 1700000000 + error = response.get_error_response() + assert error is not None + assert error.error_code is not None + assert error.error_code.value == "LIMIT_EXCEEDED" + def test_dict_body(self, config: CyclesConfig, httpx_mock) -> None: # type: ignore[no-untyped-def] httpx_mock.add_response( method="POST", diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 0acce13..d439f57 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -9,6 +9,7 @@ OverdraftLimitExceededError, ReservationExpiredError, ReservationFinalizedError, + TenantClosedError, ) @@ -44,6 +45,14 @@ def test_debt_outstanding(self) -> None: assert e.is_debt_outstanding() assert not e.is_budget_exceeded() + def test_tenant_closed(self) -> None: + e = TenantClosedError("tenant closed", status=409, error_code="TENANT_CLOSED") + assert isinstance(e, CyclesProtocolError) + assert isinstance(e, CyclesError) + assert e.is_tenant_closed() + assert not e.is_budget_exceeded() + assert not e.is_retryable() + def test_reservation_expired(self) -> None: e = ReservationExpiredError("expired", status=410, error_code="RESERVATION_EXPIRED") assert isinstance(e, CyclesProtocolError) @@ -70,6 +79,12 @@ def test_retryable_unknown(self) -> None: e = CyclesProtocolError("unknown", status=500, error_code="UNKNOWN") assert e.is_retryable() + def test_retryable_limit_exceeded(self) -> None: + # HTTP 429 rate limiting (runtime spec v0.1.25.12) is transient. + e = CyclesProtocolError("rate limited", status=429, error_code="LIMIT_EXCEEDED", retry_after_ms=3000) + assert e.is_retryable() + assert e.retry_after_ms == 3000 + def test_retryable_5xx_without_known_code(self) -> None: e = CyclesProtocolError("server error", status=502) assert e.is_retryable() diff --git a/tests/test_lifecycle.py b/tests/test_lifecycle.py index f70201f..3e446a0 100644 --- a/tests/test_lifecycle.py +++ b/tests/test_lifecycle.py @@ -15,6 +15,7 @@ OverdraftLimitExceededError, ReservationExpiredError, ReservationFinalizedError, + TenantClosedError, ) from runcycles.lifecycle import ( AsyncCyclesLifecycle, @@ -345,6 +346,45 @@ def test_maps_debt_outstanding(self) -> None: exc = _build_protocol_exception("Failed", response) assert isinstance(exc, DebtOutstandingError) + def test_retry_after_header_fallback(self) -> None: + # 429 LIMIT_EXCEEDED (runtime spec v0.1.25.12): the delay arrives in + # the HTTP Retry-After header (seconds); surface it as retry_after_ms + # when the body carries no retry_after_ms field. + response = CyclesResponse.http_error( + 429, "Rate limited", + body={"error": "LIMIT_EXCEEDED", "message": "Rate limited", "request_id": "r6"}, + headers={"retry-after": "3"}, + ) + exc = _build_protocol_exception("Failed", response) + assert exc.error_code == "LIMIT_EXCEEDED" + assert exc.retry_after_ms == 3000 + assert exc.is_retryable() + + def test_retry_after_body_wins_over_header(self) -> None: + response = CyclesResponse.http_error( + 429, "Rate limited", + body={ + "error": "LIMIT_EXCEEDED", + "message": "Rate limited", + "request_id": "r7", + "retry_after_ms": 1500, + }, + headers={"retry-after": "3"}, + ) + exc = _build_protocol_exception("Failed", response) + assert exc.retry_after_ms == 1500 + + def test_maps_tenant_closed(self) -> None: + response = CyclesResponse.http_error( + 409, "Tenant closed", + body={"error": "TENANT_CLOSED", "message": "Tenant closed", "request_id": "r5"}, + ) + exc = _build_protocol_exception("Failed", response) + assert isinstance(exc, TenantClosedError) + assert exc.error_code == "TENANT_CLOSED" + assert exc.is_tenant_closed() + assert not exc.is_retryable() + def test_maps_reservation_expired(self) -> None: response = CyclesResponse.http_error( 410, "Expired", diff --git a/tests/test_models.py b/tests/test_models.py index c8a912f..0bd70a8 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -190,6 +190,17 @@ def test_from_string(self) -> None: assert ErrorCode.from_string("NONSENSE") == ErrorCode.UNKNOWN assert ErrorCode.from_string(None) is None + def test_tenant_closed(self) -> None: + # Runtime spec v0.1.25.13: TENANT_CLOSED added to the ErrorCode enum. + assert ErrorCode.from_string("TENANT_CLOSED") == ErrorCode.TENANT_CLOSED + assert not ErrorCode.TENANT_CLOSED.is_retryable + + def test_limit_exceeded(self) -> None: + # Runtime spec v0.1.25.12: LIMIT_EXCEEDED added to the ErrorCode enum + # for HTTP 429 rate limiting; transient, so retryable. + assert ErrorCode.from_string("LIMIT_EXCEEDED") == ErrorCode.LIMIT_EXCEEDED + assert ErrorCode.LIMIT_EXCEEDED.is_retryable + class TestAmountValidation: def test_rejects_negative_amount(self) -> None: diff --git a/tests/test_response.py b/tests/test_response.py index a39bbf6..3f55a3d 100644 --- a/tests/test_response.py +++ b/tests/test_response.py @@ -69,12 +69,25 @@ def test_http_error_with_headers(self) -> None: assert resp.request_id == "req-456" assert resp.rate_limit_reset == 1700000000 + def test_retry_after_header_seconds_to_ms(self) -> None: + # Runtime spec v0.1.25.12: 429 LIMIT_EXCEEDED carries Retry-After + # in seconds; expose it as milliseconds. + resp = CyclesResponse.http_error(429, "Rate limited", headers={"retry-after": "3"}) + assert resp.retry_after_ms_header == 3000 + + def test_retry_after_header_non_numeric_ignored(self) -> None: + resp = CyclesResponse.http_error( + 429, "Rate limited", headers={"retry-after": "Wed, 21 Oct 2026 07:28:00 GMT"}, + ) + assert resp.retry_after_ms_header is None + def test_missing_headers_return_none(self) -> None: resp = CyclesResponse.success(200, {}) assert resp.request_id is None assert resp.rate_limit_remaining is None assert resp.rate_limit_reset is None assert resp.cycles_tenant is None + assert resp.retry_after_ms_header is None def test_transport_error_no_headers(self) -> None: resp = CyclesResponse.transport_error(ConnectionError("fail"))