feat: surface out-of-credits as a quota error, not an auth failure (2.7.0) - #37
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ships 2.7.0. Released in lockstep with
lenz-io2.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.LenzQuotaExceededErrorexisted 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.remainingis nullable on purpose:Nonemeans the server didn't report a balance,0means it reported an empty one. The oldcredits_remaining: int = 0couldn't tell those apart, which is exactly why it was useless — and the server omits these keys rather than sendingnull, so the distinction survives the wire.LenzRateLimitError.limit/.reset_in_seconds/.upgrade_url. The server sendsupgrade_urlon 429 too — someone hitting the daily/extractcap also wants to know a paid plan raises it.MAX_RETRY_AFTER_SLEEPexported from the package root.Changed
Retry-Afteris clamped at 60s. The/extractdaily 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 trueretry_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 honoredRetry-Afterthere.retry_afternow resolves fromreset_in_seconds— the key the server actually sends. The previously-readretry_afterbody 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 to0, and tell the caller to retry instantly against a server that just throttled them.Deprecated
credits_remaining→ useremaining. Now a property that reads and writes through toremaining, so constructor kwargs and fixtures keep working — a read-only property here would breakLenzQuotaExceededError(credits_remaining=0), whichLenzError.__init__'s**extrasplat explicitly invites, and that's not acceptable in a MINOR.Fixed (CLI)
--jsonreports"no_credits"instead of"unauthorized"for an out-of-credits run, andfriendly_textno longer tells someone with a perfectly good key to runlenz login. The payload gainsupgrade_url.Breaking-ish
LenzQuotaExceededErrordoes not inherit fromLenzAuthError. 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
pytest248 passed ·ruff checkclean ·mypyclean — 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