diff --git a/CHANGELOG.md b/CHANGELOG.md index 943e7f9..4f4d2d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ All notable changes to this SDK are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows [SemVer](https://semver.org/). +## [2.8.0] - 2026-08-21 + +The server now says WHY a verification failed and states honest waits when it +is overloaded; the SDK types both. + +### Added +- **`failure_class` + `retryable` on failed verifications.** `TaskStatus`, the + `VerificationFailed` webhook event, and `LenzPipelineError` all carry + `failure_class` (closed set: `upstream_unavailable` | `insufficient_evidence` + | `invalid_input` | `cancelled` | `internal`) and `retryable` (true iff + `upstream_unavailable` — resubmitting the same claim is the right move). + Older servers omit both; the fields default rather than break. The closed + set is also exported as the `FailureClass` `Literal` alias + (`from lenz_io import FailureClass`) for exhaustive matching — the model + field itself stays `str` so a class the server adds later can't turn into a + `ValidationError`. +- **`LenzUpstreamUnavailableError`** — a `LenzAPIError` subclass for 503s with + `code` `upstream_unavailable` (model/search providers exhausted; the request + was not charged) or `capacity` (submission shed at the door; nothing was + accepted). Carries `retry_after`. + +### Changed +- **A 503 that Lenz itself typed — body `code` `upstream_unavailable` or + `capacity` — and that asks for more than 60s now raises immediately**, as + `LenzUpstreamUnavailableError` carrying the true `retry_after`, instead of + silently burning the 1s/2s/4s backoff ladder against a server that asked + for 90-120s. That is the same rule 429 has always had. The decision is + gated on the body code, not on the status number: + - typed 503, stated wait ≤ 60s → still slept through and retried (unchanged); + - **untyped 503** — an ordinary proxy / load-balancer / maintenance + response with no Lenz `code` — → **backoff ladder, exactly as before**, + however long a `Retry-After` it states; + - every other 5xx → backoff ladder, unchanged. + + If you relied on long-stated-wait typed 503s being retried blindly, catch + `LenzUpstreamUnavailableError` (existing `except LenzAPIError` handlers + keep catching it). +- The stated wait is now also read from the 503 body's `retry_after` key + (previously only the `Retry-After` header and the 429 body's + `reset_in_seconds`), so a proxy that strips headers can't demote an honest + wait to blind backoff. + +### Fixed +- `LenzPipelineError.retryable` now coerces a non-boolean server value to + `None` instead of passing it through (parity with the Node SDK). +- Contract fixtures refreshed to the live failed-status body; added the + `verification.failed` webhook payload and both 503 envelopes (shared + byte-identically with the Node SDK, as ever). + ## [2.7.1] - 2026-08-15 ### Fixed diff --git a/README.md b/README.md index c1c29f2..a9e2a09 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Every claim-shaped response shares these fields at top level: ### Webhooks ```python -from lenz_io import LenzWebhooks, VerificationCompleted, VerificationNeedsInput +from lenz_io import LenzWebhooks, VerificationCompleted, VerificationFailed, VerificationNeedsInput webhooks = LenzWebhooks(secret="whsec_...") @@ -201,6 +201,13 @@ if isinstance(event, VerificationCompleted): elif isinstance(event, VerificationNeedsInput): tid, ni = event.task_id, event.needs_input ... +elif isinstance(event, VerificationFailed): + # event.error is WHERE the pipeline stopped; event.failure_class is WHY + # (closed set) and event.retryable tells you what to do about it. + if event.retryable: + resubmit_later(event.task_id) # transient provider outage + else: + log_permanent_failure(event.task_id, event.error) ``` If you're on Python 3.10+ a `match` statement reads even cleaner — events are @@ -223,6 +230,7 @@ from lenz_io import ( LenzAuthError, LenzQuotaExceededError, LenzRateLimitError, + LenzUpstreamUnavailableError, LenzValidationError, ) @@ -248,8 +256,21 @@ except LenzRateLimitError as exc: except LenzValidationError as exc: for field_err in exc.errors: print(field_err["loc"], field_err["msg"]) +except LenzUpstreamUnavailableError as exc: + # HTTP 503, code "upstream_unavailable" (model/search providers + # exhausted) or "capacity" (submissions shed at the door). Nothing was + # charged. Waits up to 60s are already slept through by the automatic + # retry ladder; reaching here means the server stated a longer one. + schedule_retry_in(exc.retry_after) # typically 90-120s ``` +A failed *verification* (as opposed to a failed HTTP call) raises +`LenzPipelineError` from `verify_and_wait` / `wait`. Since 2.8.0 it carries +`failure_class` (closed set: `upstream_unavailable` | `insufficient_evidence` +| `invalid_input` | `cancelled` | `internal`) and `retryable` — `True` means +a transient provider-side exhaustion where resubmitting the same claim is the +right move; older servers leave it `None`. + `LenzQuotaExceededError` is a **sibling** of `LenzAuthError`, not a subclass — "fix your key" and "top up your account" are different actions. So if you were catching `LenzAuthError` to handle an empty balance, that branch stops firing; diff --git a/src/lenz_io/__init__.py b/src/lenz_io/__init__.py index 819d547..3cd1d6d 100644 --- a/src/lenz_io/__init__.py +++ b/src/lenz_io/__init__.py @@ -46,6 +46,7 @@ LenzQuotaExceededError, LenzRateLimitError, LenzTimeoutError, + LenzUpstreamUnavailableError, LenzValidationError, LenzWebhookSignatureError, ) @@ -64,6 +65,7 @@ EntityRef, ExtractedClaims, ExtractedEntity, + FailureClass, LibraryItem, LibraryList, RelatedVerifications, @@ -105,6 +107,7 @@ "EntityRef", "ExtractedClaims", "ExtractedEntity", + "FailureClass", "Lenz", "LenzAPIError", "LenzAuthError", @@ -114,6 +117,7 @@ "LenzQuotaExceededError", "LenzRateLimitError", "LenzTimeoutError", + "LenzUpstreamUnavailableError", "LenzValidationError", "LenzWebhookSignatureError", "LenzWebhooks", diff --git a/src/lenz_io/client.py b/src/lenz_io/client.py index 475db1c..ee2d8cd 100644 --- a/src/lenz_io/client.py +++ b/src/lenz_io/client.py @@ -70,6 +70,7 @@ from . import __version__ from .errors import ( MAX_RETRY_AFTER_SLEEP, + UPSTREAM_503_CODES, LenzAPIError, LenzError, LenzNeedsInputError, @@ -648,13 +649,21 @@ def _verification_from_terminal(self, status: TaskStatus, task_id: str) -> Verif # failed. Server sends the diagnostic under ``error``; fall back to the # legacy fields for resilience. detail = status.error or status.failure_detail or status.failure_reason or "unknown" + if status.retryable: + fix = "Transient provider outage — retry the same request after a short wait." + else: + fix = "Retry with a different claim, or check status.error for the diagnostic." raise LenzPipelineError( message=f"Pipeline failed: {detail}", cause=detail, - fix="Retry with a different claim, or check status.error for the diagnostic.", + fix=fix, doc_url="https://lenz.io/docs/errors", task_id=task_id, failure_reason=status.failure_reason, + failure_class=status.failure_class, + # Coerce like the Node SDK: only a real boolean is a retry signal; + # anything else (a stringy "true", a future enum) reads as unknown. + retryable=status.retryable if isinstance(status.retryable, bool) else None, ) # ── account ── @@ -802,22 +811,32 @@ def _request( # Error path. Retry on 5xx and 429; otherwise raise immediately. # # A stated wait is honored only up to MAX_RETRY_AFTER_SLEEP. Past - # that the two statuses part ways, because the right answer differs: + # that, whether we abort or keep retrying is decided by the typed + # body ``code`` — NOT by the status number: # # * 429 — raise. The /extract daily cap sends # seconds-until-UTC-midnight, so sleeping it blocks the call for # most of a day, three times over. The caller gets the true # retry_after and can schedule the work. - # * 5xx — keep retrying on our own backoff. The server is down, - # not rate-limiting us; a maintenance-window Retry-After of an - # hour shouldn't become an hour-long sleep, but it also - # shouldn't abort a request our backoff might still satisfy. + # * 503 carrying a Lenz code in UPSTREAM_503_CODES + # (``upstream_unavailable`` / ``capacity``) — raise, same + # reasoning. These are the server's own shed/exhaustion + # responses; they state an honest 90-120s and burning the + # 1/2/4s ladder against them is the opposite of what the header + # asks (map_response_to_error types them + # LenzUpstreamUnavailableError, carrying the true retry_after). + # * every other 5xx, including an UNTYPED 503 — keep retrying on + # our own backoff. A Cloud Run / CDN / load-balancer + # maintenance-or-overload 503 states a long wait and carries no + # Lenz code; the server is down, not pacing us, so an hour-long + # Retry-After must become backoff — not an hour-long sleep, and + # not an abort of a request our ladder might still satisfy. if attempt < self._max_retries and (response.status_code >= 500 or response.status_code == 429): stated = _stated_retry_after(response) if stated is not None and stated <= MAX_RETRY_AFTER_SLEEP: time.sleep(stated) continue - if stated is None or response.status_code >= 500: + if stated is None or not _aborts_on_long_stated_wait(response): time.sleep(_retry_sleep(attempt)) continue @@ -837,9 +856,11 @@ def _stated_retry_after(response: httpx.Response) -> int | None: """Seconds the server says to wait, or None if it didn't say. Reads the ``Retry-After`` header first, then the body's - ``reset_in_seconds``. Returns None (rather than 0) on an absent or - unparseable value so the caller can tell "server stated no wait" apart - from "server said wait 0 seconds" and fall back to its own backoff. + ``reset_in_seconds`` (429 shapes), then the body's ``retry_after`` + (the 503 shapes carry the wait under that key). Returns None (rather + than 0) on an absent or unparseable value so the caller can tell + "server stated no wait" apart from "server said wait 0 seconds" and + fall back to its own backoff. """ raw = response.headers.get("Retry-After") if raw is None or str(raw).strip() == "": @@ -851,6 +872,8 @@ def _stated_retry_after(response: httpx.Response) -> int | None: if not isinstance(body, dict): return None raw = body.get("reset_in_seconds") + if raw is None or str(raw).strip() == "": + raw = body.get("retry_after") if raw is None or str(raw).strip() == "": return None try: @@ -862,6 +885,35 @@ def _stated_retry_after(response: httpx.Response) -> int | None: return None +def _body_error_code(response: httpx.Response) -> str: + """The server's machine-readable ``code`` from the body, or ``""``. + + Reads it exactly the way ``map_response_to_error`` does — string-typed + only, so a malformed ``code: 42`` reads as ``""`` rather than ``"42"`` + and nothing branches on a value the server never meant as a code. + """ + try: + body = response.json() + except Exception: + return "" + if not isinstance(body, dict): + return "" + code = body.get("code") + return code if isinstance(code, str) else "" + + +def _aborts_on_long_stated_wait(response: httpx.Response) -> bool: + """Whether a stated wait past the cap should abort instead of back off. + + True for 429 (always) and for a 503 the server typed as its own + shed/exhaustion response. An untyped 503 — the ordinary proxy / + maintenance shape — is deliberately False: it keeps the ladder. + """ + if response.status_code == 429: + return True + return response.status_code == 503 and _body_error_code(response) in UPSTREAM_503_CODES + + def _retry_sleep(attempt: int) -> float: if attempt < len(RETRY_BACKOFF): return RETRY_BACKOFF[attempt] diff --git a/src/lenz_io/errors.py b/src/lenz_io/errors.py index d8dfb59..a09eae9 100644 --- a/src/lenz_io/errors.py +++ b/src/lenz_io/errors.py @@ -190,6 +190,24 @@ class LenzAPIError(LenzError): """500 / 502 / 503 / 504 / catch-all for unexpected server errors.""" +class LenzUpstreamUnavailableError(LenzAPIError): + """503 with ``code`` ``upstream_unavailable`` or ``capacity``. + + The server is telling you the truth about a *transient* condition: its + model/search providers are exhausted (``upstream_unavailable`` — nothing + was charged, the same request succeeds once they recover) or the pipeline + is at capacity (``capacity`` — nothing was accepted or charged). Retry the + SAME request after ``retry_after`` seconds. + + Subclasses :class:`LenzAPIError`, so existing ``except LenzAPIError`` + handlers keep catching it. Waits up to ``MAX_RETRY_AFTER_SLEEP`` are + already slept through by the automatic retry ladder — seeing this raised + means the stated wait was longer, and ``retry_after`` carries it. + """ + + retry_after: int | None = None + + class LenzTimeoutError(LenzError): """``verify_and_wait`` exceeded the configured timeout. @@ -212,10 +230,19 @@ class LenzNeedsInputError(LenzError): class LenzPipelineError(LenzError): - """``verify_and_wait`` saw a terminal ``failed`` state from the pipeline.""" + """``verify_and_wait`` saw a terminal ``failed`` state from the pipeline. + + ``failure_class`` says WHY (closed set: ``upstream_unavailable`` | + ``insufficient_evidence`` | ``invalid_input`` | ``cancelled`` | + ``internal``); ``retryable`` is the derived signal — ``True`` means a + transient provider-side exhaustion where resubmitting the same claim is + the right move. ``None`` when an older server didn't say. + """ task_id: str = "" failure_reason: str = "" + failure_class: str = "" + retryable: bool | None = None class LenzWebhookSignatureError(LenzError): @@ -246,6 +273,16 @@ class LenzWebhookSignatureError(LenzError): # immediately with the true retry_after so the caller can schedule the work. MAX_RETRY_AFTER_SLEEP = 60 +# The body ``code`` values the server sends on a 503 it produced deliberately: +# providers exhausted mid-pipeline, or a submission shed at the door. Both map +# to LenzUpstreamUnavailableError and both state an honest wait. +# +# The retry ladder in ``client.py`` keys its immediate-abort decision on THIS, +# not on the status number: an ordinary Cloud Run / CDN / load-balancer 503 +# carries no Lenz code, states a maintenance-window wait, and must keep being +# retried exactly as it was before 2.8.0. +UPSTREAM_503_CODES = ("upstream_unavailable", "capacity") + _STATUS_MAP: dict[int, tuple[type[LenzError], str, str]] = { 401: ( LenzAuthError, @@ -309,6 +346,12 @@ def map_response_to_error( if status_code in _STATUS_MAP: cls, default_msg, doc_url = _STATUS_MAP[status_code] + elif status_code == 503 and code in UPSTREAM_503_CODES: + cls, default_msg, doc_url = ( + LenzUpstreamUnavailableError, + "Service temporarily unavailable", + f"{_DOCS_BASE}/errors#unavailable", + ) elif 500 <= status_code < 600: cls, default_msg, doc_url = LenzAPIError, "Server error", f"{_DOCS_BASE}/errors" else: @@ -328,6 +371,14 @@ def map_response_to_error( # Class-specific enrichment from the response body. Each is set on the # instance so callers can access via the documented attribute name. + if isinstance(err, LenzUpstreamUnavailableError): + # Body ``retry_after`` first (both 503 shapes carry it), header as + # the fallback for any proxy that strips the body. + stated = _opt_int(parsed.get("retry_after")) + if stated is None: + stated = _opt_int(headers.get("Retry-After") or headers.get("retry-after")) + err.retry_after = stated + if isinstance(err, LenzQuotaExceededError): # String-typed only. `str(...)` on a malformed dict would render # "{'a': 1}" and friendly_text would show that to a user as a URL. diff --git a/src/lenz_io/models.py b/src/lenz_io/models.py index 7675f5b..81677a4 100644 --- a/src/lenz_io/models.py +++ b/src/lenz_io/models.py @@ -35,6 +35,23 @@ class _Lax(BaseModel): model_config = ConfigDict(extra="allow") +#: Closed set of failure causes on a ``failed`` verification, mirroring the +#: Node SDK's ``FailureClass`` union. Exported for callers who want exhaustive +#: matching: +#: +#: from lenz_io import FailureClass +#: +#: The model fields themselves stay ``str`` — the SDK must not reject a class +#: the server adds after this release was cut. +FailureClass = Literal[ + "upstream_unavailable", + "insufficient_evidence", + "invalid_input", + "cancelled", + "internal", +] + + class Source(_Lax): """A single citation backing a verification.""" @@ -300,6 +317,14 @@ class TaskStatus(_Lax): error: str = "" failure_reason: str = "" failure_detail: str = "" + # WHY it failed — the closed set is ``FailureClass`` (import it for + # exhaustive matching). The annotation stays ``str`` on purpose: a + # ``Literal`` here would make an unknown class the server adds later a + # hard ValidationError, and every other field on this model is lax. + # Rows predating 2026-08 omit this and ``retryable`` (the derived retry + # signal — true iff ``upstream_unavailable``). + failure_class: str = "" + retryable: bool | None = None class BatchItemResult(_Lax): @@ -434,6 +459,7 @@ class AskReply(_Lax): "EntityRef", "ExtractedClaims", "ExtractedEntity", + "FailureClass", "LibraryItem", "LibraryList", "RelatedVerifications", diff --git a/src/lenz_io/webhooks.py b/src/lenz_io/webhooks.py index 27f4045..ba7a442 100644 --- a/src/lenz_io/webhooks.py +++ b/src/lenz_io/webhooks.py @@ -111,9 +111,17 @@ class VerificationCompleted(WebhookEvent): @dataclass class VerificationFailed(WebhookEvent): - """``event=verification.failed`` — the pipeline terminated without a verdict.""" + """``event=verification.failed`` — the pipeline terminated without a verdict. + + ``failure_class`` is WHY (closed set — ``upstream_unavailable`` | + ``insufficient_evidence`` | ``invalid_input`` | ``cancelled`` | + ``internal``); ``retryable`` is the derived signal (true iff + ``upstream_unavailable``). Both default when an older server omits them. + """ error: str = "" + failure_class: str = "" + retryable: bool | None = None @dataclass @@ -160,6 +168,8 @@ def _build_event(payload: dict[str, Any]) -> WebhookEvent: status=status, raw=payload, error=str(payload.get("error") or ""), + failure_class=str(payload.get("failure_class") or ""), + retryable=payload.get("retryable") if isinstance(payload.get("retryable"), bool) else None, ) if event == "verification.needs_input": return VerificationNeedsInput( diff --git a/tests/fixtures/contract/error_capacity_503.json b/tests/fixtures/contract/error_capacity_503.json new file mode 100644 index 0000000..f24415f --- /dev/null +++ b/tests/fixtures/contract/error_capacity_503.json @@ -0,0 +1,6 @@ +{ + "detail": "Lenz is at capacity right now — please resubmit after the stated wait. Nothing was charged.", + "code": "capacity", + "retry_after": 105, + "doc_url": "https://lenz.io/docs/errors#unavailable" +} diff --git a/tests/fixtures/contract/error_upstream_unavailable_503.json b/tests/fixtures/contract/error_upstream_unavailable_503.json new file mode 100644 index 0000000..a611d89 --- /dev/null +++ b/tests/fixtures/contract/error_upstream_unavailable_503.json @@ -0,0 +1,6 @@ +{ + "detail": "Our model providers are temporarily unavailable. Please retry shortly.", + "code": "upstream_unavailable", + "retry_after": 90, + "doc_url": "https://lenz.io/docs/errors#unavailable" +} diff --git a/tests/fixtures/contract/verify_status_failed.json b/tests/fixtures/contract/verify_status_failed.json index e4eb4f3..2d0dbae 100644 --- a/tests/fixtures/contract/verify_status_failed.json +++ b/tests/fixtures/contract/verify_status_failed.json @@ -1,4 +1,7 @@ { "status": "failed", - "error": "Pipeline stopped at: research_empty" + "error": "Pipeline stopped at: research_empty", + "failure_reason": "research_empty", + "failure_class": "upstream_unavailable", + "retryable": true } diff --git a/tests/fixtures/contract/webhook_payload_failed.json b/tests/fixtures/contract/webhook_payload_failed.json new file mode 100644 index 0000000..ee09bbd --- /dev/null +++ b/tests/fixtures/contract/webhook_payload_failed.json @@ -0,0 +1,14 @@ +{ + "event": "verification.failed", + "verification_id": null, + "task_id": "tsk_abc123", + "batch_id": null, + "status": "failed", + "result": null, + "needs_input": null, + "error": "conclusion_failed", + "failure_class": "upstream_unavailable", + "retryable": true, + "attempt": 1, + "delivered_at": "2026-08-21T12:01:00Z" +} diff --git a/tests/test_client.py b/tests/test_client.py index 49315ed..8d89f7d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -973,16 +973,108 @@ def test_5xx_still_honors_a_short_retry_after(self, client, monkeypatch): client.usage() assert slept == [5] - def test_5xx_with_a_long_retry_after_keeps_retrying_on_backoff(self, client, monkeypatch): - """Unlike 429, a 5xx is not the caller's fault and our own backoff may - still satisfy it — so a maintenance-window hour becomes backoff, not - an abort and not an hour-long sleep.""" + def test_untyped_503_with_a_long_retry_after_keeps_retrying_on_backoff(self, client, monkeypatch): + """The regression pin for the code-gated rule. A 503 with NO Lenz + ``code`` is an ordinary Cloud Run / CDN / load-balancer + maintenance-or-overload response — the server is down, not pacing us, + and our own backoff may still satisfy it. A maintenance-window hour + becomes backoff: not an abort, and not an hour-long sleep. + + Gating this on the status number instead of the body code aborts here + with a bare LenzAPIError and throws the stated wait away.""" slept = [] monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: slept.append(s)) with respx.mock(base_url=DEFAULT_BASE) as r: route = r.get("/me/usage") route.side_effect = [ - httpx.Response(503, json={"detail": "down"}, headers={"Retry-After": "3600"}), + httpx.Response(503, json={"detail": "maintenance"}, headers={"Retry-After": "600"}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [RETRY_BACKOFF[0]] + + def test_plain_5xx_with_a_long_retry_after_keeps_retrying_on_backoff(self, client, monkeypatch): + """Same rule for every other 5xx — untouched by 2.8.0.""" + slept = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: slept.append(s)) + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.get("/me/usage") + route.side_effect = [ + httpx.Response(500, json={"detail": "down"}, headers={"Retry-After": "3600"}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [RETRY_BACKOFF[0]] + + def test_typed_503_with_a_long_retry_after_raises_immediately_with_the_true_wait(self, client, monkeypatch): + """2.8.0: the server's own shed/exhaustion 503s carry ``code`` + ``capacity`` / ``upstream_unavailable`` and state 90-120s waits. + Burning the 1/2/4s ladder against them is the opposite of what the + header asks — raise at once with the wait, exactly like 429.""" + from lenz_io import LenzAPIError, LenzUpstreamUnavailableError + + slept = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: slept.append(s)) + with respx.mock(base_url=DEFAULT_BASE) as r: + r.get("/me/usage").respond( + 503, + json={"detail": "at capacity", "code": "capacity", "retry_after": 90}, + headers={"Retry-After": "90"}, + ) + with pytest.raises(LenzUpstreamUnavailableError) as exc_info: + client.usage() + assert slept == [] + assert exc_info.value.retry_after == 90 + assert exc_info.value.code == "capacity" + assert isinstance(exc_info.value, LenzAPIError) # existing handlers still catch it + + def test_503_reads_the_wait_from_the_body_retry_after_key(self, client, monkeypatch): + """The 503 bodies carry ``retry_after`` (429 carries ``reset_in_seconds``); + a proxy that strips the header must not demote the stated wait to the + blind ladder.""" + from lenz_io import LenzUpstreamUnavailableError + + slept = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: slept.append(s)) + with respx.mock(base_url=DEFAULT_BASE) as r: + r.get("/me/usage").respond( + 503, + json={"detail": "providers down", "code": "upstream_unavailable", "retry_after": 90}, + ) + with pytest.raises(LenzUpstreamUnavailableError) as exc_info: + client.usage() + assert slept == [] + assert exc_info.value.retry_after == 90 + + def test_typed_503_within_the_cap_is_slept_and_retried(self, client, monkeypatch): + """The abort is gated on the stated wait as well as the code: a typed + 503 asking for 30s is inside MAX_RETRY_AFTER_SLEEP, so we wait it out + and retry rather than handing the caller an error it could have + avoided.""" + slept = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: slept.append(s)) + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.get("/me/usage") + route.side_effect = [ + httpx.Response( + 503, + json={"detail": "at capacity", "code": "capacity", "retry_after": 30}, + headers={"Retry-After": "30"}, + ), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [30] + + def test_plain_503_without_a_stated_wait_keeps_the_ladder(self, client, monkeypatch): + """Regression pin: a bare 503 (no header, no body key) is still 'server + down' and keeps the backoff ladder as before 2.8.0.""" + slept = [] + monkeypatch.setattr("lenz_io.client.time.sleep", lambda s: slept.append(s)) + with respx.mock(base_url=DEFAULT_BASE) as r: + route = r.get("/me/usage") + route.side_effect = [ + httpx.Response(503, json={"detail": "down"}), httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), ] client.usage() diff --git a/tests/test_contract.py b/tests/test_contract.py index edc3b9b..c9584b8 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -225,3 +225,53 @@ def test_rate_limit_429_envelope_maps_every_field(): handled = {"detail", "code", "limit", "reset_in_seconds", "upgrade_url", "doc_url"} assert not set(fixture) - handled + + +@pytest.mark.parametrize( + "fixture_name,expected_code,expected_retry_after", + [ + ("error_upstream_unavailable_503.json", "upstream_unavailable", 90), + ("error_capacity_503.json", "capacity", 105), + ], +) +def test_unavailable_503_envelope_maps_every_field(fixture_name, expected_code, expected_retry_after): + """The two 503 shapes (provider exhaustion on the sync endpoints; the + admission-control shed on /verify) map to LenzUpstreamUnavailableError — + a LenzAPIError subclass so existing handlers keep catching it.""" + from lenz_io.errors import LenzAPIError, LenzUpstreamUnavailableError, map_response_to_error + + fixture = _load(fixture_name) + err = map_response_to_error(503, json.dumps(fixture), {}) + + assert isinstance(err, LenzUpstreamUnavailableError) + assert isinstance(err, LenzAPIError) + assert err.message == fixture["detail"] + assert err.code == expected_code + assert err.retry_after == expected_retry_after + assert err.body == fixture + + handled = {"detail", "code", "retry_after", "doc_url"} + assert not set(fixture) - handled + + +def test_webhook_payload_failed_maps_every_field(): + """Every key of the failed-event payload is consumed by a typed + VerificationFailed attribute (or is one of the always-present-but-null + payload slots that belong to the other events).""" + import dataclasses + + from lenz_io.webhooks import VerificationFailed, _build_event + + payload = _load("webhook_payload_failed.json") + event = _build_event(payload) + assert isinstance(event, VerificationFailed) + assert event.error == payload["error"] + assert event.failure_class == payload["failure_class"] + assert event.retryable is payload["retryable"] + assert event.status == "failed" + + typed = {f.name for f in dataclasses.fields(VerificationFailed)} + # `result` / `needs_input` ride as null on the failed event — the wire + # payload has one field set per event kind. + handled = typed | {"result", "needs_input"} + assert not set(payload) - handled