Skip to content

Commit dac5b0b

Browse files
makowskidclaude
andcommitted
Add rate-limit awareness with automatic 429 retry and adaptive polling
Track X-RateLimit-Limit/Remaining headers from API responses, automatically retry on HTTP 429 with Retry-After respect, and adaptively slow down fetchResults() polling when remaining requests are low. Adds public getters for rate-limit state and configurable retry/threshold settings. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 976f6b5 commit dac5b0b

File tree

3 files changed

+211
-5
lines changed

3 files changed

+211
-5
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,41 @@ echo $quota->requests_per_minute;
3838

3939
---
4040

41+
## Rate Limiting
42+
43+
The SDK automatically handles API rate limits. When the API returns HTTP 429 (Too Many Requests), the client will:
44+
45+
1. **Retry automatically** — reads the `Retry-After` header, sleeps for the specified duration, and retries the request (up to 3 times by default).
46+
2. **Slow down polling** — during `fetchResults()`, when `X-RateLimit-Remaining` drops below the low threshold, polling intervals are automatically increased to avoid hitting the limit.
47+
48+
### Inspecting Rate-Limit State
49+
50+
After any API call, you can check the current rate-limit values:
51+
52+
```php
53+
$client = new SharpApiClient('your-api-key');
54+
$client->ping();
55+
56+
echo $client->getRateLimitLimit(); // e.g. 60 (requests per window)
57+
echo $client->getRateLimitRemaining(); // e.g. 58 (remaining in current window)
58+
```
59+
60+
> **Note:** `getRateLimitLimit()` and `getRateLimitRemaining()` return `null` before the first API call or after endpoints that don't return rate-limit headers (e.g. `/ping`, `/quota`).
61+
62+
### Configuration
63+
64+
```php
65+
// Max automatic retries on HTTP 429 (default: 3)
66+
$client->setMaxRetryOnRateLimit(5);
67+
68+
// Threshold below which polling intervals are increased (default: 3)
69+
$client->setRateLimitLowThreshold(5);
70+
```
71+
72+
When `rateLimitRemaining` is at or below the threshold, polling intervals in `fetchResults()` are multiplied by an increasing factor (2x at threshold, growing as remaining approaches 0). This helps avoid 429 errors during long-running job polling.
73+
74+
---
75+
4176
## Credits
4277

4378
- [A2Z WEB LTD](https://github.com/a2zwebltd)

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
],
2525
"extra": {
2626
"branch-alias": {
27-
"dev-master": "1.1.x-dev"
27+
"dev-master": "1.3.x-dev"
2828
}
2929
},
3030
"minimum-stability": "stable"

src/Client/SharpApiClient.php

Lines changed: 175 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Carbon\Carbon;
88
use Exception;
99
use GuzzleHttp\Client;
10+
use GuzzleHttp\Exception\ClientException;
1011
use GuzzleHttp\Exception\GuzzleException;
1112
use InvalidArgumentException;
1213
use Psr\Http\Message\ResponseInterface;
@@ -34,6 +35,10 @@ class SharpApiClient
3435
protected bool $useCustomInterval = false;
3536
protected int $apiJobStatusPollingWait = 180;
3637
protected string $userAgent;
38+
protected ?int $rateLimitLimit = null;
39+
protected ?int $rateLimitRemaining = null;
40+
protected int $maxRetryOnRateLimit = 3;
41+
protected int $rateLimitLowThreshold = 3;
3742
private Client $client;
3843

