Skip to content

Commit acf55ea

Browse files
fix: don't preflight-block unmetered (demo) rate-limit snapshots
Demo/unauthenticated responses carry x-api-ratelimit-limit=0, remaining=0 with a future reset on every successful (203) call — the API's "unmetered" signal, not an exhausted quota. The §10.3 preflight gate read that remaining=0 as exhaustion and short-circuited every request after the first demo response, breaking demo mode for the AAPL options/stocks/funds data the API serves without a token. Treat limit==0 as unmetered and allow the request; a genuine exhaustion is limit>0 && remaining==0 (still blocked). Adds a covering unit test (preflightAllowsWhenSnapshotIsUnmeteredDemo).
1 parent 7dc5cdb commit acf55ea

2 files changed

Lines changed: 37 additions & 3 deletions

File tree

src/main/java/com/marketdata/sdk/HttpTransport.java

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,8 +210,15 @@ private static boolean isServerHintedRetry(@Nullable Throwable previousCause) {
210210
/**
211211
* Returns a {@link RateLimitError} when the last-known snapshot reports zero remaining credits
212212
* <em>and</em> the snapshot's {@code reset} timestamp is still in the future. Returns {@code
213-
* null} when the request is allowed (credits available, no snapshot yet, or the reset window has
214-
* elapsed — the snapshot is stale and the next response's headers will refresh it).
213+
* null} when the request is allowed (credits available, an unmetered demo snapshot, no snapshot
214+
* yet, or the reset window has elapsed — the snapshot is stale and the next response's headers
215+
* will refresh it).
216+
*
217+
* <p>The {@code limit == 0} guard is what keeps demo mode working: unauthenticated/demo responses
218+
* carry {@code limit=0, remaining=0} on every successful (HTTP 203) call — the API's "unmetered"
219+
* signal, not an exhausted quota. Treating that {@code remaining=0} as exhaustion would block
220+
* every request after the first demo response (e.g. AAPL options/stocks/funds the API serves
221+
* freely without a token). A genuine exhaustion is {@code limit > 0 && remaining == 0}.
215222
*
216223
* <p>Without the reset check, a single response carrying {@code remaining=0} would freeze the
217224
* client forever: the preflight would short-circuit every subsequent request, no request would
@@ -220,7 +227,7 @@ private static boolean isServerHintedRetry(@Nullable Throwable previousCause) {
220227
*/
221228
private @Nullable RateLimitError checkRateLimitPreflight(URI uri) {
222229
RateLimitSnapshot snap = latestRateLimits.get();
223-
if (snap == null || snap.remaining() > 0) {
230+
if (snap == null || snap.limit() == 0 || snap.remaining() > 0) {
224231
return null;
225232
}
226233
Instant now = clock.instant();

src/test/java/com/marketdata/sdk/HttpTransportTest.java

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,33 @@ void preflightAllowsWhenSnapshotShowsCreditsRemaining() {
483483
assertThat(client.captured).hasSize(2);
484484
}
485485

486+
/**
487+
* Demo / unauthenticated responses carry {@code limit=0, remaining=0} on every successful (203)
488+
* call — the API's "unmetered" signal, NOT an exhausted quota. The preflight must let every
489+
* subsequent request through; otherwise demo mode breaks after the first call (the SDK would
490+
* block AAPL options/stocks/funds that the API serves freely without a token).
491+
*/
492+
@Test
493+
void preflightAllowsWhenSnapshotIsUnmeteredDemo() {
494+
long resetEpoch = Instant.now().plus(Duration.ofHours(1)).getEpochSecond();
495+
HttpHeaders demo =
496+
TestHttpClients.headersOf(
497+
Map.of(
498+
"x-api-ratelimit-limit", "0",
499+
"x-api-ratelimit-remaining", "0",
500+
"x-api-ratelimit-reset", String.valueOf(resetEpoch),
501+
"x-api-ratelimit-consumed", "0"));
502+
CapturingClient client = new CapturingClient(203, "ok".getBytes(), demo);
503+
HttpTransport transport = newTransport(client);
504+
505+
// First call lands the unmetered snapshot (limit=0, remaining=0, reset in the future).
506+
transport.executeAsync(RequestSpec.get("options/chain/AAPL").build()).join();
507+
// Second call must still reach the wire — limit=0 means unmetered, not exhausted.
508+
transport.executeAsync(RequestSpec.get("options/chain/AAPL").build()).join();
509+
510+
assertThat(client.captured).hasSize(2);
511+
}
512+
486513
/**
487514
* Before any rate-limit-bearing response has arrived, the snapshot is {@code null} — the first
488515
* request must NOT be blocked despite there being "zero" remaining in the EMPTY sentinel. The

0 commit comments

Comments
 (0)