From 9be8c29b9157be7d9cb6c4099add156b17152010 Mon Sep 17 00:00:00 2001 From: arjunjain Date: Thu, 3 Sep 2026 06:40:33 +0530 Subject: [PATCH 1/2] 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/core/client.js | 15 ++++++++++++++- src/core/constants.js | 11 +++++++++++ test/client.test.js | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/core/client.js b/src/core/client.js index ddeaa05..f064000 100644 --- a/src/core/client.js +++ b/src/core/client.js @@ -5,6 +5,7 @@ import { ENDPOINTS, ENDPOINT_METHODS, RESULTS_OPTIONAL, + QUOTA_EXHAUSTED_CODES, WS_NEWS_TYPE, DEFAULT_REQUEST_TIMEOUT, DEFAULT_MAX_RETRIES, @@ -30,6 +31,16 @@ export function redactApiKey(url) { return url.replace(/(apikey=)[^&]*/i, '$1REDACTED'); } +/** + * Whether a 429 body carries an error code meaning the account is out of API + * credits, as opposed to a transient rate limit. + */ +function isQuotaExhausted(body) { + const results = asObject(body)?.results; + const code = results && typeof results === 'object' ? results.code : undefined; + return typeof code === 'string' && QUOTA_EXHAUSTED_CODES.includes(code); +} + /** Parse a Retry-After header (integer seconds or HTTP-date) into ms. */ function parseRetryAfter(value) { if (value === null || value === undefined) return null; @@ -294,7 +305,9 @@ export class NewsDataApiClient { if (status === 429) { const retryAfter = parseRetryAfter(res.headers.get('retry-after')); - if (attempt >= this.#maxRetries) { + // A 429 covers a burst limit, a rate limit, and exhausted API + // credits. Only the first two are worth retrying. + if (isQuotaExhausted(body) || attempt >= this.#maxRetries) { throw new NewsdataRateLimitError( this.#errorMessage(body, status), 429, asObject(body), retryAfter === null ? null : Math.round(retryAfter / 1000), diff --git a/src/core/constants.js b/src/core/constants.js index e1b7de0..56799f3 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -56,6 +56,17 @@ export const WS_RECONNECT_DELAY = 1_000; // ms before the first reconnect; doubl export const WS_RECONNECT_DELAY_MAX = 30_000; // cap on the reconnect delay export const WS_OPEN_TIMEOUT = 10_000; // ms to wait for the opening handshake +// 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. +export const QUOTA_EXHAUSTED_CODES = Object.freeze([ + 'ApiLimitExceeded', 'ApiKeyLimitExceeded', +]); + // Endpoints that require both from_date and to_date. export const REQUIRES_DATE_RANGE = Object.freeze(['count', 'crypto_count', 'market_count']); diff --git a/test/client.test.js b/test/client.test.js index 33fdebf..3111a29 100644 --- a/test/client.test.js +++ b/test/client.test.js @@ -129,3 +129,41 @@ test('redactApiKey hides the key', () => { 'https://newsdata.io/api/1/latest?apikey=REDACTED&q=foo', ); }); + +// A 429 covers a burst limit, a rate limit, and exhausted API credits. Only +// the first two are worth retrying. +for (const code of ['ApiLimitExceeded', 'ApiKeyLimitExceeded']) { + test(`429 with ${code} is not retried`, async () => { + const body = { status: 'error', results: { message: 'limit', code } }; + const fetchStub = stubFetch([ + mockResponse(429, body), mockResponse(429, body), mockResponse(429, body), + ]); + const client = new NewsDataApiClient('key', { + fetch: fetchStub, retryBackoff: 1, retryBackoffMax: 1, + }); + + await assert.rejects( + () => client.latestApi({ q: 'x' }), + (err) => { + assert.ok(err instanceof NewsdataRateLimitError, `got ${err.name}`); + assert.deepEqual(err.responseBody, body); + return true; + }, + ); + assert.equal(fetchStub.calls.length, 1, 'exhausted quota must not retry'); + }); +} + +test('429 without a quota code still retries', async () => { + const fetchStub = stubFetch([ + mockResponse(429, { status: 'error', results: { message: 'slow', code: 'RateLimitExceeded' } }), + mockResponse(200, ok([{ title: 'recovered' }])), + ]); + const client = new NewsDataApiClient('key', { + fetch: fetchStub, retryBackoff: 1, retryBackoffMax: 1, + }); + + const res = await client.latestApi({ q: 'x' }); + assert.equal(res.results[0].title, 'recovered'); + assert.equal(fetchStub.calls.length, 2); +}); From 33376b3bbd582fa2d4e8c2a3a0f059caf6ee6828 Mon Sep 17 00:00:00 2001 From: arjunjain Date: Thu, 3 Sep 2026 09:07:31 +0530 Subject: [PATCH 2/2] ci: skip the npm publish when the version already exists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm rejects a version that is already released, so re-running the release workflow fails on a release that published perfectly well — as happened on newsdata-java-sdk's v0.2.0. npm publish has no skip flag, so check with npm view before publishing. --- .github/workflows/publish.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 52eb8b9..364731a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,6 +61,19 @@ jobs: exit 1 fi + # npm rejects a version that already exists, so a re-run of this + # workflow would fail on a release that published perfectly well. + - name: Skip if this version is already on npm + id: published + run: | + VERSION="$(node -p "require('./package.json').version")" + if npm view "newsdataapi@$VERSION" version >/dev/null 2>&1; then + echo "already=true" >> "$GITHUB_OUTPUT" + echo "::notice::newsdataapi@$VERSION is already on npm; skipping the publish (versions are immutable). Bump the version to publish again." + else + echo "already=false" >> "$GITHUB_OUTPUT" + fi + # Tokenless OIDC trusted publishing — no NPM_TOKEN required. Provenance is # generated automatically. One-time setup on npmjs.com: open the package # → Settings → Trusted Publisher → add a GitHub Actions publisher with @@ -69,4 +82,5 @@ jobs: # Workflow filename: publish.yml # Environment: npm (optional; matches `environment:` above) - name: Publish to npm + if: steps.published.outputs.already != 'true' run: npm publish --provenance --access public