diff --git a/CHANGELOG.md b/CHANGELOG.md index c6f2a72..a946f3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- SDK/CLI: new accounts start on the free plan ($5.00 of usage a month at pay-as-you-go rates, no card), and a request with no credentials now gets a 401 whose `detail` is an object (`code: "UNAUTHENTICATED"`, `message`, `signup_url`, `plan`, `allowance_usd`, `cpc`, `cpm`) pointing at free signup. The SDK raises `AuthenticationError` with the server's `message`, as for any object `detail`; other 401s keep a string `detail`. No SDK code change; older versions show the JSON body as the message. - SDK/CLI: the API now prices every billed request before it runs and refuses one the remaining balance cannot cover with a 403 whose `detail` is an object (`code: "INSUFFICIENT_BALANCE"`, `message`, `remaining_usd`, `max_affordable_records`, `requested_records`, `upgrade_url`). The SDK raises it as the new `InsufficientBalanceError`, a `PlanAccessError` subclass, so existing `except PlanAccessError` handlers still catch it; its message is the server's `message`, and `max_affordable_records`, `remaining_usd`, `requested_records` and `upgrade_url` are attributes, counted in the request's own size unit (records, or contacts, companies, domains, rows, queries or personas for requests sized by those; the message names it), so a caller can retry at `max_affordable_records`. Only a 403 carrying that code raises it. A request the API could not price answers 503 (`ServerError`), which the SDK retries automatically on GET calls. Any other object `detail` with a `message` now uses that message instead of the raw JSON. The CLI exits 3 with `"code": "insufficient_balance"` and adds `max_affordable_records` and `remaining_usd` to the error JSON. Older SDK versions raise a plain `PlanAccessError` whose message is the JSON body. ## 0.6.0 (2026-10-12) diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py index f0f46cb..eb9d75a 100644 --- a/packages/discolike/tests/test_exceptions.py +++ b/packages/discolike/tests/test_exceptions.py @@ -117,3 +117,21 @@ def test_plain_403_stays_plan_access_error() -> None: with pytest.raises(PlanAccessError) as exc_info: raise_for_status(_response(403)) assert type(exc_info.value) is PlanAccessError + + +def test_no_credentials_401_object_detail_uses_its_message() -> None: + body = { + "detail": { + "code": "UNAUTHENTICATED", + "message": "No credentials. A human can create a free account (no card).", + "signup_url": "https://api.discolike.com/v1/public/signup", + "plan": "free", + "allowance_usd": 5.0, + "cpc": 0.7, + "cpm": 14.0, + } + } + with pytest.raises(AuthenticationError) as exc_info: + raise_for_status(_response(401, json_body=body)) + assert str(exc_info.value) == "No credentials. A human can create a free account (no card)." + assert exc_info.value.payload["detail"]["signup_url"] == "https://api.discolike.com/v1/public/signup"