From 7f82f344068c6dac396538dd31b9bdc710f58934 Mon Sep 17 00:00:00 2001 From: arjunjain Date: Thu, 3 Sep 2026 06:40:33 +0530 Subject: [PATCH 1/2] fix: do not retry a 429 when API credits are exhausted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sync with python-client 0.3.1 (PR #28). A 429 covers three cases — a burst limit, a rate limit, and exhausted API credits — and the client retried all three. Waiting out the backoff cannot conjure more credits, so an exhausted quota burned the full retry budget (~62s on the default five attempts) before surfacing an error that was never going to clear. The 429 branch now reads `results.code` and fails immediately when it means exhausted credits. Transient 429s retry exactly as before. ApiLimitExceeded is the documented code (see the ErrorCode enum in https://newsdata.io/openapi.json, whose 429 response is described as "Too many requests in a short period, rate limit exceeded, or API credits exhausted"). ApiKeyLimitExceeded is accepted too: it is absent from the spec, but python-client sends it and the spec has proven incomplete before, so dropping it would silently miss key-scoped quotas. --- src/newsdata_mcp/http.py | 32 +++++++++++++-- src/newsdata_mcp/settings.py | 9 +++++ tests/test_http_quota.py | 78 ++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 3 deletions(-) create mode 100644 tests/test_http_quota.py diff --git a/src/newsdata_mcp/http.py b/src/newsdata_mcp/http.py index 96b1121..ba95c7a 100644 --- a/src/newsdata_mcp/http.py +++ b/src/newsdata_mcp/http.py @@ -13,7 +13,9 @@ - HTTP 5xx → retry with exponential backoff. - HTTP 429 → retry, honoring ``Retry-After`` (integer seconds or HTTP-date per RFC 7231) when parseable; falling back to exponential - backoff otherwise. + backoff otherwise. The exception is a 429 whose error code means the + account's API credits are exhausted — retrying cannot conjure more + credits, so that is a permanent failure. - HTTP 401/403/422/other 4xx, non-JSON 2xx, soft 200 errors → permanent failure, never retried. @@ -41,6 +43,7 @@ MAX_RETRIES, NEWSDATA_API_KEY, NEWSDATA_BASE_URL, + QUOTA_EXHAUSTED_CODES, REQUEST_TIMEOUT, RETRY_BACKOFF, RETRY_BACKOFF_MAX, @@ -178,6 +181,21 @@ async def close_client() -> None: _client = None +def _quota_exhausted(response: httpx.Response) -> bool: + """Whether a 429 body carries an error code meaning the account is out of + API credits, as opposed to a transient rate limit.""" + try: + body = response.json() + except ValueError: + return False + if not isinstance(body, dict): + return False + results = body.get("results") + if not isinstance(results, dict): + return False + return results.get("code") in QUOTA_EXHAUSTED_CODES + + async def _request_once( endpoint: str, clean: dict[str, Any], method: str = "GET" ) -> tuple[dict[str, Any], bool]: @@ -261,14 +279,22 @@ async def _request_once( False, ) if code == 429: + # A 429 covers a burst limit, a rate limit, and exhausted API + # credits. Only the first two are worth retrying. + exhausted = _quota_exhausted(e.response) envelope: dict[str, Any] = { "status": "error", - "message": "Rate limit exceeded. Try again later.", + "message": ( + "API credits exhausted for this key. Retrying will not " + "help; upgrade the plan or wait for the quota to reset." + if exhausted + else "Rate limit exceeded. Try again later." + ), "status_code": code, } if retry_after is not None: envelope["retry_after"] = retry_after - return envelope, True + return envelope, not exhausted body = (e.response.text or "").strip() if len(body) > 500: diff --git a/src/newsdata_mcp/settings.py b/src/newsdata_mcp/settings.py index 5a8de38..6d6a01c 100644 --- a/src/newsdata_mcp/settings.py +++ b/src/newsdata_mcp/settings.py @@ -50,6 +50,15 @@ ) RETRY_BACKOFF_MAX = 60.0 +# Error codes on a 429 meaning the account's API credits are exhausted rather +# than a transient rate limit. These are never retried — waiting out the +# backoff cannot conjure more credits. +# +# `ApiLimitExceeded` is the documented code (see the ErrorCode enum in +# https://newsdata.io/openapi.json); `ApiKeyLimitExceeded` is accepted too +# because the API has been observed to send it and the spec is not exhaustive. +QUOTA_EXHAUSTED_CODES = frozenset({"ApiLimitExceeded", "ApiKeyLimitExceeded"}) + # Real-time WebSocket endpoint used by the `stream_news` tool. NEWSDATA_WS_URL = os.getenv("NEWSDATA_WS_URL", "wss://ws.newsdata.io/ws/event") diff --git a/tests/test_http_quota.py b/tests/test_http_quota.py new file mode 100644 index 0000000..e69589d --- /dev/null +++ b/tests/test_http_quota.py @@ -0,0 +1,78 @@ +"""A 429 covers a burst limit, a rate limit, and exhausted API credits. + +Only the first two are worth retrying — waiting out the backoff cannot +conjure more credits, so an exhausted quota is a permanent failure. +""" +import httpx +import pytest +import respx + +from newsdata_mcp.http import fetch +from newsdata_mcp.settings import QUOTA_EXHAUSTED_CODES + +LATEST = "https://newsdata.io/api/1/latest" + + +def _body(code: str) -> dict: + return {"status": "error", "results": {"message": "limit", "code": code}} + + +def test_quota_codes_are_the_documented_ones(): + # ApiLimitExceeded is in the spec's ErrorCode enum; the key-scoped variant + # is accepted too because the spec is not exhaustive. + assert "ApiLimitExceeded" in QUOTA_EXHAUSTED_CODES + assert "ApiKeyLimitExceeded" in QUOTA_EXHAUSTED_CODES + # Transient codes must stay out of the set or they would stop retrying. + assert "RateLimitExceeded" not in QUOTA_EXHAUSTED_CODES + assert "TooManyRequests" not in QUOTA_EXHAUSTED_CODES + + +@pytest.mark.parametrize("code", sorted(QUOTA_EXHAUSTED_CODES)) +@respx.mock +async def test_exhausted_quota_is_not_retried(code, monkeypatch): + monkeypatch.setattr("newsdata_mcp.http.NEWSDATA_API_KEY", "test-key") + route = respx.get(LATEST).mock(return_value=httpx.Response(429, json=_body(code))) + + envelope = await fetch("latest", {"q": "x"}) + + assert envelope["status"] == "error" + assert envelope["status_code"] == 429 + assert "credits exhausted" in envelope["message"] + assert route.call_count == 1, "exhausted quota must not retry" + + +@respx.mock +async def test_transient_429_still_retries(monkeypatch): + monkeypatch.setattr("newsdata_mcp.http.NEWSDATA_API_KEY", "test-key") + monkeypatch.setattr("newsdata_mcp.http.RETRY_BACKOFF", 0.001) + monkeypatch.setattr("newsdata_mcp.http.RETRY_BACKOFF_MAX", 0.001) + route = respx.get(LATEST).mock( + side_effect=[ + httpx.Response(429, json=_body("RateLimitExceeded")), + httpx.Response(200, json={"status": "success", "results": []}), + ] + ) + + envelope = await fetch("latest", {"q": "x"}) + + assert envelope["status"] == "success" + assert route.call_count == 2, "a transient rate limit should still retry" + + +@respx.mock +async def test_429_without_a_code_still_retries(monkeypatch): + """No error code at all is treated as transient, as before.""" + monkeypatch.setattr("newsdata_mcp.http.NEWSDATA_API_KEY", "test-key") + monkeypatch.setattr("newsdata_mcp.http.RETRY_BACKOFF", 0.001) + monkeypatch.setattr("newsdata_mcp.http.RETRY_BACKOFF_MAX", 0.001) + route = respx.get(LATEST).mock( + side_effect=[ + httpx.Response(429, json={"status": "error"}), + httpx.Response(200, json={"status": "success", "results": []}), + ] + ) + + envelope = await fetch("latest", {"q": "x"}) + + assert envelope["status"] == "success" + assert route.call_count == 2 From 1910cf634c1016099f84d619c2a33cc8b58e6652 Mon Sep 17 00:00:00 2001 From: arjunjain Date: Thu, 3 Sep 2026 09:07:20 +0530 Subject: [PATCH 2/2] ci: skip the PyPI publish when the version already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PyPI rejects a version that is already released, so re-running the release workflow fails on a release that published perfectly well — as happened on newsdata-java-sdk's v0.2.0. pypa/gh-action-pypi-publish has a native skip-existing flag for exactly this. --- .github/workflows/release.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 062e41f..4fe14d3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -67,8 +67,13 @@ jobs: name: dist path: dist/ + # PyPI rejects a version that already exists, so a re-run of this + # workflow would fail on a release that published perfectly well. + # skip-existing turns that into a no-op. - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + skip-existing: true github-release: name: Create GitHub Release