diff --git a/CHANGELOG.md b/CHANGELOG.md index c63d9fa..c6f2a72 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, 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) - 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..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. 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-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..5a71d99 100644 --- a/packages/discolike/src/discolike/_exceptions.py +++ b/packages/discolike/src/discolike/_exceptions.py @@ -25,6 +25,34 @@ class AuthenticationError(DiscolikeError): ... class PlanAccessError(DiscolikeError): ... +class InsufficientBalanceError(PlanAccessError): + """A billed request priced above the account's remaining balance (403 ``INSUFFICIENT_BALANCE``). + + ``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 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``. + """ + + 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 +84,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 +105,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 +126,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 response.status_code == 403 and 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..f0f46cb 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,50 @@ 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 $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", +} + + +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 == 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: + 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