Skip to content

feat: surface out-of-credits as a quota error, not an auth failure (2.7.0) - #37

Merged
lenzhq merged 1 commit into
mainfrom
quota-error-contract
Aug 10, 2026
Merged

feat: surface out-of-credits as a quota error, not an auth failure (2.7.0)#37
lenzhq merged 1 commit into
mainfrom
quota-error-contract

Conversation

@lenzhq

@lenzhq lenzhq commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Ships 2.7.0. Released in lockstep with lenz-io 2.7.0 for Node — the two SDKs are a stated parity invariant.

A developer who ran out of credits was told their API key was broken. The server returned 403, which maps to LenzAuthError"This key doesn't have access to that resource", pointing at /docs/auth. LenzQuotaExceededError existed but was keyed to 402, a status the server had never sent, so it was never constructed.

The server now sends 402 (lenzhq/Lenz#223), which this SDK already mapped correctly — so the headline fix lands even on 2.6.0. What's added here is the detail that was missing everywhere.

Added

  • LenzError.code — the server's machine-readable error code, on the base class so 402, 403 and 429 all carry it.
  • LenzQuotaExceededError.upgrade_url / .remaining / .resets_at / .requested. remaining is nullable on purpose: None means the server didn't report a balance, 0 means it reported an empty one. The old credits_remaining: int = 0 couldn't tell those apart, which is exactly why it was useless — and the server omits these keys rather than sending null, so the distinction survives the wire.
  • LenzRateLimitError.limit / .reset_in_seconds / .upgrade_url. The server sends upgrade_url on 429 too — someone hitting the daily /extract cap also wants to know a paid plan raises it.
  • MAX_RETRY_AFTER_SLEEP exported from the package root.

Changed

  • Retry-After is clamped at 60s. The /extract daily cap sends seconds-until-UTC-midnight, so the old ladder could sleep most of a day — three times over. Past the clamp the two retryable statuses now differ: 429 raises immediately with the true retry_after (schedule it, don't sit in it); 5xx falls back to backoff and keeps retrying, since the server being down isn't the caller's fault and 2.6.0 honored Retry-After there.
  • retry_after now resolves from reset_in_seconds — the key the server actually sends. The previously-read retry_after body key was an SDK invention. Each candidate is coerced before being accepted, so a truthy-but-unparseable header (an HTTP-date is legal per RFC 7231) can no longer win the chain, collapse to 0, and tell the caller to retry instantly against a server that just throttled them.

Deprecated

  • credits_remaining → use remaining. Now a property that reads and writes through to remaining, so constructor kwargs and fixtures keep working — a read-only property here would break LenzQuotaExceededError(credits_remaining=0), which LenzError.__init__'s **extra splat explicitly invites, and that's not acceptable in a MINOR.

Fixed (CLI)

--json reports "no_credits" instead of "unauthorized" for an out-of-credits run, and friendly_text no longer tells someone with a perfectly good key to run lenz login. The payload gains upgrade_url.

Breaking-ish

LenzQuotaExceededError does not inherit from LenzAuthError. If you were catching the auth error to handle an empty balance, catch the quota error instead. This reaches you on every SDK version the moment the server ships — it isn't gated on upgrading.

Verification

pytest 248 passed · ruff check clean · mypy clean — all via the repo's own pre-commit gate.

New coverage for the nullable-vs-zero rule, the deprecated-alias write path, the clamp boundary, the body-fallback branch, and 402/429 fixtures added to the cross-SDK contract harness (which had no error-envelope coverage at all — the same fixture files the Node SDK validates).

Note

There is deliberately no "403 + quota code also means quota" fallback. It was considered and cut: the only thing it covers is a server rollback, and the compat that actually matters flows the other way — 2.6.0 already handles 402 correctly.

🤖 Generated with Claude Code

https://claude.ai/code/session_01UwCqqYgF6h1YxBg4J1a3Ns

A developer who ran out of credits was told their API key was broken:
the server returned 403, which mapped to LenzAuthError ("This key
doesn't have access to that resource") pointing at /docs/auth.
LenzQuotaExceededError existed but was keyed to 402 — a status the
server had never sent — so it was never constructed.

The API now sends 402, which this SDK already mapped correctly, so the
headline fix lands on upgrade. What's added here is the detail that was
missing everywhere: `code` on the base error, and `upgrade_url`,
`remaining`, `resets_at`, `requested` on the quota error. `remaining` is
nullable on purpose — the old `credits_remaining: int = 0` could not
tell "0 left" from "server said nothing", which is why it was useless.

Also clamps Retry-After at 60s. The /extract daily cap sends
seconds-until-UTC-midnight, so the old ladder could sleep most of a day,
three times over. Past the clamp a 429 raises with the true retry_after
(schedule it); a 5xx falls back to backoff and keeps retrying, since the
server being down is not the caller's fault.

The CLI stops reporting an out-of-credits run as "unauthorized" and no
longer tells someone with a working key to run `lenz login`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwCqqYgF6h1YxBg4J1a3Ns
@lenzhq
lenzhq requested a review from paveljor as a code owner August 10, 2026 10:50
@lenzhq
lenzhq merged commit 560efde into main Aug 10, 2026
9 checks passed
@lenzhq
lenzhq deleted the quota-error-contract branch August 10, 2026 11:09
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