Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 29 additions & 3 deletions src/newsdata_mcp/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -41,6 +43,7 @@
MAX_RETRIES,
NEWSDATA_API_KEY,
NEWSDATA_BASE_URL,
QUOTA_EXHAUSTED_CODES,
REQUEST_TIMEOUT,
RETRY_BACKOFF,
RETRY_BACKOFF_MAX,
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions src/newsdata_mcp/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
78 changes: 78 additions & 0 deletions tests/test_http_quota.py
Original file line number Diff line number Diff line change
@@ -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
Loading