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
12 changes: 12 additions & 0 deletions src/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'];

Expand Down
29 changes: 28 additions & 1 deletion src/NewsdataApiBase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
83 changes: 83 additions & 0 deletions tests/QuotaExhaustedTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
<?php

declare(strict_types=1);

namespace NewsdataIO\Tests;

use NewsdataIO\Constants;
use PHPUnit\Framework\TestCase;

/**
* A 429 covers three cases: a burst limit, a rate limit, and exhausted API
* credits. Only the first two are worth retrying — waiting out the backoff
* cannot conjure more credits.
*
* The retry loop itself needs a live socket, so these pin the code set and the
* classification helper rather than driving cURL.
*/
class QuotaExhaustedTest extends TestCase
{
public function testQuotaCodesMatchTheApi(): void
{
// `ApiLimitExceeded` is in the spec's ErrorCode enum; the key-scoped
// variant is accepted too because the spec is not exhaustive.
$this->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<string,array{0:mixed,1:bool}> */
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],
];
}
}
Loading