Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <task_id>` handle so a long run isn't lost. Key resolution
order is `--api-key` flag → `LENZ_API_KEY` → `~/.config/lenz/config.json`.

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/lenz_io/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -89,6 +90,7 @@
__all__ = [
"API_VERSION",
"DEFAULT_BASE_URL",
"MAX_RETRY_AFTER_SLEEP",
"AskHistory",
"AskMessage",
"AskReply",
Expand Down
27 changes: 24 additions & 3 deletions src/lenz_io/cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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}


Expand All @@ -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
56 changes: 49 additions & 7 deletions src/lenz_io/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@

from . import __version__
from .errors import (
MAX_RETRY_AFTER_SLEEP,
LenzAPIError,
LenzError,
LenzNeedsInputError,
Expand Down Expand Up @@ -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,
Expand All @@ -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]
Expand Down
Loading
Loading