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,