From dce79585ddc63c38c9f97c9963fe26648b2d9a7a Mon Sep 17 00:00:00 2001 From: Lenz Date: Mon, 10 Aug 2026 13:21:13 +0300 Subject: [PATCH] feat: surface out-of-credits as a quota error, not an auth failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01UwCqqYgF6h1YxBg4J1a3Ns --- CHANGELOG.md | 68 +++++++ README.md | 25 ++- src/lenz_io/__init__.py | 2 + src/lenz_io/cli/errors.py | 27 ++- src/lenz_io/client.py | 56 +++++- src/lenz_io/errors.py | 177 ++++++++++++++++-- tests/fixtures/contract/error_quota_402.json | 8 + .../contract/error_rate_limit_429.json | 8 + tests/test_cli.py | 46 +++++ tests/test_client.py | 136 +++++++++++++- tests/test_contract.py | 46 +++++ tests/test_errors.py | 150 ++++++++++++++- 12 files changed, 720 insertions(+), 29 deletions(-) create mode 100644 tests/fixtures/contract/error_quota_402.json create mode 100644 tests/fixtures/contract/error_rate_limit_429.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cd3764..3576618 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,74 @@ 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.7.0] - 2026-08-10 + +Quota errors are now a first-class, typed condition instead of an +authorization failure. + +### Changed +- **Out-of-credits raises `LenzQuotaExceededError`, not `LenzAuthError`.** The + API moved these rejections from HTTP 403 to **402**; 402 already mapped to + `LenzQuotaExceededError` in this SDK, so the class you catch changes the + moment the server ships. Previously a developer who ran out of credits was + told *"This key doesn't have access to that resource"* and pointed at + `/docs/auth`. + + **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. + +- **`Retry-After` is clamped at 60s** (`MAX_RETRY_AFTER_SLEEP`, now exported). + The `/extract` daily cap sends seconds-until-UTC-midnight, so the old + behavior could block a call for most of a day — three times over, once per + retry. Past the clamp the two retryable statuses now differ: + + - **429** raises immediately with the true `retry_after`. Schedule the work; + don't sit in it. + - **5xx** falls back to the normal backoff ladder and keeps retrying — the + server is down, not throttling you, and a maintenance-window + `Retry-After: 3600` shouldn't become an hour-long sleep *or* abort a call + that backoff might still satisfy. + + Note the clamp bounds a single sleep, not the call: a 429 stating 60s can + still sleep 60s on each of `max_retries` attempts. + +- **`LenzRateLimitError.retry_after` now reads `reset_in_seconds`** from the + body when the `Retry-After` header is absent. The previously-read + `retry_after` body key was an SDK invention the server has never sent. + +- **Two server `code` values were retired** (server-side change, affects every + SDK version): `insufficient_credits` and `no_chat_credits` are now plain + `no_credits`. Both named the endpoint you called rather than what went + wrong. **A branch on either string stops matching silently** — read + `remaining` instead. + +### Added +- **`LenzError.code`** — the server's machine-readable error code, on the base + class so 402, 403 and 429 all carry it. `""` when the server sent none. +- **`LenzQuotaExceededError.upgrade_url`** — where the wall lifts. No rejection + used to carry a URL at all. +- **`LenzQuotaExceededError.remaining` / `.resets_at` / `.requested`.** + `remaining` is **nullable**: `None` means the server didn't report a balance, + `0` means it reported an empty one. The server omits these rather than + sending `null`, so the distinction survives the wire. +- **`LenzRateLimitError.limit` / `.reset_in_seconds` / `.upgrade_url`.** The + server sends `upgrade_url` on 429 as well as 402 — someone hitting the daily + `/extract` cap also wants to know a paid plan raises it. +- **`MAX_RETRY_AFTER_SLEEP`** is exported from the package root. + +### Deprecated +- **`LenzQuotaExceededError.credits_remaining`** — use `remaining`. The old + attribute was zero-defaulted and the server never sent the field it read, so + it was always `0`. It is now a property that reads and writes through to + `remaining` (still assignable, so constructor kwargs and fixtures keep + working) and emits a `DeprecationWarning`. Removed in 3.0. + +### Fixed +- **CLI `--json` reports `"no_credits"`, not `"unauthorized"`,** for an + out-of-credits run, and `friendly_text` no longer tells someone with a + working key to run `lenz login`. The payload gains `upgrade_url`. + ## [2.6.0] - 2026-08-05 ### Added diff --git a/README.md b/README.md index 77c0f26..18e1b98 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,8 @@ lenz config # show which key/base URL is in use Every command takes `--json` for a clean machine-readable object (also emitted automatically when stdout is not a TTY, so pipes Just Work). Errors in `--json` mode are `{"error": {"code", "message", "status"}}` on stdout with a nonzero -exit. `verify` blocks with a progress spinner; Ctrl-C prints a +exit (an out-of-credits run reports `"code": "no_credits"` and adds +`upgrade_url`). `verify` blocks with a progress spinner; Ctrl-C prints a `lenz verify --resume ` handle so a long run isn't lost. Key resolution order is `--api-key` flag → `LENZ_API_KEY` → `~/.config/lenz/config.json`. @@ -218,10 +219,20 @@ Every error subclass is typed and carries a `request_id` you can quote on support tickets: ```python -from lenz_io import LenzAuthError, LenzRateLimitError, LenzValidationError +from lenz_io import ( + LenzAuthError, + LenzQuotaExceededError, + LenzRateLimitError, + LenzValidationError, +) try: client.verify_and_wait(claim="...") +except LenzQuotaExceededError as exc: + # HTTP 402. Out of balance — retrying will not clear it. + print(exc.remaining) # 0, or None if the server didn't report a balance + print(exc.resets_at) # "2026-09-01T00:00:00+00:00", or None + print(exc.upgrade_url) # https://lenz.io/plans except LenzAuthError as exc: print(exc) # Unauthorized @@ -230,12 +241,20 @@ except LenzAuthError as exc: # Docs: https://lenz.io/docs/auth # Request ID: req_abc123 except LenzRateLimitError as exc: - time.sleep(exc.retry_after) + # Waits up to 60s are already retried for you, so reaching here means + # either the ladder ran out or the wait is long. Don't sleep it — the + # /extract daily cap can be hours away. + schedule_retry_in(exc.retry_after) except LenzValidationError as exc: for field_err in exc.errors: print(field_err["loc"], field_err["msg"]) ``` +`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; +add a `LenzQuotaExceededError` handler. + ## Resuming a verification If a `verify_and_wait` call exceeds its `timeout` (default 120s) or your diff --git a/src/lenz_io/__init__.py b/src/lenz_io/__init__.py index 9b9701a..819d547 100644 --- a/src/lenz_io/__init__.py +++ b/src/lenz_io/__init__.py @@ -37,6 +37,7 @@ # Public surface from .client import API_VERSION, DEFAULT_BASE_URL, Lenz, VerifyBatchItem from .errors import ( + MAX_RETRY_AFTER_SLEEP, LenzAPIError, LenzAuthError, LenzError, @@ -89,6 +90,7 @@ __all__ = [ "API_VERSION", "DEFAULT_BASE_URL", + "MAX_RETRY_AFTER_SLEEP", "AskHistory", "AskMessage", "AskReply", diff --git a/src/lenz_io/cli/errors.py b/src/lenz_io/cli/errors.py index 3ff86eb..d66b249 100644 --- a/src/lenz_io/cli/errors.py +++ b/src/lenz_io/cli/errors.py @@ -6,8 +6,8 @@ Claude Code skill / MCP server) always parses valid JSON and branches on the ``error`` key. Human mode prints a friendly block to stderr. Never a traceback. -Status codes match the real server (verified against the API): no-credits is -**403** (not 402), extract rate-limit **429**, bad key **401**. +Status codes match the real server: out-of-credits is **402** (it was 403 +until August 2026), extract rate-limit **429**, bad key **401**. """ from __future__ import annotations @@ -70,8 +70,19 @@ def no_api_key_error() -> CLIError: def to_payload(exc: Exception) -> dict[str, Any]: """Normalize any handled error into the locked ``--json`` error shape.""" + upgrade_url = "" if isinstance(exc, CLIError): code, message, status, fix = exc.code, exc.message, exc.status, exc.fix + elif isinstance(exc, LenzQuotaExceededError): + # Checked explicitly, and before the generic LenzError branch, so an + # out-of-credits run can never be reported as "unauthorized" again — + # which is what happened while the server sent 403 for quota and the + # exact-type lookup below landed on LenzAuthError. + code = "no_credits" + message = exc.message or str(exc) + status = exc.status_code + fix = exc.fix + upgrade_url = exc.upgrade_url elif isinstance(exc, LenzError): code = _CODE_BY_TYPE.get(type(exc), "api_error") message = exc.message or str(exc) @@ -84,6 +95,8 @@ def to_payload(exc: Exception) -> dict[str, Any]: err: dict[str, Any] = {"code": code, "message": message, "status": status} if fix: err["fix"] = fix + if upgrade_url: + err["upgrade_url"] = upgrade_url return {"error": err} @@ -95,6 +108,14 @@ def friendly_text(exc: Exception) -> str: """A single human-readable block for stderr (pretty mode).""" message = getattr(exc, "message", None) or str(exc) fix = getattr(exc, "fix", "") - if isinstance(exc, LenzAuthError) and not fix: + # Quota first: it is NOT a LenzAuthError, but telling someone who is + # simply out of credits to "run `lenz login`" sends them to re-auth a + # key that works fine. + if isinstance(exc, LenzQuotaExceededError): + if not fix: + fix = "Top up or upgrade at https://lenz.io/plans." + if exc.upgrade_url and exc.upgrade_url not in fix: + fix = f"{fix} See {exc.upgrade_url}" + elif isinstance(exc, LenzAuthError) and not fix: fix = "Run `lenz login` or check your API key." return f"{message}\n Fix: {fix}" if fix else message diff --git a/src/lenz_io/client.py b/src/lenz_io/client.py index 7640ba3..c0b3f36 100644 --- a/src/lenz_io/client.py +++ b/src/lenz_io/client.py @@ -69,6 +69,7 @@ from . import __version__ from .errors import ( + MAX_RETRY_AFTER_SLEEP, LenzAPIError, LenzError, LenzNeedsInputError, @@ -799,14 +800,26 @@ def _request( return response.json() if response.content else {} # 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: + # + # * 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. if attempt < self._max_retries and (response.status_code >= 500 or response.status_code == 429): - ra = response.headers.get("Retry-After") - try: - sleep_for = int(ra) if ra else _retry_sleep(attempt) - except ValueError: - sleep_for = _retry_sleep(attempt) - time.sleep(sleep_for) - continue + 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: + time.sleep(_retry_sleep(attempt)) + continue raise map_response_to_error( response.status_code, @@ -820,6 +833,35 @@ def _request( raise LenzAPIError(message=f"{method} {path} failed without diagnostic") +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. + """ + raw = response.headers.get("Retry-After") + if raw is None or str(raw).strip() == "": + try: + body = response.json() + except Exception: + # A non-JSON body is not exceptional — fall back to backoff. + return None + if not isinstance(body, dict): + return None + raw = body.get("reset_in_seconds") + if raw is None or str(raw).strip() == "": + return None + try: + # Floored at zero: `Retry-After: -5` is malformed, and time.sleep() + # raises ValueError on a negative — which would escape the retry + # ladder as a bare ValueError, defeating the typed-exception contract. + return max(0, int(float(raw))) + except (TypeError, ValueError): + return None + + 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 b1e3d81..752f8f9 100644 --- a/src/lenz_io/errors.py +++ b/src/lenz_io/errors.py @@ -20,6 +20,7 @@ from __future__ import annotations import json +import warnings from typing import Any @@ -38,6 +39,9 @@ class LenzError(Exception): * ``doc_url`` — deep link to the relevant docs page * ``request_id`` — ``X-Request-ID`` header value; quote on support tickets * ``status_code``— HTTP status code (0 for client-side errors) + * ``code`` — the server's machine-readable error code, e.g. + ``"no_credits"``. Present on 402, 403 and 429; ``""`` when the + server sent none. Branch on this rather than on message text. * ``body`` — parsed JSON response body if available """ @@ -50,6 +54,7 @@ def __init__( doc_url: str = "", request_id: str = "", status_code: int = 0, + code: str = "", body: dict[str, Any] | None = None, **extra: Any, ) -> None: @@ -60,6 +65,7 @@ def __init__( self.doc_url = doc_url self.request_id = request_id self.status_code = status_code + self.code = code self.body = body # Per-subclass enrichment (retry_after, task_id, etc.). Set on the # instance so they're accessible as ``exc.task_id`` regardless of @@ -81,16 +87,71 @@ def __str__(self) -> str: # pragma: no cover - trivial class LenzAuthError(LenzError): - """401 / 403 — the API key is missing, invalid, or revoked.""" + """401 / 403 — the API key is missing, invalid, or revoked. + + Note: an out-of-credits response is NOT this error. It used to be — + the API returned 403 for quota, which landed here — but the API now + returns 402 and that maps to :class:`LenzQuotaExceededError`. If you were + catching ``LenzAuthError`` to handle an empty balance, catch the quota + error instead. The two do not share a parent on purpose: "fix your key" + and "top up your account" are different actions. + """ class LenzQuotaExceededError(LenzError): - """402 — you've spent all your credits this period. - - ``credits_remaining`` is set from the response body. + """402 — you're out of balance, or your plan doesn't cover this call. + + Fields set from the response body: + + * ``upgrade_url`` — where the wall lifts (the plans page). + * ``remaining`` — usable capacity left for the capability, or + ``None`` when the server didn't say. **Nullable on purpose**: the + old ``credits_remaining: int = 0`` could not tell "0 left" apart + from "server said nothing", which made it useless to branch on. + * ``resets_at`` — ISO-8601 timestamp of the next monthly reset, + or ``None``. + * ``requested`` — for a batch call, how many units were asked for. """ - credits_remaining: int = 0 + upgrade_url: str = "" + remaining: int | None = None + resets_at: str | None = None + requested: int | None = None + + @property + def credits_remaining(self) -> int: + """Deprecated alias for ``remaining``. Removed in 3.0. + + Returns 0 when ``remaining`` is unknown, which is exactly the + ambiguity ``remaining`` exists to fix — migrate to ``remaining``. + """ + self._warn_credits_remaining() + return self.remaining or 0 + + @credits_remaining.setter + def credits_remaining(self, value: int | None) -> None: + """Writes through to ``remaining``. + + A read-only property here would be a breaking change in a MINOR + release: ``LenzError.__init__`` splats unknown kwargs onto the + instance via ``setattr``, and its docstring advertises that as the + forward-compatibility path — so + ``LenzQuotaExceededError(credits_remaining=0)`` was legal in 2.6.0 and + appears in real test fixtures and retry shims. Without this setter, + those raise ``AttributeError`` on upgrade. + """ + self._warn_credits_remaining() + self.remaining = value + + @staticmethod + def _warn_credits_remaining() -> None: + warnings.warn( + "credits_remaining is deprecated and will be removed in 3.0; " + "use `remaining`, which is None when the server didn't report a " + "balance (credits_remaining reports that as 0).", + DeprecationWarning, + stacklevel=3, + ) class LenzValidationError(LenzError): @@ -104,9 +165,25 @@ class LenzValidationError(LenzError): class LenzRateLimitError(LenzError): - """429 — rate limited. ``retry_after`` is seconds until the next allowed call.""" + """429 — rate limited. + + * ``retry_after`` — seconds until the next allowed call, resolved + from the ``Retry-After`` header or the body's ``reset_in_seconds``. + * ``limit`` — the cap that was hit, when the server states it. + * ``reset_in_seconds`` — the body's raw echo of the same wait. + + Seeing this raised does not always mean the automatic retry ladder was + exhausted: waits longer than ``MAX_RETRY_AFTER_SLEEP`` raise immediately + so a call can't block for hours inside a sleeping retry. + """ retry_after: int = 0 + limit: int | None = None + reset_in_seconds: int | None = None + #: Where the cap lifts. The server sends this on 429 as well as 402, + #: deliberately — someone hitting the daily /extract cap also wants to + #: know a paid plan raises it. + upgrade_url: str = "" class LenzAPIError(LenzError): @@ -157,6 +234,18 @@ class LenzWebhookSignatureError(LenzError): _DOCS_BASE = "https://lenz.io/docs" +# NOTE: quota is 402 and only 402. There is deliberately no "403 carrying a +# quota code also means quota" fallback here — the API emits 402, and the only +# thing such a fallback would buy is coverage for a server rollback. The MCP +# server keeps an equivalent branch because it is a separate Cloud Run service +# that deploys non-atomically alongside the API; an SDK has no such window. +# +# Longest Retry-After we'll sleep through inside the automatic retry ladder. +# The /extract daily cap sends seconds-until-UTC-midnight, so honoring the raw +# value could block a call for ~24h (three times over). Above this we raise +# immediately with the true retry_after so the caller can schedule the work. +MAX_RETRY_AFTER_SLEEP = 60 + _STATUS_MAP: dict[int, tuple[type[LenzError], str, str]] = { 401: ( LenzAuthError, @@ -212,6 +301,12 @@ def map_response_to_error( headers = headers or {} request_id = headers.get("X-Request-ID") or headers.get("x-request-id") or "" + # String-typed only, matching the Node SDK: a malformed `code: 42` becomes + # "" rather than the string "42", so nothing downstream branches on a + # value the server never meant as a code. + code_raw = parsed.get("code") + code = code_raw if isinstance(code_raw, str) else "" + if status_code in _STATUS_MAP: cls, default_msg, doc_url = _STATUS_MAP[status_code] elif 500 <= status_code < 600: @@ -227,13 +322,24 @@ def map_response_to_error( doc_url=doc_url, request_id=request_id, status_code=status_code, + code=code, body=parsed, ) # 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, LenzQuotaExceededError): - err.credits_remaining = int(parsed.get("credits_remaining", 0) or 0) + # String-typed only. `str(...)` on a malformed dict would render + # "{'a': 1}" and friendly_text would show that to a user as a URL. + upgrade_url = parsed.get("upgrade_url") + err.upgrade_url = upgrade_url if isinstance(upgrade_url, str) else "" + # None, not 0, when absent — the server omits these rather than + # sending null precisely so "unknown" stays distinguishable from + # "zero". Collapsing that here would throw the distinction away. + err.remaining = _opt_int(parsed.get("remaining")) + err.requested = _opt_int(parsed.get("requested")) + resets_at = parsed.get("resets_at") + err.resets_at = resets_at if isinstance(resets_at, str) and resets_at else None elif isinstance(err, LenzValidationError): # Ninja returns errors as `detail: [...]` (a list of per-field dicts). # Older or alternative shapes can put them under `errors`. @@ -244,26 +350,73 @@ def map_response_to_error( else: err.errors = [] elif isinstance(err, LenzRateLimitError): - ra = headers.get("Retry-After") or headers.get("retry-after") or parsed.get("retry_after", 0) - try: - err.retry_after = int(ra) - except (TypeError, ValueError): + err.limit = _opt_int(parsed.get("limit")) + err.reset_in_seconds = _opt_int(parsed.get("reset_in_seconds")) + rl_upgrade_url = parsed.get("upgrade_url") + err.upgrade_url = rl_upgrade_url if isinstance(rl_upgrade_url, str) else "" + # Header first, then the body. `reset_in_seconds` is what the server + # actually sends; `retry_after` was an SDK-side invention that the + # server has never emitted — kept last purely as a defensive read. + # + # Each candidate is COERCED before being accepted, rather than picking + # the first truthy raw value and coercing once at the end. `Retry-After` + # may legally be an HTTP-date (RFC 7231), and a truthy-but-unparseable + # header would otherwise win the `or` chain, coerce to None, and land + # on 0 — discarding a perfectly good `reset_in_seconds` and telling the + # caller to retry immediately against a server that just throttled it. + for candidate in ( + headers.get("Retry-After"), + headers.get("retry-after"), + parsed.get("reset_in_seconds"), + parsed.get("retry_after"), + ): + resolved = _opt_int(candidate) + if resolved is not None: + err.retry_after = resolved + break + else: err.retry_after = 0 return err +def _opt_int(value: Any) -> int | None: + """Coerce to int, or None when absent/unparseable. + + Blank input returns None rather than 0. In JS ``Number("")`` and + ``Number(" ")`` are both ``0``, so the Node counterpart trims before the + same check — a whitespace-only ``remaining`` must read as "unknown", not + "balance is empty", which is the whole reason these fields are nullable. + + Numeric strings with a fractional part are accepted and truncated, so + ``"42.7"`` and ``42.7`` agree. Every field this parses (counts, seconds) + is an integer on the wire; a float is malformed either way, and the two + SDKs disagreeing about it is worse than either answer. + """ + if isinstance(value, str): + value = value.strip() + if value is None or value == "": + return None + if isinstance(value, bool): + return None + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + def _fix_hint_for(status_code: int) -> str: return { 401: "Generate a new key at https://lenz.io/api-integration.", 403: "This key doesn't have access to that resource.", - 402: "Upgrade your plan or wait for the period reset.", + 402: "Top up or upgrade at https://lenz.io/plans, or wait for the period reset.", 422: "Check the request body against the OpenAPI spec.", 429: "Wait Retry-After seconds and retry.", }.get(status_code, "Retry; if the error persists, file an issue with the Request ID.") __all__ = [ + "MAX_RETRY_AFTER_SLEEP", "LenzAPIError", "LenzAuthError", "LenzError", diff --git a/tests/fixtures/contract/error_quota_402.json b/tests/fixtures/contract/error_quota_402.json new file mode 100644 index 0000000..bfa8129 --- /dev/null +++ b/tests/fixtures/contract/error_quota_402.json @@ -0,0 +1,8 @@ +{ + "detail": "No remaining claim checks.", + "code": "no_credits", + "doc_url": "https://lenz.io/docs/errors#quota", + "upgrade_url": "https://lenz.io/plans", + "remaining": 0, + "resets_at": "2026-09-01T00:00:00+00:00" +} diff --git a/tests/fixtures/contract/error_rate_limit_429.json b/tests/fixtures/contract/error_rate_limit_429.json new file mode 100644 index 0000000..012dcb4 --- /dev/null +++ b/tests/fixtures/contract/error_rate_limit_429.json @@ -0,0 +1,8 @@ +{ + "detail": "Daily /extract limit of 1000 reached.", + "code": "extract_daily_limit", + "limit": 1000, + "reset_in_seconds": 7200, + "doc_url": "https://lenz.io/docs/errors#rate-limits", + "upgrade_url": "https://lenz.io/plans" +} diff --git a/tests/test_cli.py b/tests/test_cli.py index 05eb1ee..026e3ca 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1401,6 +1401,52 @@ def test_resume_honors_claim_preselect(monkeypatch): assert json.loads(result.stdout)["verdict"] == "False" +# ── quota errors ──────────────────────────────────────────────────────────── +class TestQuotaErrorMapping: + """The CLI's out-of-credits contract. + + Before the API moved quota from 403 to 402, an out-of-credits run was a + LenzAuthError, so `--json` reported `"unauthorized"` and the human path + told the user to re-authenticate a key that worked fine. `"no_credits"` + was unreachable. + """ + + def _quota_error(self, **kwargs): + from lenz_io.errors import map_response_to_error + + body = json.dumps( + { + "detail": "No remaining claim checks.", + "code": "no_credits", + "upgrade_url": "https://lenz.io/plans", + "remaining": 0, + **kwargs, + } + ) + return map_response_to_error(402, body, {}) + + def test_json_payload_reports_no_credits_not_unauthorized(self): + from lenz_io.cli.errors import to_payload + + err = to_payload(self._quota_error())["error"] + assert err["code"] == "no_credits" + assert err["status"] == 402 + assert err["upgrade_url"] == "https://lenz.io/plans" + + def test_friendly_text_does_not_tell_a_paying_user_to_re_login(self): + from lenz_io.cli.errors import friendly_text + + text = friendly_text(self._quota_error()) + assert "lenz login" not in text + assert "lenz.io/plans" in text + + def test_auth_errors_still_point_at_login(self): + from lenz_io.cli.errors import friendly_text + from lenz_io.errors import LenzAuthError + + assert "lenz login" in friendly_text(LenzAuthError(message="Unauthorized")) + + # ── lazy-import guard ─────────────────────────────────────────────────────── def test_lazy_import_guard(monkeypatch, capsys): import importlib diff --git a/tests/test_client.py b/tests/test_client.py index f1dc79f..c0746bd 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -19,10 +19,12 @@ LenzAuthError, LenzNeedsInputError, LenzPipelineError, + LenzRateLimitError, LenzTimeoutError, TaskAccepted, ) -from lenz_io.client import API_VERSION +from lenz_io.client import API_VERSION, RETRY_BACKOFF +from lenz_io.errors import MAX_RETRY_AFTER_SLEEP DEFAULT_BASE = "https://lenz.io/api/v1" @@ -854,6 +856,138 @@ def test_429_honors_retry_after_header(self, client, monkeypatch): client.usage() assert 7 in slept + def test_429_with_a_long_retry_after_raises_instead_of_sleeping(self, client, monkeypatch): + """The /extract daily cap sends seconds-until-UTC-midnight. Sleeping + that would block the call for most of a day — three times over. + + Without the clamp this test does not fail, it HANGS for 24 hours. + """ + 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( + 429, + json={"detail": "Daily /extract limit of 1000 reached.", "reset_in_seconds": 86400}, + headers={"Retry-After": "86400"}, + ), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + with pytest.raises(LenzRateLimitError) as exc: + client.usage() + + assert slept == [], "must not sleep through a 24h wait" + assert len(route.calls) == 1, "must not burn retries on an unwinnable wait" + # The true wait is surfaced so the caller can schedule the work. + assert exc.value.retry_after == 86400 + + def test_429_at_the_clamp_boundary_still_retries(self, client, monkeypatch): + 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(429, json={"detail": "slow"}, headers={"Retry-After": str(MAX_RETRY_AFTER_SLEEP)}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [MAX_RETRY_AFTER_SLEEP] + + def test_429_without_a_stated_wait_falls_back_to_backoff(self, client, monkeypatch): + 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(429, json={"detail": "slow"}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [RETRY_BACKOFF[0]] + + def test_429_reads_the_wait_from_the_body_when_no_header(self, client, monkeypatch): + """The body-fallback branch — the only place this diverged from Node.""" + 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(429, json={"detail": "slow", "reset_in_seconds": 5}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [5] + + def test_429_long_wait_in_the_body_alone_still_raises(self, client, monkeypatch): + 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(429, json={"detail": "capped", "reset_in_seconds": 86400}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + with pytest.raises(LenzRateLimitError) as exc: + client.usage() + assert slept == [] + assert exc.value.retry_after == 86400 + + def test_429_with_a_non_json_body_falls_back_to_backoff(self, client, monkeypatch): + 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(429, text="rate limited"), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [RETRY_BACKOFF[0]] + + def test_negative_retry_after_is_floored_not_a_bare_ValueError(self, client, monkeypatch): + """time.sleep(-5) raises ValueError, which would escape the retry + ladder as a non-LenzError and defeat the typed-exception contract.""" + 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(429, json={"detail": "slow"}, headers={"Retry-After": "-5"}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [0] + + def test_5xx_still_honors_a_short_retry_after(self, client, monkeypatch): + """2.6.0 honored Retry-After on 5xx; the clamp must not silently + drop that for a status class the changelog never mentions.""" + 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": "5"}), + httpx.Response(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + 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.""" + 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(200, json={"plan": "free", "credits_used": 0, "credits_total": 10}), + ] + client.usage() + assert slept == [RETRY_BACKOFF[0]] + # ─────────────────────────────────────────────────── Connection reuse ── diff --git a/tests/test_contract.py b/tests/test_contract.py index 7b8fb74..edc3b9b 100644 --- a/tests/test_contract.py +++ b/tests/test_contract.py @@ -179,3 +179,49 @@ def test_assess_multiclaim_round_trips(): assert parsed.claims[0].verdict == "True" assert parsed.claims[0].confidence == "high" assert parsed.claims[0].verification_url is not None + + +# ── Error envelopes ───────────────────────────────────────────────────────── +# The 402/429 bodies are part of the wire contract too, and drift between the +# two SDKs' error mapping is exactly what this file exists to catch. Node +# validates the SAME fixture files in test/contract.test.ts. + + +def test_quota_402_envelope_maps_every_field(): + from lenz_io.errors import LenzQuotaExceededError, map_response_to_error + + fixture = _load("error_quota_402.json") + err = map_response_to_error(402, json.dumps(fixture), {}) + + assert isinstance(err, LenzQuotaExceededError) + assert err.message == fixture["detail"] + assert err.code == fixture["code"] + assert err.upgrade_url == fixture["upgrade_url"] + assert err.remaining == fixture["remaining"] + assert err.resets_at == fixture["resets_at"] + + # Every key the server sends must be consumed by a typed field or + # deliberately skipped — an unhandled key means the mapper drifted. + # `doc_url` is intentionally not mapped: the SDK sets its own doc_url from + # the status table so the link is right even against an older server. + handled = {"detail", "code", "upgrade_url", "remaining", "resets_at", "requested", "doc_url"} + assert not set(fixture) - handled + + +def test_rate_limit_429_envelope_maps_every_field(): + from lenz_io.errors import LenzRateLimitError, map_response_to_error + + fixture = _load("error_rate_limit_429.json") + err = map_response_to_error(429, json.dumps(fixture), {}) + + assert isinstance(err, LenzRateLimitError) + assert err.message == fixture["detail"] + assert err.code == fixture["code"] + assert err.limit == fixture["limit"] + assert err.reset_in_seconds == fixture["reset_in_seconds"] + assert err.retry_after == fixture["reset_in_seconds"] + # upgrade_url is on 429 too — the daily /extract cap is lifted by a plan. + assert err.upgrade_url == fixture["upgrade_url"] + + handled = {"detail", "code", "limit", "reset_in_seconds", "upgrade_url", "doc_url"} + assert not set(fixture) - handled diff --git a/tests/test_errors.py b/tests/test_errors.py index ff848e5..aa2e035 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -37,10 +37,103 @@ def test_403_maps_to_auth_error(self): e = map_response_to_error(403, _body({"detail": "forbidden"}), {}) assert isinstance(e, LenzAuthError) - def test_402_maps_to_quota_error_with_credits_remaining(self): - e = map_response_to_error(402, _body({"detail": "out of credits", "credits_remaining": 0}), {}) + def test_402_maps_to_quota_error_with_the_full_envelope(self): + e = map_response_to_error( + 402, + _body( + { + "detail": "No remaining claim checks.", + "code": "no_credits", + "upgrade_url": "https://lenz.io/plans", + "remaining": 0, + "resets_at": "2026-09-01T00:00:00+00:00", + } + ), + {}, + ) assert isinstance(e, LenzQuotaExceededError) - assert e.credits_remaining == 0 + assert e.code == "no_credits" + assert e.upgrade_url == "https://lenz.io/plans" + assert e.remaining == 0 + assert e.resets_at == "2026-09-01T00:00:00+00:00" + # Not an auth error — the whole point of the 402 migration. + assert not isinstance(e, LenzAuthError) + + def test_402_remaining_is_none_when_the_server_omits_it(self): + """None, not 0. The server omits rather than nulls precisely so + 'unknown' stays distinguishable from 'empty'.""" + e = map_response_to_error(402, _body({"detail": "out", "code": "no_credits"}), {}) + assert e.remaining is None + assert e.resets_at is None + assert e.requested is None + + def test_402_requested_echoed_for_batch_shortfall(self): + e = map_response_to_error( + 402, + _body({"detail": "batch too big", "code": "no_credits", "requested": 5, "remaining": 2}), + {}, + ) + assert e.requested == 5 + assert e.remaining == 2 + + def test_credits_remaining_alias_still_works_but_warns(self): + e = map_response_to_error(402, _body({"detail": "out", "remaining": 7}), {}) + with pytest.deprecated_call(): + assert e.credits_remaining == 7 + + def test_credits_remaining_alias_flattens_unknown_to_zero(self): + """Documents the exact ambiguity `remaining` exists to fix.""" + e = map_response_to_error(402, _body({"detail": "out"}), {}) + assert e.remaining is None + with pytest.deprecated_call(): + assert e.credits_remaining == 0 + + def test_credits_remaining_is_still_writable(self): + """2.7.0 is a MINOR — a read-only property here would break anyone + constructing or mutating the error, which `LenzError.__init__`'s + **extra splat explicitly invites.""" + e = LenzQuotaExceededError(message="x") + with pytest.deprecated_call(): + e.credits_remaining = 5 + assert e.remaining == 5 + + def test_credits_remaining_accepted_as_a_constructor_kwarg(self): + with pytest.deprecated_call(): + e = LenzQuotaExceededError(message="x", credits_remaining=7) + assert e.remaining == 7 + + def test_blank_strings_are_unknown_not_zero(self): + """Whitespace-only must read as "unknown", matching Node's trim. + Number(" ") === 0 in JS, so an untrimmed check diverges.""" + e = map_response_to_error(402, _body({"detail": "out", "remaining": " "}), {}) + assert e.remaining is None + + def test_malformed_code_and_upgrade_url_do_not_stringify(self): + """A dict rendered as "{'a': 1}" would be shown to a user as a URL.""" + e = map_response_to_error(402, _body({"detail": "out", "code": 42, "upgrade_url": {"a": 1}}), {}) + assert e.code == "" + assert e.upgrade_url == "" + + def test_403_is_always_an_auth_error_even_with_a_quota_code(self): + """Quota is 402 and only 402. + + There is no "403 + quota code also means quota" fallback: the only + thing it would cover is a server rollback, and carrying it forever + to insure against that is not worth the branch. The MCP server keeps + an equivalent branch because it deploys as a separate service. + """ + e = map_response_to_error( + 403, + _body({"detail": "No remaining claim checks.", "code": "no_credits"}), + {}, + ) + assert isinstance(e, LenzAuthError) + assert not isinstance(e, LenzQuotaExceededError) + assert e.code == "no_credits" # still surfaced for the caller to inspect + + def test_code_is_carried_on_the_base_error(self): + e = map_response_to_error(429, _body({"detail": "slow", "code": "extract_daily_limit"}), {}) + assert e.code == "extract_daily_limit" def test_422_maps_to_validation_error_with_field_errors(self): body = _body({"detail": [{"loc": ["text"], "msg": "required", "type": "missing"}]}) @@ -59,6 +152,57 @@ def test_429_picks_retry_after_from_body_when_header_absent(self): assert isinstance(e, LenzRateLimitError) assert e.retry_after == 12 + def test_429_reads_reset_in_seconds_the_key_the_server_actually_sends(self): + """`retry_after` in the body was an SDK invention the server never + emitted; `reset_in_seconds` is the real field.""" + e = map_response_to_error( + 429, + _body({"detail": "capped", "code": "extract_daily_limit", "limit": 1000, "reset_in_seconds": 7200}), + {}, + ) + assert e.retry_after == 7200 + assert e.reset_in_seconds == 7200 + assert e.limit == 1000 + assert e.code == "extract_daily_limit" + + def test_429_blank_retry_after_falls_through_to_the_body(self): + """A blank header must not win the resolution chain. + + With the body carrying a real wait, `0` here would mean the header + short-circuited and the caller was told to retry immediately against + a server that just throttled it. + """ + e = map_response_to_error(429, _body({"detail": "slow", "reset_in_seconds": 42}), {"Retry-After": ""}) + assert e.retry_after == 42 + + def test_429_unparseable_retry_after_falls_through_to_the_body(self): + """`Retry-After` may legally be an HTTP-date (RFC 7231). + + Truthy but unparseable, so a first-truthy-wins chain would take it, + coerce to None, and land on 0 — discarding the body's real value. + """ + e = map_response_to_error( + 429, + _body({"detail": "slow", "reset_in_seconds": 42}), + {"Retry-After": "Wed, 21 Oct 2015 07:28:00 GMT"}, + ) + assert e.retry_after == 42 + + def test_429_with_no_wait_anywhere_is_zero(self): + e = map_response_to_error(429, _body({"detail": "slow"}), {"Retry-After": ""}) + assert e.retry_after == 0 + assert e.reset_in_seconds is None + + def test_429_carries_upgrade_url(self): + """The server puts upgrade_url on 429 too — a developer hitting the + daily /extract cap also wants to know a paid plan lifts it.""" + e = map_response_to_error( + 429, + _body({"detail": "capped", "reset_in_seconds": 60, "upgrade_url": "https://lenz.io/plans"}), + {}, + ) + assert e.upgrade_url == "https://lenz.io/plans" + def test_5xx_maps_to_api_error(self): e = map_response_to_error(503, _body({"detail": "unavailable"}), {"x-request-id": "rq2"}) assert isinstance(e, LenzAPIError)