From e3968c10a948c775117e8f3439c0563ca91d964d Mon Sep 17 00:00:00 2001 From: chirag Date: Thu, 22 Oct 2026 17:13:21 +0530 Subject: [PATCH] Expose API error code on exceptions; surface apikey credit-cap header --- README.md | 16 +++++++++++++--- src/newsdataapi/__init__.py | 2 +- src/newsdataapi/client.py | 13 ++++++++----- src/newsdataapi/exceptions.py | 16 ++++++++++++++++ tests/test_unit.py | 31 ++++++++++++++++++++++++++++++- 5 files changed, 68 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 22c658b..50b71be 100644 --- a/README.md +++ b/README.md @@ -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}") ``` @@ -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) @@ -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 diff --git a/src/newsdataapi/__init__.py b/src/newsdataapi/__init__.py index 4d0efbe..9f5ec8a 100644 --- a/src/newsdataapi/__init__.py +++ b/src/newsdataapi/__init__.py @@ -15,7 +15,7 @@ ) from .websocket import NewsDataApiWebSocket -__version__ = "0.3.3" +__version__ = "0.3.4" __all__ = [ "NewsDataApiClient", diff --git a/src/newsdataapi/client.py b/src/newsdataapi/client.py index d021c62..564507d 100644 --- a/src/newsdataapi/client.py +++ b/src/newsdataapi/client.py @@ -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: @@ -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 = ( @@ -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)", @@ -1277,6 +1278,7 @@ 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. @@ -1284,6 +1286,7 @@ def _request( 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 diff --git a/src/newsdataapi/exceptions.py b/src/newsdataapi/exceptions.py index efaa3f6..b5aa995 100644 --- a/src/newsdataapi/exceptions.py +++ b/src/newsdataapi/exceptions.py @@ -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__( @@ -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): @@ -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 @@ -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 diff --git a/tests/test_unit.py b/tests/test_unit.py index 9a2b8b0..f83d5b0 100644 --- a/tests/test_unit.py +++ b/tests/test_unit.py @@ -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, @@ -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( @@ -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"]) @@ -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(