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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/discolike-cli/src/discolike_cli/_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +39,7 @@
ValidationError: 2,
AuthenticationError: 3,
PlanAccessError: 3,
InsufficientBalanceError: 3,
RateLimitError: 4,
APIConnectionError: 5,
NotFoundError: 6,
Expand All @@ -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",
Expand Down Expand Up @@ -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)

Expand Down
14 changes: 14 additions & 0 deletions packages/discolike-cli/tests/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
],
)
Expand Down Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions packages/discolike/src/discolike/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -51,6 +52,7 @@
"EmailJobResult",
"EnumerationMatch",
"EnumerationOutput",
"InsufficientBalanceError",
"Job",
"JobFailedError",
"JobStatus",
Expand Down
43 changes: 43 additions & 0 deletions packages/discolike/src/discolike/_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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): ...


Expand Down Expand Up @@ -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,
Expand All @@ -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', '')}"
Expand All @@ -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)
48 changes: 48 additions & 0 deletions packages/discolike/tests/test_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading