From 6b27c2be2591049086a9d895d526a22ec7e3a0c2 Mon Sep 17 00:00:00 2001 From: arjunjain Date: Thu, 3 Sep 2026 06:40:33 +0530 Subject: [PATCH] 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/main/java/io/newsdata/api/Constants.java | 13 +++++ .../io/newsdata/api/NewsDataApiClient.java | 17 ++++++- .../newsdata/api/NewsDataApiClientTest.java | 48 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/src/main/java/io/newsdata/api/Constants.java b/src/main/java/io/newsdata/api/Constants.java index 99d266b..fd3e55d 100644 --- a/src/main/java/io/newsdata/api/Constants.java +++ b/src/main/java/io/newsdata/api/Constants.java @@ -85,6 +85,19 @@ private Constants() {} /** Bound on the opening handshake. */ public static final Duration WS_HANDSHAKE_TIMEOUT = Duration.ofSeconds(10); + /** + * 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. + * + *

{@code ApiLimitExceeded} is the documented code (see the ErrorCode + * enum in https://newsdata.io/openapi.json); {@code ApiKeyLimitExceeded} is + * accepted too because the API has been observed to send it and the spec is + * not exhaustive. + */ + public static final Set QUOTA_EXHAUSTED_CODES = + Set.of("ApiLimitExceeded", "ApiKeyLimitExceeded"); + /** Endpoints that require both {@code from_date} and {@code to_date}. */ public static final Set REQUIRES_DATE_RANGE = Set.of("count", "crypto_count", "market_count"); diff --git a/src/main/java/io/newsdata/api/NewsDataApiClient.java b/src/main/java/io/newsdata/api/NewsDataApiClient.java index 216513d..61173f3 100644 --- a/src/main/java/io/newsdata/api/NewsDataApiClient.java +++ b/src/main/java/io/newsdata/api/NewsDataApiClient.java @@ -356,7 +356,9 @@ NewsdataResponse request(String endpoint, Map params) { if (status == 429) { int retryAfter = parseRetryAfter(resp.headers().firstValue("retry-after").orElse(null)); - if (attempt >= maxRetries) { + // A 429 covers a burst limit, a rate limit, and exhausted API + // credits. Only the first two are worth retrying. + if (quotaExhausted(parsed) || attempt >= maxRetries) { throw new NewsdataRateLimitException(message, 429, body, retryAfter); } Duration wait = retryAfter > 0 ? Duration.ofSeconds(retryAfter) : backoff(attempt); @@ -406,6 +408,19 @@ private void log(String level, String message) { if (logger != null) logger.accept(level, "[newsdataapi] " + message); } + /** + * Whether a 429 body carries an error code meaning the account is out of + * API credits, as opposed to a transient rate limit. + */ + private static boolean quotaExhausted(JsonNode body) { + if (body == null) return false; + JsonNode results = body.get("results"); + if (results == null || !results.isObject()) return false; + JsonNode code = results.get("code"); + return code != null && code.isTextual() + && Constants.QUOTA_EXHAUSTED_CODES.contains(code.asText()); + } + private String errorMessage(JsonNode body, int status) { if (body != null) { JsonNode results = body.get("results"); diff --git a/src/test/java/io/newsdata/api/NewsDataApiClientTest.java b/src/test/java/io/newsdata/api/NewsDataApiClientTest.java index 55944d0..6f6f68f 100644 --- a/src/test/java/io/newsdata/api/NewsDataApiClientTest.java +++ b/src/test/java/io/newsdata/api/NewsDataApiClientTest.java @@ -269,6 +269,54 @@ void articleSymbolAndMarketIdAreNullWhenAbsent() { assertNull(art.marketId()); } + // A 429 covers a burst limit, a rate limit, and exhausted API credits. + // Only the first two are worth retrying. + @Test + void quotaExhausted429IsNotRetried() { + // One context, driven by a mutable code so the loop can reuse it. + AtomicReference code = new AtomicReference<>(); + AtomicInteger calls = new AtomicInteger(); + handle("latest", exchange -> { + calls.incrementAndGet(); + respond(exchange, 429, + "{\"status\":\"error\",\"results\":{\"message\":\"limit\",\"code\":\"" + + code.get() + "\"}}"); + }); + var client = defaultBuilder() + .retryBackoff(Duration.ofMillis(1)) + .retryBackoffMax(Duration.ofMillis(1)) + .build(); + + for (String c : List.of("ApiLimitExceeded", "ApiKeyLimitExceeded")) { + code.set(c); + calls.set(0); + assertThrows(NewsdataRateLimitException.class, + () -> client.latest(Params.of().with("q", "x"))); + assertEquals(1, calls.get(), c + " must not be retried"); + } + } + + @Test + void transient429StillRetries() { + AtomicInteger calls = new AtomicInteger(); + handle("latest", exchange -> { + if (calls.incrementAndGet() == 1) { + respond(exchange, 429, + "{\"status\":\"error\",\"results\":{\"message\":\"slow\",\"code\":\"RateLimitExceeded\"}}"); + return; + } + respond(exchange, 200, successBody("[{\"article_id\":\"1\",\"title\":\"ok\"}]")); + }); + var client = defaultBuilder() + .retryBackoff(Duration.ofMillis(1)) + .retryBackoffMax(Duration.ofMillis(1)) + .build(); + + var resp = client.latest(Params.of().with("q", "x")); + assertEquals("success", resp.status()); + assertEquals(2, calls.get()); + } + @Test void countReturnsAggregateMap() { handle("count", exchange -> respond(exchange, 200,