From a8178fac097e70d7a999eb89512acb37d0090624 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Mon, 12 Oct 2026 23:50:26 -0700 Subject: [PATCH 1/4] Raise InsufficientBalanceError for a 403 INSUFFICIENT_BALANCE --- CHANGELOG.md | 4 ++ README.md | 2 +- .../src/discolike_cli/_output.py | 6 +++ packages/discolike-cli/tests/test_output.py | 14 +++++++ packages/discolike/src/discolike/__init__.py | 2 + .../discolike/src/discolike/_exceptions.py | 39 +++++++++++++++++++ packages/discolike/tests/test_exceptions.py | 39 +++++++++++++++++++ 7 files changed, 105 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c63d9fa..6ffcb70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- 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, so a caller can retry with at most `max_affordable_records` records. 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) - SDK/CLI: the API now limits how much work one account can have queued or running: email lookups (2,000 times the plan multiplier) and three DiscoGen, bulk match, contact bulk match and segment tasks. A submit over a limit is answered 429 with `Retry-After: 60`, which the SDK already waits out and retries, so with the default `max_retries=3` the call blocks for up to three minutes and then raises `RateLimitError` carrying the count and the limit in its message. In the SDK, `Discolike(max_retries=0)` gets the error at once; the CLI has no retry option, so its commands wait and then exit 4. No SDK change; older versions behave the same. diff --git a/README.md b/README.md index f179a66..1963798 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ except ValidationError as err: ... ``` -`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. Transient failures are retried automatically (3 attempts by default). +`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. A request the account's remaining balance cannot cover raises `InsufficientBalanceError` (a `PlanAccessError`), whose `max_affordable_records` is the largest record count that would fit. Transient failures are retried automatically (3 attempts by default). ```python import pydantic diff --git a/packages/discolike-cli/src/discolike_cli/_output.py b/packages/discolike-cli/src/discolike_cli/_output.py index 0bf6a94..27e1456 100644 --- a/packages/discolike-cli/src/discolike_cli/_output.py +++ b/packages/discolike-cli/src/discolike_cli/_output.py @@ -20,6 +20,7 @@ from discolike._exceptions import APIConnectionError from discolike._exceptions import AuthenticationError from discolike._exceptions import DiscolikeError +from discolike._exceptions import InsufficientBalanceError from discolike._exceptions import JobFailedError from discolike._exceptions import JobTimeoutError from discolike._exceptions import NotFoundError @@ -38,6 +39,7 @@ ValidationError: 2, AuthenticationError: 3, PlanAccessError: 3, + InsufficientBalanceError: 3, RateLimitError: 4, APIConnectionError: 5, NotFoundError: 6, @@ -51,6 +53,7 @@ ValidationError: "validation_error", AuthenticationError: "auth_invalid", PlanAccessError: "plan_access", + InsufficientBalanceError: "insufficient_balance", RateLimitError: "rate_limited", APIConnectionError: "network_error", NotFoundError: "not_found", @@ -150,6 +153,9 @@ def fail(exc: DiscolikeError) -> typer.Exit: } if isinstance(exc, RateLimitError) and exc.retry_after is not None: payload["retry_after"] = exc.retry_after + if isinstance(exc, InsufficientBalanceError): + payload["max_affordable_records"] = exc.max_affordable_records + payload["remaining_usd"] = exc.remaining_usd print(json.dumps(payload), file=sys.stderr) return typer.Exit(code=exit_code) diff --git a/packages/discolike-cli/tests/test_output.py b/packages/discolike-cli/tests/test_output.py index 0cbd44a..e4df92f 100644 --- a/packages/discolike-cli/tests/test_output.py +++ b/packages/discolike-cli/tests/test_output.py @@ -11,6 +11,7 @@ from discolike import APIConnectionError from discolike import AuthenticationError from discolike import DiscolikeError +from discolike import InsufficientBalanceError from discolike import JobFailedError from discolike import JobTimeoutError from discolike import NotFoundError @@ -154,6 +155,7 @@ def test_emit_table_flag_falls_back_to_json_when_no_scalar_columns(capsys: pytes (ValidationError("bad input"), 2), (AuthenticationError("no key"), 3), (RateLimitError("slow down", retry_after=3), 4), + (InsufficientBalanceError("too costly", max_affordable_records=10), 3), (ServerError("boom"), 1), ], ) @@ -186,6 +188,18 @@ def test_fail_rate_limit_includes_retry_after(capsys: pytest.CaptureFixture[str] assert payload["retry_after"] == 3.5 +def test_fail_insufficient_balance_includes_max_affordable_records(capsys: pytest.CaptureFixture[str]) -> None: + exc = InsufficientBalanceError( + "too costly", status_code=403, max_affordable_records=60, remaining_usd=3.0, requested_records=250 + ) + result = fail(exc) + assert result.exit_code == 3 + payload = json.loads(capsys.readouterr().err) + assert payload["code"] == "insufficient_balance" + assert payload["max_affordable_records"] == 60 + assert payload["remaining_usd"] == 3.0 + + def test_fail_rate_limit_without_retry_after_omits_key(capsys: pytest.CaptureFixture[str]) -> None: exc = RateLimitError("slow down", status_code=429, retry_after=None) fail(exc) diff --git a/packages/discolike/src/discolike/__init__.py b/packages/discolike/src/discolike/__init__.py index dfe1eaa..c5c1efd 100644 --- a/packages/discolike/src/discolike/__init__.py +++ b/packages/discolike/src/discolike/__init__.py @@ -5,6 +5,7 @@ from discolike._exceptions import APIConnectionError from discolike._exceptions import AuthenticationError from discolike._exceptions import DiscolikeError +from discolike._exceptions import InsufficientBalanceError from discolike._exceptions import JobFailedError from discolike._exceptions import JobTimeoutError from discolike._exceptions import NotFoundError @@ -51,6 +52,7 @@ "EmailJobResult", "EnumerationMatch", "EnumerationOutput", + "InsufficientBalanceError", "Job", "JobFailedError", "JobStatus", diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index 55f590a..5c55927 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -25,6 +25,30 @@ class AuthenticationError(DiscolikeError): ... class PlanAccessError(DiscolikeError): ... +class InsufficientBalanceError(PlanAccessError): + """A billed request priced above the account's remaining balance (403 ``INSUFFICIENT_BALANCE``). + + Retry with at most ``max_affordable_records`` records, or top up at ``upgrade_url``. + """ + + def __init__( + self, + message: str, + *, + status_code: int | None = None, + payload: Any = None, # noqa: ANN401 -- decoded JSON body, shape is server-defined + max_affordable_records: int | None = None, + remaining_usd: float | None = None, + requested_records: int | None = None, + upgrade_url: str | None = None, + ) -> None: + super().__init__(message, status_code=status_code, payload=payload) + self.max_affordable_records = max_affordable_records + self.remaining_usd = remaining_usd + self.requested_records = requested_records + self.upgrade_url = upgrade_url + + class ValidationError(DiscolikeError): ... @@ -56,6 +80,8 @@ def __init__( self.retry_after = retry_after +INSUFFICIENT_BALANCE_CODE = "INSUFFICIENT_BALANCE" + _STATUS_MAP: dict[int, type[DiscolikeError]] = { 400: ValidationError, 401: AuthenticationError, @@ -75,6 +101,8 @@ def _extract_message(response: httpx2.Response) -> tuple[str, Any]: detail = payload.get("detail") if isinstance(payload, dict) else None if isinstance(detail, str): return detail, payload + if isinstance(detail, dict) and isinstance(detail.get("message"), str): + return detail["message"], payload if isinstance(detail, list): parts = [ f"{'.'.join(str(x) for x in item.get('loc', []))}: {item.get('msg', '')}" @@ -94,5 +122,16 @@ def raise_for_status(response: httpx2.Response) -> None: header = response.headers.get("Retry-After") retry_after = float(header) if header and header.replace(".", "", 1).isdigit() else None raise RateLimitError(message, status_code=429, payload=payload, retry_after=retry_after) + detail = payload.get("detail") if isinstance(payload, dict) else None + if isinstance(detail, dict) and detail.get("code") == INSUFFICIENT_BALANCE_CODE: + raise InsufficientBalanceError( + message, + status_code=response.status_code, + payload=payload, + max_affordable_records=detail.get("max_affordable_records"), + remaining_usd=detail.get("remaining_usd"), + requested_records=detail.get("requested_records"), + upgrade_url=detail.get("upgrade_url"), + ) exc_type = _STATUS_MAP.get(response.status_code, ServerError if response.status_code >= 500 else DiscolikeError) raise exc_type(message, status_code=response.status_code, payload=payload) diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py index 4be2323..20e2353 100644 --- a/packages/discolike/tests/test_exceptions.py +++ b/packages/discolike/tests/test_exceptions.py @@ -5,6 +5,7 @@ from discolike import AuthenticationError from discolike import DiscolikeError +from discolike import InsufficientBalanceError from discolike import NotFoundError from discolike import PlanAccessError from discolike import RateLimitError @@ -69,3 +70,41 @@ def test_detail_list_with_non_dict_items() -> None: def test_all_are_discolike_errors() -> None: assert issubclass(RateLimitError, DiscolikeError) + + +INSUFFICIENT_BALANCE_DETAIL = { + "code": "INSUFFICIENT_BALANCE", + "message": "This request could cost up to $40.20 but the account has $12.50 left this cycle.", + "remaining_usd": 12.5, + "max_affordable_records": 3075, + "requested_records": 10000, + "upgrade_url": "https://app.discolike.com/account/management/plans", +} + + +def test_insufficient_balance_403_raises_typed_error_with_fields() -> None: + with pytest.raises(InsufficientBalanceError) as exc_info: + raise_for_status(_response(403, json_body={"detail": INSUFFICIENT_BALANCE_DETAIL})) + err = exc_info.value + assert isinstance(err, PlanAccessError) + assert str(err) == INSUFFICIENT_BALANCE_DETAIL["message"] + assert err.status_code == 403 + assert err.max_affordable_records == 3075 + assert err.remaining_usd == 12.5 + assert err.requested_records == 10000 + assert err.upgrade_url == "https://app.discolike.com/account/management/plans" + assert err.payload == {"detail": INSUFFICIENT_BALANCE_DETAIL} + + +def test_other_dict_detail_uses_its_message() -> None: + body = {"detail": {"code": "SOMETHING_ELSE", "message": "not allowed"}} + with pytest.raises(PlanAccessError) as exc_info: + raise_for_status(_response(403, json_body=body)) + assert type(exc_info.value) is PlanAccessError + assert str(exc_info.value) == "not allowed" + + +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 From eb56a4e66a71beb0fc452bf9311d8d9510f94ad6 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Tue, 13 Oct 2026 00:24:56 -0700 Subject: [PATCH 2/4] Raise InsufficientBalanceError only for a 403 and name the size unit --- CHANGELOG.md | 2 +- README.md | 2 +- packages/discolike/src/discolike/_exceptions.py | 6 ++++-- packages/discolike/tests/test_exceptions.py | 15 ++++++++++++--- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffcb70..c6f2a72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- 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, so a caller can retry with at most `max_affordable_records` records. 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. +- 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/README.md b/README.md index 1963798..8f60293 100644 --- a/README.md +++ b/README.md @@ -370,7 +370,7 @@ except ValidationError as err: ... ``` -`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. A request the account's remaining balance cannot cover raises `InsufficientBalanceError` (a `PlanAccessError`), whose `max_affordable_records` is the largest record count that would fit. Transient failures are retried automatically (3 attempts by default). +`AuthenticationError`, `PlanAccessError`, `NotFoundError`, `ServerError`, and `APIConnectionError` cover the rest. A request the account's remaining balance cannot cover raises `InsufficientBalanceError` (a `PlanAccessError`), whose `max_affordable_records` is the largest size that would fit, counted in the request's own unit (the message names it: records, contacts, domains, rows, queries or personas). Transient failures are retried automatically (3 attempts by default). ```python import pydantic diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index 5c55927..3ea096a 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -28,7 +28,9 @@ class PlanAccessError(DiscolikeError): ... class InsufficientBalanceError(PlanAccessError): """A billed request priced above the account's remaining balance (403 ``INSUFFICIENT_BALANCE``). - Retry with at most ``max_affordable_records`` records, or top up at ``upgrade_url``. + ``requested_records`` and ``max_affordable_records`` count in the request's own size unit + (records, contacts, companies, domains, rows, queries or personas; the message names it), so a + retry at ``max_affordable_records`` fits. A fixed-size lookup reports 0; top up at ``upgrade_url``. """ def __init__( @@ -123,7 +125,7 @@ def raise_for_status(response: httpx2.Response) -> None: retry_after = float(header) if header and header.replace(".", "", 1).isdigit() else None raise RateLimitError(message, status_code=429, payload=payload, retry_after=retry_after) detail = payload.get("detail") if isinstance(payload, dict) else None - if isinstance(detail, dict) and detail.get("code") == INSUFFICIENT_BALANCE_CODE: + if response.status_code == 403 and isinstance(detail, dict) and detail.get("code") == INSUFFICIENT_BALANCE_CODE: raise InsufficientBalanceError( message, status_code=response.status_code, diff --git a/packages/discolike/tests/test_exceptions.py b/packages/discolike/tests/test_exceptions.py index 20e2353..f0f46cb 100644 --- a/packages/discolike/tests/test_exceptions.py +++ b/packages/discolike/tests/test_exceptions.py @@ -74,8 +74,11 @@ def test_all_are_discolike_errors() -> None: INSUFFICIENT_BALANCE_DETAIL = { "code": "INSUFFICIENT_BALANCE", - "message": "This request could cost up to $40.20 but the account has $12.50 left this cycle.", - "remaining_usd": 12.5, + "message": ( + "This request could cost up to $140.70 but the account has $43.75 left this cycle. " + "Lower it to at most 3075 records, or upgrade or top up to continue." + ), + "remaining_usd": 43.75, "max_affordable_records": 3075, "requested_records": 10000, "upgrade_url": "https://app.discolike.com/account/management/plans", @@ -90,12 +93,18 @@ def test_insufficient_balance_403_raises_typed_error_with_fields() -> None: assert str(err) == INSUFFICIENT_BALANCE_DETAIL["message"] assert err.status_code == 403 assert err.max_affordable_records == 3075 - assert err.remaining_usd == 12.5 + assert err.remaining_usd == 43.75 assert err.requested_records == 10000 assert err.upgrade_url == "https://app.discolike.com/account/management/plans" assert err.payload == {"detail": INSUFFICIENT_BALANCE_DETAIL} +def test_insufficient_balance_code_on_another_status_is_not_a_balance_error() -> None: + with pytest.raises(ServerError) as exc_info: + raise_for_status(_response(500, json_body={"detail": INSUFFICIENT_BALANCE_DETAIL})) + assert not isinstance(exc_info.value, InsufficientBalanceError) + + def test_other_dict_detail_uses_its_message() -> None: body = {"detail": {"code": "SOMETHING_ELSE", "message": "not allowed"}} with pytest.raises(PlanAccessError) as exc_info: From 3868f152b7e84e3d1abb18e20d49b8e158f818ef Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Tue, 13 Oct 2026 06:24:31 -0700 Subject: [PATCH 3/4] docs(sdk): InsufficientBalanceError reports what fits, a fixed-size request only asks to top up --- packages/discolike/src/discolike/_exceptions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index 3ea096a..e44da39 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -30,7 +30,9 @@ class InsufficientBalanceError(PlanAccessError): ``requested_records`` and ``max_affordable_records`` count in the request's own size unit (records, contacts, companies, domains, rows, queries or personas; the message names it), so a - retry at ``max_affordable_records`` fits. A fixed-size lookup reports 0; top up at ``upgrade_url``. + retry at ``max_affordable_records`` fits; it is 0 when even the request fee does not fit. A request + with no size to lower (a single-company lookup or a single email find) cannot be retried smaller, + and its message only asks to top up at ``upgrade_url``. """ def __init__( From 39a295e9a106fd8886e6c0f274853376c15137e5 Mon Sep 17 00:00:00 2001 From: Yegor Dolgopolov Date: Tue, 13 Oct 2026 06:57:18 -0700 Subject: [PATCH 4/4] docs(sdk): max_affordable_records is 0 when no record fits after the fee too --- packages/discolike/src/discolike/_exceptions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/discolike/src/discolike/_exceptions.py b/packages/discolike/src/discolike/_exceptions.py index e44da39..5a71d99 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -30,8 +30,8 @@ class InsufficientBalanceError(PlanAccessError): ``requested_records`` and ``max_affordable_records`` count in the request's own size unit (records, contacts, companies, domains, rows, queries or personas; the message names it), so a - retry at ``max_affordable_records`` fits; it is 0 when even the request fee does not fit. A request - with no size to lower (a single-company lookup or a single email find) cannot be retried smaller, + retry at ``max_affordable_records`` fits; it is 0 when the request fee does not fit or no record + fits after the fee. A request with no size to lower (a single-company lookup or a single email find) cannot be retried smaller, and its message only asks to top up at ``upgrade_url``. """