From e3968c10a948c775117e8f3439c0563ca91d964d Mon Sep 17 00:00:00 2001 From: chirag Date: Thu, 22 Oct 2026 17:13:21 +0530 Subject: [PATCH 1/2] 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( From dad1c974d770693ab6eaae8e5dd6ccbb1eb1b81f Mon Sep 17 00:00:00 2001 From: chirag Date: Mon, 26 Oct 2026 11:49:47 +0530 Subject: [PATCH 2/2] Add NewsdataApiLimitExceededError and NewsdataApiKeyLimitExceededError --- README.md | 36 ++++++++++++++++++----------------- src/newsdataapi/__init__.py | 4 ++++ src/newsdataapi/client.py | 29 +++++++++++++++++----------- src/newsdataapi/exceptions.py | 33 ++++++++++++++++---------------- tests/test_unit.py | 26 +++++++++++++------------ 5 files changed, 71 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 50b71be..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,15 +180,14 @@ 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: - 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") + print(f"rate limited; retry after {e.retry_after}s") except NewsdataAPIError as e: - print(f"API error {e.status_code} ({e.code}): {e.response_body}") + print(f"API error {e.status_code}: {e.response_body}") except NewsdataNetworkError as e: print(f"network failure: {e.original}") ``` @@ -195,20 +196,21 @@ The full hierarchy: ``` NewsdataException -├── NewsdataValidationError (also a ValueError; carries .param) -├── NewsdataAPIError (carries .status_code, .response_body, .code) -│ ├── 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) ``` -`NewsdataException` is always a valid catch-all. +`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. -`.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. +`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). diff --git a/src/newsdataapi/__init__.py b/src/newsdataapi/__init__.py index 9f5ec8a..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, @@ -21,6 +23,8 @@ "NewsDataApiClient", "NewsDataApiWebSocket", "NewsdataAPIError", + "NewsdataApiKeyLimitExceededError", + "NewsdataApiLimitExceededError", "NewsdataAuthError", "NewsdataException", "NewsdataNetworkError", diff --git a/src/newsdataapi/client.py b/src/newsdataapi/client.py index 564507d..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,14 +1195,19 @@ def _request( continue elapsed = time.perf_counter() - t0 - logger.debug( - "%d %s (%.2fs) X-API-Limit-Remaining=%s X-APIKEY-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"), - response.headers.get("X-APIKEY-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() @@ -1232,12 +1242,12 @@ def _request( if response.status_code == 429: retry_after = _parse_retry_after(response.headers.get("Retry-After")) error_code = _extract_error_code(body) - if error_code in _QUOTA_EXHAUSTED_CODES or attempt >= self.max_retries: - raise NewsdataRateLimitError( + 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, - code=error_code, retry_after=retry_after, ) sleep_for = ( @@ -1261,7 +1271,6 @@ 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)", @@ -1278,7 +1287,6 @@ 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. @@ -1286,7 +1294,6 @@ 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 b5aa995..5cfbda2 100644 --- a/src/newsdataapi/exceptions.py +++ b/src/newsdataapi/exceptions.py @@ -33,9 +33,6 @@ 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__( @@ -43,13 +40,11 @@ def __init__( message: Any, *, status_code: int | None = None, - response_body: dict[str, Any] | None = None, - code: str | None = None, + response_body: dict[str, Any] | None = None ) -> None: super().__init__(message) self.status_code = status_code self.response_body = response_body - self.code = code class NewsdataAuthError(NewsdataAPIError): @@ -59,15 +54,6 @@ 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 @@ -80,18 +66,31 @@ 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 +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 f83d5b0..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, @@ -786,7 +788,6 @@ 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( @@ -854,30 +855,31 @@ 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"]) +@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. - - ``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``. - """ + """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 - assert exc_info.value.code == code def test_500_then_200_succeeds(