From 8e9afdabfee684fe13774688104c00a208a973d3 Mon Sep 17 00:00:00 2001 From: arjunjain Date: Thu, 3 Sep 2026 06:40:33 +0530 Subject: [PATCH] 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/Constants.php | 12 ++++++ src/NewsdataApiBase.php | 29 ++++++++++++- tests/QuotaExhaustedTest.php | 83 ++++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 tests/QuotaExhaustedTest.php diff --git a/src/Constants.php b/src/Constants.php index d7ee9ae..c14be61 100644 --- a/src/Constants.php +++ b/src/Constants.php @@ -99,6 +99,18 @@ final class Constants /** Bound on the opening handshake, in seconds. */ public const WS_HANDSHAKE_TIMEOUT = 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. + * + * `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. + */ + public const QUOTA_EXHAUSTED_CODES = ['ApiLimitExceeded', 'ApiKeyLimitExceeded']; + /** Endpoints that require both `from_date` and `to_date`. */ public const REQUIRES_DATE_RANGE = ['count', 'crypto_count', 'market_count']; diff --git a/src/NewsdataApiBase.php b/src/NewsdataApiBase.php index b049eed..6477650 100644 --- a/src/NewsdataApiBase.php +++ b/src/NewsdataApiBase.php @@ -149,7 +149,9 @@ private function execute(string $baseUrl, array $params, string $endpoint = '') $retryAfter = $this->parseRetryAfter( isset($headers['retry_after']) ? $headers['retry_after'] : null ); - if ($attempt >= $attempts) { + // A 429 covers a burst limit, a rate limit, and exhausted API + // credits. Only the first two are worth retrying. + if ($this->quotaExhausted($body) || $attempt >= $attempts) { throw new NewsdataRateLimitError( $this->errorMessage($body, $status), 429, @@ -348,6 +350,31 @@ private function toArray($body): ?array * @param mixed $body * @param int $status */ + /** + * Whether a 429 body carries an error code meaning the account is out of + * API credits, as opposed to a transient rate limit. + * + * @param mixed $body + */ + private function quotaExhausted($body): bool + { + $results = null; + if (is_array($body)) { + $results = $body['results'] ?? null; + } elseif ($body instanceof \stdClass) { + $results = $body->results ?? null; + } + + $code = null; + if (is_array($results)) { + $code = $results['code'] ?? null; + } elseif ($results instanceof \stdClass) { + $code = $results->code ?? null; + } + + return is_string($code) && in_array($code, Constants::QUOTA_EXHAUSTED_CODES, true); + } + private function errorMessage($body, int $status): string { $arr = $this->toArray($body); diff --git a/tests/QuotaExhaustedTest.php b/tests/QuotaExhaustedTest.php new file mode 100644 index 0000000..73e21b0 --- /dev/null +++ b/tests/QuotaExhaustedTest.php @@ -0,0 +1,83 @@ +assertContains('ApiLimitExceeded', Constants::QUOTA_EXHAUSTED_CODES); + $this->assertContains('ApiKeyLimitExceeded', Constants::QUOTA_EXHAUSTED_CODES); + $this->assertCount(2, Constants::QUOTA_EXHAUSTED_CODES); + } + + public function testTransientRateLimitCodesAreNotTreatedAsQuota(): void + { + // These are retryable and must stay out of the set. + foreach (['RateLimitExceeded', 'TooManyRequests'] as $code) { + $this->assertNotContains($code, Constants::QUOTA_EXHAUSTED_CODES); + } + } + + /** + * The classification helper is private; exercise it through reflection so + * the body-shape handling (array and object decoding) is covered. + * + * @dataProvider quotaBodies + * + * @param mixed $body + */ + public function testQuotaExhaustedDetection($body, bool $expected): void + { + $api = new \NewsdataIO\NewsdataApi('key'); + $method = new \ReflectionMethod($api, 'quotaExhausted'); + $method->setAccessible(true); + + $this->assertSame($expected, $method->invoke($api, $body)); + } + + /** @return array */ + public static function quotaBodies(): array + { + return [ + 'array, quota code' => [ + ['status' => 'error', 'results' => ['code' => 'ApiLimitExceeded']], + true, + ], + 'array, key quota code' => [ + ['status' => 'error', 'results' => ['code' => 'ApiKeyLimitExceeded']], + true, + ], + 'array, transient code' => [ + ['status' => 'error', 'results' => ['code' => 'RateLimitExceeded']], + false, + ], + 'object, quota code' => [ + (object) ['status' => 'error', 'results' => (object) ['code' => 'ApiLimitExceeded']], + true, + ], + 'object, transient code' => [ + (object) ['status' => 'error', 'results' => (object) ['code' => 'TooManyRequests']], + false, + ], + 'no code at all' => [['status' => 'error', 'results' => []], false], + 'no results key' => [['status' => 'error'], false], + 'not a body' => ['plain string', false], + ]; + } +}