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 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