feat: failure_class + retryable on failed verifications; typed 503 with honoured Retry-After - #43
Conversation
…th honoured Retry-After Server contract (Lenz #311/#312/#313): a failed verification now says WHY (failure_class, closed set: upstream_unavailable | insufficient_evidence | invalid_input | cancelled | internal) and whether resubmitting helps (retryable, true iff upstream_unavailable) — on the status body and the verification.failed webhook. /assess + /extract answer 503 + Retry-After 90 (code upstream_unavailable) on provider-side exhaustion; /verify sheds with code capacity and Retry-After 90-120. - TaskStatus, VerificationFailed and LenzPipelineError carry both fields; the pipeline error's fix text branches on retryable. - New LenzUpstreamUnavailableError (subclass of LenzAPIError so existing handlers keep catching it) with retry_after, mapped from 503 bodies with code upstream_unavailable/capacity. - 503 retry policy: a stated wait past MAX_RETRY_AFTER_SLEEP (60s) raises immediately with the true retry_after — the 429 rule — instead of burning the 1/2/4s ladder against a server that asked for 90-120s. Waits <= 60s still sleep-and-retry; other 5xx keep the ladder (pinned by test). - _stated_retry_after also reads the body retry_after key (the 503 shapes carry it; 429 carries reset_in_seconds). - Contract fixtures: verify_status_failed.json refreshed to the live body; webhook_payload_failed.json + error_upstream_unavailable_503.json + error_capacity_503.json added and wired into the zero-unknown-fields tests. Byte-identical with the Node SDK's fixture dir (lockstep 2.8.0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 2.8.0 abort decision keyed on `response.status_code` while the error
typing keyed on the body `code`. The two were misaligned, so an ordinary
Cloud Run / CDN / load-balancer 503 — no Lenz code, long maintenance
`Retry-After` — aborted immediately as a bare `LenzAPIError` with the
stated wait discarded, where 2.7.x retried it and often succeeded.
The rule is now stated once, in `UPSTREAM_503_CODES` (errors.py) and
`_aborts_on_long_stated_wait` (client.py):
* 429, any stated wait past the cap -> raise
* 503 typed `upstream_unavailable` / `capacity`,
stated wait past the cap -> raise
(LenzUpstreamUnavailableError, carrying the true retry_after)
* typed 503 within the cap -> sleep + retry
* untyped 503, and every other 5xx -> backoff ladder
Also in this commit:
* Restore the regression pin that had been weakened from 503 to 500 so
the old logic would keep passing; it now pins the untyped-503 case
directly, with the plain-5xx twin kept alongside it. Adds a
typed-503-within-the-cap test.
* `LenzPipelineError.retryable` coerces a non-boolean to None, matching
the Node SDK.
* Export a `FailureClass` Literal alias for exhaustive matching. The
model field stays `str` on purpose — a Literal annotation would turn
a class the server adds later into a hard ValidationError, and every
other field on that model is lax.
* CHANGELOG `### Changed` now describes the code-gated rule, including
that untyped 5xx behaviour is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fixed: the abort keyed on the status, the typing keyed on the body codeReview caught a real regression. The decision is now gated on the typed body code, stated once per SDK
Evidence — the real retry loop against a stubbed transportPython (sleeps recorded, not slept): Same four cases with the old status-gated condition restored — the bug, reproduced: Node (real waits): Byte-identical outcomes across the two SDKs. Also in this commit
Contract fixtures still byte-identical ( |
Lockstep with lenz-io-node 2.8.0 (same contract, byte-identical fixtures).
Server contract (Lenz backend, merged 2026-08-21)
A failed verification now says WHY and whether resubmitting helps:
{"status": "failed", "error": "Pipeline stopped at: research_empty", "failure_reason": "research_empty", "failure_class": "upstream_unavailable", "retryable": true}failure_classis a closed set (upstream_unavailable | insufficient_evidence | invalid_input | cancelled | internal);retryableis true iffupstream_unavailable. Theverification.failedwebhook carries both./assess+/extractanswer 503 + Retry-After 90 (code: upstream_unavailable) on provider-side exhaustion;/verifysheds withcode: capacityand 90–120s.SDK changes
TaskStatus,VerificationFailed,LenzPipelineErrorcarryfailure_class+retryable; the pipeline error'sfixtext branches on it.LenzUpstreamUnavailableError(**subclassesLenzAPIError\)** — existing handlers keep catching it) withretry_after`.retry_after— the 429 rule — instead of burning the 1/2/4s ladder against a server that asked for 90–120s. Waits ≤ 60s still sleep-and-retry; other 5xx keep the ladder (regression-pinned).retry_afterkey.Fixtures
verify_status_failed.jsonrefreshed to the live body;webhook_payload_failed.json,error_upstream_unavailable_503.json,error_capacity_503.jsonadded — all wired into the zero-unknown-fields contract tests.diff -rqagainst the Node repo's fixture dir: identical.Not in this PR
openapi.jsonregen — the main Lenz checkout wasn't verifiably on today's main from this session, so the snapshot is deliberately untouched rather than risking a stale vendor; regen in a follow-up from the backend.2.7.1entry with nov2.7.1tag on origin (unreleased) — worth resolving when taggingv2.8.0.Tests: full suite green (274 collected, 8 staging-smoke skips), ruff + mypy clean.
🤖 Generated with Claude Code