diff --git a/README.md b/README.md index 22c658b..9267c1f 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ ws = NewsDataApiWebSocket( ```python from newsdataapi import ( NewsdataAPIError, + NewsdataApiKeyLimitExceededError, + NewsdataApiLimitExceededError, NewsdataAuthError, NewsdataNetworkError, NewsdataRateLimitError, @@ -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: @@ -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 diff --git a/src/newsdataapi/__init__.py b/src/newsdataapi/__init__.py index 4d0efbe..a01c6f4 100644 --- a/src/newsdataapi/__init__.py +++ b/src/newsdataapi/__init__.py @@ -4,6 +4,8 @@ from .csv_export import save_to_csv from .exceptions import ( NewsdataAPIError, + NewsdataApiKeyLimitExceededError, + NewsdataApiLimitExceededError, NewsdataAuthError, NewsdataException, NewsdataNetworkError, @@ -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", diff --git a/src/newsdataapi/client.py b/src/newsdataapi/client.py index d021c62..8b5cf11 100644 --- a/src/newsdataapi/client.py +++ b/src/newsdataapi/client.py @@ -18,6 +18,8 @@ from . import constants, csv_export from .exceptions import ( NewsdataAPIError, + NewsdataApiKeyLimitExceededError, + NewsdataApiLimitExceededError, NewsdataAuthError, NewsdataException, NewsdataNetworkError, @@ -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]: @@ -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() @@ -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, diff --git a/src/newsdataapi/exceptions.py b/src/newsdataapi/exceptions.py index efaa3f6..5cfbda2 100644 --- a/src/newsdataapi/exceptions.py +++ b/src/newsdataapi/exceptions.py @@ -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 @@ -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.""" diff --git a/tests/test_unit.py b/tests/test_unit.py index 9a2b8b0..91a1727 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -19,6 +19,8 @@ from newsdataapi import ( NewsDataApiClient, NewsdataAPIError, + NewsdataApiKeyLimitExceededError, + NewsdataApiLimitExceededError, NewsDataApiWebSocket, NewsdataAuthError, NewsdataException, @@ -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, @@ -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