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)