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
26 changes: 19 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,8 @@ ws = NewsDataApiWebSocket(
```python
from newsdataapi import (
NewsdataAPIError,
NewsdataApiKeyLimitExceededError,
NewsdataApiLimitExceededError,
NewsdataAuthError,
NewsdataNetworkError,
NewsdataRateLimitError,
Expand All @@ -178,6 +180,10 @@ try:
client.latest_api(q="news")
except NewsdataAuthError as e:
print(f"bad API key (HTTP {e.status_code})")
except NewsdataApiKeyLimitExceededError:
print("this API key's own credit cap is exhausted")
except NewsdataApiLimitExceededError:
print("account credit pool is exhausted")
except NewsdataRateLimitError as e:
print(f"rate limited; retry after {e.retry_after}s")
except NewsdataAPIError as e:
Expand All @@ -190,18 +196,24 @@ The full hierarchy:

```
NewsdataException
├── NewsdataValidationError (also a ValueError; carries .param)
├── NewsdataAPIError (carries .status_code, .response_body)
│ ├── NewsdataAuthError (401 / 403)
│ ├── NewsdataRateLimitError (429; carries .retry_after)
│ └── NewsdataServerError (5xx)
├── NewsdataNetworkError (carries .original)
└── NewsdataWebSocketError (real-time stream)
├── NewsdataValidationError (also a ValueError; carries .param)
├── NewsdataAPIError (carries .status_code, .response_body)
│ ├── NewsdataAuthError (401 / 403)
│ ├── NewsdataRateLimitError (429; carries .retry_after)
│ │ ├── NewsdataApiLimitExceededError (account credit pool exhausted)
│ │ └── NewsdataApiKeyLimitExceededError (this API key's own credit cap exhausted)
│ └── NewsdataServerError (5xx)
├── NewsdataNetworkError (carries .original)
└── NewsdataWebSocketError (real-time stream)
└── NewsdataWebSocketAuthError (handshake 401 / 403, or policy-violation close 1008)
```

`NewsdataApiLimitExceededError` and `NewsdataApiKeyLimitExceededError` are both subclasses of `NewsdataRateLimitError`, so existing code that only catches `NewsdataRateLimitError` keeps working unchanged — catch the specific subclasses only if you want to react differently to each. Neither is ever retried.

`NewsdataException` is always a valid catch-all.

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
6 changes: 5 additions & 1 deletion src/newsdataapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from .csv_export import save_to_csv
from .exceptions import (
NewsdataAPIError,
NewsdataApiKeyLimitExceededError,
NewsdataApiLimitExceededError,
NewsdataAuthError,
NewsdataException,
NewsdataNetworkError,
Expand All @@ -15,12 +17,14 @@
)
from .websocket import NewsDataApiWebSocket

__version__ = "0.3.3"
__version__ = "0.3.4"

__all__ = [
"NewsDataApiClient",
"NewsDataApiWebSocket",
"NewsdataAPIError",
"NewsdataApiKeyLimitExceededError",
"NewsdataApiLimitExceededError",
"NewsdataAuthError",
"NewsdataException",
"NewsdataNetworkError",
Expand Down
28 changes: 19 additions & 9 deletions src/newsdataapi/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from . import constants, csv_export
from .exceptions import (
NewsdataAPIError,
NewsdataApiKeyLimitExceededError,
NewsdataApiLimitExceededError,
NewsdataAuthError,
NewsdataException,
NewsdataNetworkError,
Expand Down Expand Up @@ -85,7 +87,10 @@
("domain", "domainurl", "excludedomain", "excludedomainurl"),
)

_QUOTA_EXHAUSTED_CODES = frozenset({"ApiKeyLimitExceeded", "ApiLimitExceeded"})
_QUOTA_EXHAUSTED_ERRORS: dict[str, type[NewsdataRateLimitError]] = {
"ApiLimitExceeded": NewsdataApiLimitExceededError,
"ApiKeyLimitExceeded": NewsdataApiKeyLimitExceededError,
}


def _validate_params(user_params: Mapping[str, Any]) -> dict[str, Any]:
Expand Down Expand Up @@ -1190,13 +1195,19 @@ def _request(
continue

elapsed = time.perf_counter() - t0
logger.debug(
"%d %s (%.2fs) X-API-Limit-Remaining=%s",
apikey_limit_remaining = response.headers.get("X-APIKEY-Limit-Remaining")
log_msg = "%d %s (%.2fs) X-API-Limit-Remaining=%s"
log_args = [
response.status_code,
response.reason,
elapsed,
response.headers.get("X-API-Limit-Remaining"),
)
]
if apikey_limit_remaining is not None:
# Only sent for API keys that have a credit cap set.
log_msg += " X-APIKEY-Limit-Remaining=%s"
log_args.append(apikey_limit_remaining)
logger.debug(log_msg, *log_args)

try:
body: Any = response.json()
Expand Down Expand Up @@ -1230,11 +1241,10 @@ 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
):
raise NewsdataRateLimitError(
error_code = _extract_error_code(body)
exc_cls = _QUOTA_EXHAUSTED_ERRORS.get(error_code or "", NewsdataRateLimitError)
if error_code in _QUOTA_EXHAUSTED_ERRORS or attempt >= self.max_retries:
raise exc_cls(
body,
status_code=429,
response_body=body if isinstance(body, dict) else None,
Expand Down
17 changes: 16 additions & 1 deletion src/newsdataapi/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def __init__(
message: Any,
*,
status_code: int | None = None,
response_body: dict[str, Any] | None = None,
response_body: dict[str, Any] | None = None
) -> None:
super().__init__(message)
self.status_code = status_code
Expand Down Expand Up @@ -76,6 +76,21 @@ def __init__(
self.retry_after = retry_after


class NewsdataApiLimitExceededError(NewsdataRateLimitError):
"""Raised on 429 when the account's overall credit pool is exhausted.

Blocks every API key on the account, regardless of any per-key cap.
Never retried.
"""


class NewsdataApiKeyLimitExceededError(NewsdataRateLimitError):
"""Raised on 429 when this specific API key's own credit cap
(``max_credit_limit``) is exhausted, even though the account's overall
pool may still have credits left. Never retried.
"""


class NewsdataServerError(NewsdataAPIError):
"""Raised on 5xx responses when retries are exhausted."""

Expand Down
37 changes: 34 additions & 3 deletions tests/test_unit.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
from newsdataapi import (
NewsDataApiClient,
NewsdataAPIError,
NewsdataApiKeyLimitExceededError,
NewsdataApiLimitExceededError,
NewsDataApiWebSocket,
NewsdataAuthError,
NewsdataException,
Expand Down Expand Up @@ -728,6 +730,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 @@ -835,18 +857,27 @@ def test_429_exhausted_raises_rate_limit_with_retry_after(
assert exc_info.value.retry_after == 5


@pytest.mark.parametrize("code", ["ApiKeyLimitExceeded", "ApiLimitExceeded"])
@pytest.mark.parametrize(
("code", "expected_exc"),
[
("ApiLimitExceeded", NewsdataApiLimitExceededError),
("ApiKeyLimitExceeded", NewsdataApiKeyLimitExceededError),
],
)
def test_429_quota_exhausted_raises_without_retry(
code: str,
expected_exc: type[NewsdataRateLimitError],
client: NewsDataApiClient,
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,
and raises the specific subclass for that code."""
body = {"status": "error", "results": {"message": "limit", "code": code}}
mocked_responses.get(LATEST_URL, json=body, status=429)
with pytest.raises(NewsdataRateLimitError) as exc_info:
with pytest.raises(expected_exc) as exc_info:
client.latest_api()
assert isinstance(exc_info.value, NewsdataRateLimitError)
assert len(mocked_responses.calls) == 1
assert exc_info.value.response_body == body

Expand Down