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
13 changes: 13 additions & 0 deletions src/main/java/io/newsdata/api/Constants.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>{@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<String> QUOTA_EXHAUSTED_CODES =
Set.of("ApiLimitExceeded", "ApiKeyLimitExceeded");

/** Endpoints that require both {@code from_date} and {@code to_date}. */
public static final Set<String> REQUIRES_DATE_RANGE =
Set.of("count", "crypto_count", "market_count");
Expand Down
17 changes: 16 additions & 1 deletion src/main/java/io/newsdata/api/NewsDataApiClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ NewsdataResponse request(String endpoint, Map<String, Object> 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);
Expand Down Expand Up @@ -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");
Expand Down
48 changes: 48 additions & 0 deletions src/test/java/io/newsdata/api/NewsDataApiClientTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> 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,
Expand Down
Loading