Skip to content
Open
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
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,9 +179,14 @@ try:
except NewsdataAuthError as e:
print(f"bad API key (HTTP {e.status_code})")
except NewsdataRateLimitError as e:
print(f"rate limited; retry after {e.retry_after}s")
if e.code == "ApiKeyLimitExceeded":
print("this API key's own credit cap is exhausted")
elif e.code == "ApiLimitExceeded":
print("account credit pool is exhausted")
else:
print(f"rate limited; retry after {e.retry_after}s")
except NewsdataAPIError as e:
print(f"API error {e.status_code}: {e.response_body}")
print(f"API error {e.status_code} ({e.code}): {e.response_body}")
except NewsdataNetworkError as e:
print(f"network failure: {e.original}")
```
Expand All @@ -191,7 +196,7 @@ The full hierarchy:
```
NewsdataException
├── NewsdataValidationError (also a ValueError; carries .param)
├── NewsdataAPIError (carries .status_code, .response_body)
├── NewsdataAPIError (carries .status_code, .response_body, .code)
│ ├── NewsdataAuthError (401 / 403)
│ ├── NewsdataRateLimitError (429; carries .retry_after)
│ └── NewsdataServerError (5xx)
Expand All @@ -202,6 +207,11 @@ NewsdataException

`NewsdataException` is always a valid catch-all.

`.code` is the API's own error code (``response_body["results"]["code"]``), when present. On a 429 it tells you *why*:
`"ApiLimitExceeded"` means the account's overall credit pool is exhausted (blocks every API key on the account); `"ApiKeyLimitExceeded"` means this specific API key has its own credit cap (`max_credit_limit`, set per key in the dashboard) and that cap — not the account pool — is exhausted. Either way the request is never retried. Any other 429 (or no code at all) is ordinary rate limiting and is retried with backoff until `max_retries` is reached.

When a request succeeds and `include_headers=True`, the response dict's `response_headers` also carries `X-API-Limit-Remaining` (account pool) and, for API keys with a credit cap, `X-APIKEY-Limit-Remaining` (that key's own remaining credits).

## Save results to CSV

```python
Expand Down
2 changes: 1 addition & 1 deletion src/newsdataapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)
from .websocket import NewsDataApiWebSocket

__version__ = "0.3.3"
__version__ = "0.3.4"

__all__ = [
"NewsDataApiClient",
Expand Down
13 changes: 8 additions & 5 deletions src/newsdataapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -1191,11 +1191,12 @@ def _request(

elapsed = time.perf_counter() - t0
logger.debug(
"%d %s (%.2fs) X-API-Limit-Remaining=%s",
"%d %s (%.2fs) X-API-Limit-Remaining=%s X-APIKEY-Limit-Remaining=%s",
response.status_code,
response.reason,
elapsed,
response.headers.get("X-API-Limit-Remaining"),
response.headers.get("X-APIKEY-Limit-Remaining"),
)

try:
Expand Down Expand Up @@ -1230,14 +1231,13 @@ def _request(
# Rate limit.
if response.status_code == 429:
retry_after = _parse_retry_after(response.headers.get("Retry-After"))
if (
_extract_error_code(body) in _QUOTA_EXHAUSTED_CODES
or attempt >= self.max_retries
):
error_code = _extract_error_code(body)
if error_code in _QUOTA_EXHAUSTED_CODES or attempt >= self.max_retries:
raise NewsdataRateLimitError(
body,
status_code=429,
response_body=body if isinstance(body, dict) else None,
code=error_code,
retry_after=retry_after,
)
sleep_for = (
Expand All @@ -1261,6 +1261,7 @@ def _request(
body,
status_code=response.status_code,
response_body=body if isinstance(body, dict) else None,
code=_extract_error_code(body),
)
logger.warning(
"%d server error (attempt %d/%d)",
Expand All @@ -1277,13 +1278,15 @@ def _request(
body,
status_code=response.status_code,
response_body=body if isinstance(body, dict) else None,
code=_extract_error_code(body),
)

# Other 4xx — never retry.
raise NewsdataAPIError(
body,
status_code=response.status_code,
response_body=body if isinstance(body, dict) else None,
code=_extract_error_code(body),
)

# Defensive: the loop always either returns or raises. This is
Expand Down
16 changes: 16 additions & 0 deletions src/newsdataapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ class NewsdataAPIError(NewsdataException):
Attributes:
status_code: HTTP status returned by the API.
response_body: Parsed JSON body of the error response, when available.
code: The API's own error code (``response_body["results"]["code"]``),
e.g. ``"ApiLimitExceeded"`` or ``"ApiKeyLimitExceeded"``, when
present. ``None`` if the body didn't carry one.
"""

def __init__(
Expand All @@ -41,10 +44,12 @@ def __init__(
*,
status_code: int | None = None,
response_body: dict[str, Any] | None = None,
code: str | None = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
self.code = code


class NewsdataAuthError(NewsdataAPIError):
Expand All @@ -54,6 +59,15 @@ class NewsdataAuthError(NewsdataAPIError):
class NewsdataRateLimitError(NewsdataAPIError):
"""Raised on 429 responses when retries are exhausted.

A 429 can mean three different things, distinguished by :attr:`code`:

* ``"ApiLimitExceeded"`` — the account's overall credit pool is exhausted.
* ``"ApiKeyLimitExceeded"`` — this specific API key has its own credit
cap (``max_credit_limit``), and that cap is exhausted, even though the
account's pool may still have credits left.
* Anything else (or ``None``) — ordinary rate limiting; back off and
retry.

Attributes:
retry_after: Seconds to wait before retrying, parsed from the
``Retry-After`` header when available. May be ``None`` if the
Expand All @@ -66,12 +80,14 @@ def __init__(
*,
status_code: int | None = 429,
response_body: dict[str, Any] | None = None,
code: str | None = None,
retry_after: int | None = None,
) -> None:
super().__init__(
message,
status_code=status_code,
response_body=response_body,
code=code,
)
self.retry_after = retry_after

Expand Down
31 changes: 30 additions & 1 deletion tests/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,6 +728,26 @@ def test_include_headers_merges_into_response(
assert response["response_headers"]["X-API-Limit-Remaining"] == "100"


def test_include_headers_merges_apikey_limit_header(
mocked_responses: responses.RequestsMock,
) -> None:
"""``X-APIKEY-Limit-Remaining`` is only sent for API keys with a credit
cap, but the client passes it through like any other header."""
with NewsDataApiClient("k", include_headers=True, max_retries=1) as c:
mocked_responses.get(
LATEST_URL,
json={"status": "success", "results": []},
status=200,
headers={
"X-API-Limit-Remaining": "100",
"X-APIKEY-Limit-Remaining": "25",
},
)
response = c.latest_api()
assert isinstance(response, dict)
assert response["response_headers"]["X-APIKEY-Limit-Remaining"] == "25"


def test_status_error_with_200_raises_api_error(
client: NewsDataApiClient,
mocked_responses: responses.RequestsMock,
Expand Down Expand Up @@ -766,6 +786,7 @@ def test_401_raises_auth_error(
with pytest.raises(NewsdataAuthError) as exc_info:
client.latest_api()
assert exc_info.value.status_code == 401
assert exc_info.value.code == "Unauthorized"


def test_403_raises_auth_error(
Expand Down Expand Up @@ -833,6 +854,7 @@ def test_429_exhausted_raises_rate_limit_with_retry_after(
with pytest.raises(NewsdataRateLimitError) as exc_info:
client.latest_api()
assert exc_info.value.retry_after == 5
assert exc_info.value.code is None


@pytest.mark.parametrize("code", ["ApiKeyLimitExceeded", "ApiLimitExceeded"])
Expand All @@ -842,13 +864,20 @@ def test_429_quota_exhausted_raises_without_retry(
mocked_responses: responses.RequestsMock,
no_sleep: None,
) -> None:
"""A 429 whose error code means exhausted credits is never retried."""
"""A 429 whose error code means exhausted credits is never retried.

``ApiLimitExceeded`` is the account-wide credit pool; ``ApiKeyLimitExceeded``
is a per-API-key cap (``max_credit_limit``) on top of it. Either way
``.code`` lets the caller tell which one happened without digging into
``response_body``.
"""
body = {"status": "error", "results": {"message": "limit", "code": code}}
mocked_responses.get(LATEST_URL, json=body, status=429)
with pytest.raises(NewsdataRateLimitError) as exc_info:
client.latest_api()
assert len(mocked_responses.calls) == 1
assert exc_info.value.response_body == body
assert exc_info.value.code == code


def test_500_then_200_succeeds(
Expand Down