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
14 changes: 14 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
15 changes: 14 additions & 1 deletion src/core/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
ENDPOINTS,
ENDPOINT_METHODS,
RESULTS_OPTIONAL,
QUOTA_EXHAUSTED_CODES,
WS_NEWS_TYPE,
DEFAULT_REQUEST_TIMEOUT,
DEFAULT_MAX_RETRIES,
Expand All @@ -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;
Expand Down Expand Up @@ -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),
Expand Down
11 changes: 11 additions & 0 deletions src/core/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
38 changes: 38 additions & 0 deletions test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading