Skip to content

feat: failure_class + retryable on failed verifications; typed 503 with honoured Retry-After - #43

Merged
lenzhq merged 2 commits into
mainfrom
feat/failure-class
Aug 21, 2026
Merged

feat: failure_class + retryable on failed verifications; typed 503 with honoured Retry-After#43
lenzhq merged 2 commits into
mainfrom
feat/failure-class

Conversation

@lenzhq

@lenzhq lenzhq commented Aug 21, 2026

Copy link
Copy Markdown
Owner

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_class is a closed set (upstream_unavailable | insufficient_evidence | invalid_input | cancelled | internal); retryable is true iff upstream_unavailable. The verification.failed webhook carries both. /assess + /extract answer 503 + Retry-After 90 (code: upstream_unavailable) on provider-side exhaustion; /verify sheds with code: capacity and 90–120s.

SDK changes

  • TaskStatus, VerificationFailed, LenzPipelineError carry failure_class + retryable; the pipeline error's fix text branches on it.
  • New LenzUpstreamUnavailableError (**subclasses LenzAPIError\)** — existing handlers keep catching it) with retry_after`.
  • Behaviour change (503 only): a stated wait past 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 (regression-pinned).
  • The stated wait is also read from the 503 body's retry_after key.

Fixtures

verify_status_failed.json refreshed to the live body; webhook_payload_failed.json, error_upstream_unavailable_503.json, error_capacity_503.json added — all wired into the zero-unknown-fields contract tests. diff -rq against the Node repo's fixture dir: identical.

Not in this PR

  • openapi.json regen — 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.
  • Release/tag. Note: the repo has a CHANGELOG 2.7.1 entry with no v2.7.1 tag on origin (unreleased) — worth resolving when tagging v2.8.0.

Tests: full suite green (274 collected, 8 staging-smoke skips), ruff + mypy clean.

🤖 Generated with Claude Code

…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>
@lenzhq

lenzhq commented Aug 21, 2026

Copy link
Copy Markdown
Owner Author

Fixed: the abort keyed on the status, the typing keyed on the body code

Review caught a real regression. map_response_to_error / mapResponseToError decides
LenzUpstreamUnavailableError from the body code, but the retry loop decided
abort-vs-retry from the status number. Consequence: an ordinary Cloud Run / CDN /
load-balancer 503 — no Lenz code, long maintenance Retry-After — aborted on the
first call as a bare LenzAPIError with the stated wait thrown away. On main that
request retried and often succeeded.

The decision is now gated on the typed body code, stated once per SDK
(UPSTREAM_503_CODES + _aborts_on_long_stated_wait / abortsOnLongStatedWait):

response behaviour
429, stated wait > 60s raise/throw with the true wait (unchanged)
503 typed upstream_unavailable/capacity, wait > 60s raise/throw LenzUpstreamUnavailableError with the true wait
typed 503, wait ≤ 60s sleep it, retry (unchanged)
untyped 503 backoff ladder — repaired
every other 5xx backoff ladder (unchanged)

Evidence — the real retry loop against a stubbed transport

Python (sleeps recorded, not slept):

(a) untyped 503 + Retry-After 600      -> RETURNED OK (retried, succeeded)  http_calls=2  slept=[1.0]
(b) typed capacity 503 + Retry-After 90 -> RAISED LenzUpstreamUnavailableError  http_calls=1  slept=[]  retry_after=90  code='capacity'
(c) typed 503 + Retry-After 30          -> RETURNED OK (retried, succeeded)  http_calls=2  slept=[30]
(d) plain 500 + Retry-After 600         -> RETURNED OK (retried, succeeded)  http_calls=2  slept=[1.0]

Same four cases with the old status-gated condition restored — the bug, reproduced:

(a) untyped 503 + Retry-After 600      -> RAISED LenzAPIError  http_calls=1  slept=[]  retry_after=<absent>  code=''

Node (real waits):

(a) untyped 503 + Retry-After 600      -> RESOLVED OK (retried, succeeded)  http_calls=2  waited=1.0s
(b) typed capacity 503 + Retry-After 90 -> THREW LenzUpstreamUnavailableError  http_calls=1  waited=0.0s  retryAfter=90  code="capacity"
(c) typed 503 + Retry-After 30          -> RESOLVED OK (retried, succeeded)  http_calls=2  waited=30.0s
(d) plain 500 + Retry-After 600         -> RESOLVED OK (retried, succeeded)  http_calls=2  waited=1.0s

Byte-identical outcomes across the two SDKs.

Also in this commit

  • Restored the regression pin that had been weakened from 503 → 500 so the old logic
    would keep passing. It now pins the untyped-503 case directly, with the plain-5xx
    twin kept beside it, plus a new typed-503-within-the-cap test.
  • The stale block comment above the condition (it described 5xx aborting as "a silent
    change to a status class this release never set out to touch") now describes the
    code-gated rule.
  • CHANGELOG ### Changed rewritten to the actual rule, calling out explicitly that
    untyped 5xx behaviour is unchanged.
  • Parity sweep from the same review: retryable non-boolean → None in Python
    (Node already did); Node body fallback falls through on an EMPTY reset_in_seconds
    (not only null/undefined) to match Python; statedRetryAfterSeconds docstring now
    mentions retry_after; VerificationFailed.failureClass typed as the exported
    FailureClass union; contract KEYSETS gained VerificationListItem.language; the
    "5xx honors a short Retry-After" pin uses 5 instead of the meaningless 0.
  • Python failure_class: exported a FailureClass Literal alias for exhaustive
    matching but kept the field annotated str. Annotating the field would make an
    unknown class the server adds later a hard ValidationError, which is the opposite
    of the lax-model contract every other field on TaskStatus follows.

Contract fixtures still byte-identical (diff -rq empty). Node: lint + type + tests
green. Python: everything green except the 8 pre-existing tests/test_cli.py Rich-ANSI
failures that also fail on origin/main.

@lenzhq
lenzhq merged commit b22b79f into main Aug 21, 2026
9 checks passed
@lenzhq
lenzhq deleted the feat/failure-class branch August 21, 2026 21:39
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