3944
/**
@@ -54,7 +59,7 @@ public function __construct(
5459
throw new InvalidArgumentException('API key is required.');
5560
}
5661
$this->setApiBaseUrl($apiBaseUrl ?? 'https://sharpapi.com/api/v1');
57-
$this->setUserAgent($userAgent ?? 'SharpAPIPHPAgent/1.2.0');
62+
$this->setUserAgent($userAgent ?? 'SharpAPIPHPAgent/1.3.0');
5863
$this->client = new Client([
5964
'headers' => $this->getHeaders()
6065
]);
@@ -135,6 +140,62 @@ public function setApiJobStatusPollingWait(int $apiJobStatusPollingWait): void
135140
$this->apiJobStatusPollingWait = $apiJobStatusPollingWait;
136141
}
137142

143+
/**
144+
* @return int|null The rate limit (requests per window) from the last API response, or null if not yet known.
145+
* @api
146+
*/
147+
public function getRateLimitLimit(): ?int
148+
{
149+
return $this->rateLimitLimit;
150+
}
151+
152+
/**
153+
* @return int|null The remaining requests in the current window from the last API response, or null if not yet known.
154+
* @api
155+
*/
156+
public function getRateLimitRemaining(): ?int
157+
{
158+
return $this->rateLimitRemaining;
159+
}
160+
161+
/**
162+
* @return int Maximum number of automatic retries on HTTP 429.
163+
* @api
164+
*/
165+
public function getMaxRetryOnRateLimit(): int
166+
{
167+
return $this->maxRetryOnRateLimit;
168+
}
169+
170+
/**
171+
* @param int $maxRetryOnRateLimit Maximum number of automatic retries on HTTP 429.
172+
* @return void
173+
* @api
174+
*/
175+
public function setMaxRetryOnRateLimit(int $maxRetryOnRateLimit): void
176+
{
177+
$this->maxRetryOnRateLimit = $maxRetryOnRateLimit;
178+
}
179+
180+
/**
181+
* @return int When remaining requests fall at or below this threshold, polling intervals are increased.
182+
* @api
183+
*/
184+
public function getRateLimitLowThreshold(): int
185+
{
186+
return $this->rateLimitLowThreshold;
187+
}
188+
189+
/**
190+
* @param int $rateLimitLowThreshold When remaining requests fall at or below this threshold, polling intervals are increased.
191+
* @return void
192+
* @api
193+
*/
194+
public function setRateLimitLowThreshold(int $rateLimitLowThreshold): void
195+
{
196+
$this->rateLimitLowThreshold = $rateLimitLowThreshold;
197+
}
198+
138199
/**
139200
* Sends a ping request to the API to check its availability and retrieve the current timestamp.
140201
*
@@ -220,7 +281,7 @@ protected function makeRequest(
220281
}
221282
}
222283

223-
return $this->client->request($method, $this->getApiBaseUrl() . $url, $options);
284+
return $this->executeWithRateLimitRetry($method, $this->getApiBaseUrl() . $url, $options);
224285
}
225286

226287
/**
@@ -249,7 +310,7 @@ protected function makeGetRequest(
249310
$options['query'] = $queryParams;
250311
}
251312

252-
return $this->client->request('GET', $this->getApiBaseUrl() . $url, $options);
313+
return $this->executeWithRateLimitRetry('GET', $this->getApiBaseUrl() . $url, $options);
253314
}
254315

255316
/**
@@ -275,7 +336,28 @@ public function fetchResults(string $statusUrl): SharpApiJob
275336
$waitingTime = 0;
276337

277338
do {
278-
$response = $this->client->request('GET', $statusUrl, ['headers' => $this->getHeaders()]);
339+
try {
340+
$response = $this->client->request('GET', $statusUrl, ['headers' => $this->getHeaders()]);
341+
} catch (ClientException $e) {
342+
if ($e->getResponse()->getStatusCode() === 429) {
343+
$retryResponse = $e->getResponse();
344+
$this->extractRateLimitHeaders($retryResponse);
345+
$retryDelay = isset($retryResponse->getHeader('Retry-After')[0])
346+
? (int) $retryResponse->getHeader('Retry-After')[0]
347+
: $this->getApiJobStatusPollingInterval();
348+
349+
$waitingTime += $retryDelay;
350+
if ($waitingTime >= $this->getApiJobStatusPollingWait()) {
351+
throw new ApiException('Polling timed out while waiting for job completion (rate limited).', 429);
352+
}
353+
354+
sleep($retryDelay);
355+
continue;
356+
}
357+
throw $e;
358+
}
359+
360+
$this->extractRateLimitHeaders($response);
279361
$jobStatus = json_decode($response->getBody()->__toString(), true)['data']['attributes'];
280362

281363
if ($jobStatus['status'] === SharpApiJobStatusEnum::SUCCESS->value ||
@@ -291,6 +373,8 @@ public function fetchResults(string $statusUrl): SharpApiJob
291373
$retryAfter = $this->getApiJobStatusPollingInterval();
292374
}
293375

376+
$retryAfter = $this->adjustIntervalForRateLimit($retryAfter);
377+
294378
$waitingTime += $retryAfter;
295379
if ($waitingTime >= $this->getApiJobStatusPollingWait()) {
296380
throw new ApiException('Polling timed out while waiting for job completion.');
@@ -313,6 +397,93 @@ public function fetchResults(string $statusUrl): SharpApiJob
313397
);
314398
}
315399

400+
/**
401+
* Extracts rate-limit headers from an API response and stores them.
402+
*
403+
* @param ResponseInterface $response The API response.
404+
*/
405+
private function extractRateLimitHeaders(ResponseInterface $response): void
406+
{
407+
$limit = $response->getHeader('X-RateLimit-Limit');
408+
if (!empty($limit)) {
409+
$this->rateLimitLimit = (int) $limit[0];
410+
}
411+
412+
$remaining = $response->getHeader('X-RateLimit-Remaining');
413+
if (!empty($remaining)) {
414+
$this->rateLimitRemaining = (int) $remaining[0];
415+
}
416+
}
417+
418+
/**
419+
* Adjusts a polling interval when the rate-limit remaining count is low.
420+
*
421+
* When remaining is at or below the threshold, the base interval is multiplied
422+
* by a scaling factor that increases as remaining approaches 0.
423+
*
424+
* @param int $baseInterval The original polling interval in seconds.
425+
* @return int The adjusted interval.
426+
*/
427+
private function adjustIntervalForRateLimit(int $baseInterval): int
428+
{
429+
if ($this->rateLimitRemaining === null || $this->rateLimitRemaining > $this->rateLimitLowThreshold) {
430+
return $baseInterval;
431+
}
432+
433+
// Scale: 2x at threshold, increasing as remaining approaches 0
434+
$scale = 2 + ($this->rateLimitLowThreshold - $this->rateLimitRemaining);
435+
436+
return $baseInterval * $scale;
437+
}
438+
439+
/**
440+
* Wraps an HTTP request with automatic 429 retry logic.
441+
*
442+
* On success, rate-limit headers are extracted. On HTTP 429, the request is
443+
* retried after sleeping for the Retry-After duration, up to maxRetryOnRateLimit times.
444+
* All other client exceptions are re-thrown.
445+
*
446+
* @param string $method The HTTP method.
447+
* @param string $url The full request URL.
448+
* @param array $options Guzzle request options.
449+
* @return ResponseInterface The successful response.
450+
* @throws ApiException if retries are exhausted on 429.
451+
* @throws GuzzleException for non-429 failures.
452+
*/
453+
private function executeWithRateLimitRetry(string $method, string $url, array $options): ResponseInterface
454+
{
455+
$attempts = 0;
456+
457+
while (true) {
458+
try {
459+
$response = $this->client->request($method, $url, $options);
460+
$this->extractRateLimitHeaders($response);
461+
return $response;
462+
} catch (ClientException $e) {
463+
if ($e->getResponse()->getStatusCode() !== 429) {
464+
throw $e;
465+
}
466+
467+
$attempts++;
468+
$retryResponse = $e->getResponse();
469+
$this->extractRateLimitHeaders($retryResponse);
470+
471+
if ($attempts >= $this->maxRetryOnRateLimit) {
472+
throw new ApiException(
473+
'Rate limit exceeded. Retries exhausted after ' . $attempts . ' attempts.',
474+
429
475+
);
476+
}
477+
478+
$retryAfter = isset($retryResponse->getHeader('Retry-After')[0])
479+
? (int) $retryResponse->getHeader('Retry-After')[0]
480+
: 1;
481+
482+
sleep($retryAfter);
483+
}
484+
}
485+
}
486+
316487
/**
317488
* Prepares the headers for API requests.
318489
*

0 commit comments

Comments
 (0)