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 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); +});