Skip to content

Commit 02b12c0

Browse files
authored
Merge pull request #5 from utopia-php/feat-impl-retries
Implement Retry functionality into the fetch library
2 parents 0f7be9d + 6363847 commit 02b12c0

6 files changed

Lines changed: 250 additions & 21 deletions

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
vendor
22
*.cache
3-
composer.lock
3+
composer.lock
4+
state.json

src/Client.php

Lines changed: 121 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@ class Client
3030
private int $maxRedirects = 5;
3131
private bool $allowRedirects = true;
3232
private string $userAgent = '';
33+
private int $maxRetries = 0;
34+
private int $retryDelay = 1000; // milliseconds
35+
36+
/** @var array<int> $retryStatusCodes */
37+
private array $retryStatusCodes = [500, 503];
3338

3439
/**
3540
* @param string $key
@@ -102,6 +107,45 @@ public function setUserAgent(string $userAgent): self
102107
return $this;
103108
}
104109

110+
/**
111+
* Set the maximum number of retries.
112+
*
113+
* The client will automatically retry the request if the response status code is 500 or 503, indicating a temporary error.
114+
* If the request fails after the maximum number of retries, the normal response will be returned.
115+
*
116+
* @param int $maxRetries
117+
* @return self
118+
*/
119+
public function setMaxRetries(int $maxRetries): self
120+
{
121+
$this->maxRetries = $maxRetries;
122+
return $this;
123+
}
124+
125+
/**
126+
* Set the retry delay in milliseconds.
127+
*
128+
* @param int $retryDelay
129+
* @return self
130+
*/
131+
public function setRetryDelay(int $retryDelay): self
132+
{
133+
$this->retryDelay = $retryDelay;
134+
return $this;
135+
}
136+
137+
/**
138+
* Set the retry status codes.
139+
*
140+
* @param array<int> $retryStatusCodes
141+
* @return self
142+
*/
143+
public function setRetryStatusCodes(array $retryStatusCodes): self
144+
{
145+
$this->retryStatusCodes = $retryStatusCodes;
146+
return $this;
147+
}
148+
105149
/**
106150
* Flatten request body array to PHP multiple format
107151
*
@@ -125,6 +169,29 @@ private static function flatten(array $data, string $prefix = ''): array
125169
return $output;
126170
}
127171

172+
/**
173+
* Retry a callback with exponential backoff
174+
*
175+
* @param callable $callback
176+
* @return mixed
177+
* @throws \Exception
178+
*/
179+
private function withRetries(callable $callback): mixed
180+
{
181+
$attempts = 1;
182+
183+
while (true) {
184+
$res = $callback();
185+
186+
if (!in_array($res->getStatusCode(), $this->retryStatusCodes) || $attempts >= $this->maxRetries) {
187+
return $res;
188+
}
189+
190+
usleep($this->retryDelay * 1000); // Convert milliseconds to microseconds
191+
$attempts++;
192+
}
193+
}
194+
128195
/**
129196
* This method is used to make a request to the server.
130197
*
@@ -190,23 +257,33 @@ public function fetch(
190257
curl_setopt($ch, $option, $value);
191258
}
192259

193-
$responseBody = curl_exec($ch);
194-
$responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
195-
if (curl_errno($ch)) {
196-
$errorMsg = curl_error($ch);
197-
}
260+
$sendRequest = function () use ($ch, &$responseHeaders) {
261+
$responseHeaders = [];
198262

199-
curl_close($ch);
263+
$responseBody = curl_exec($ch);
264+
$responseStatusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
265+
if (curl_errno($ch)) {
266+
$errorMsg = curl_error($ch);
267+
}
200268

201-
if (isset($errorMsg)) {
202-
throw new FetchException($errorMsg);
203-
}
269+
curl_close($ch);
204270

205-
$response = new Response(
206-
statusCode: $responseStatusCode,
207-
headers: $responseHeaders,
208-
body: $responseBody
209-
);
271+
if (isset($errorMsg)) {
272+
throw new FetchException($errorMsg);
273+
}
274+
275+
return new Response(
276+
statusCode: $responseStatusCode,
277+
headers: $responseHeaders,
278+
body: $responseBody
279+
);
280+
};
281+
282+
if ($this->maxRetries > 0) {
283+
$response = $this->withRetries($sendRequest);
284+
} else {
285+
$response = $sendRequest();
286+
}
210287

211288
return $response;
212289
}
@@ -260,4 +337,34 @@ public function getUserAgent(): string
260337
{
261338
return $this->userAgent;
262339
}
340+
341+
/**
342+
* Get the maximum number of retries.
343+
*
344+
* @return int
345+
*/
346+
public function getMaxRetries(): int
347+
{
348+
return $this->maxRetries;
349+
}
350+
351+
/**
352+
* Get the retry delay.
353+
*
354+
* @return int
355+
*/
356+
public function getRetryDelay(): int
357+
{
358+
return $this->retryDelay;
359+
}
360+
361+
/**
362+
* Get the retry status codes.
363+
*
364+
* @return array<int>
365+
*/
366+
public function getRetryStatusCodes(): array
367+
{
368+
return $this->retryStatusCodes;
369+
}
263370
}

src/Response.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ public function text(): string
8888
public function json(): mixed
8989
{
9090
$data = \json_decode($this->body, true);
91-
if($data === null) { // Throw an exception if the data is null
91+
if ($data === null) { // Throw an exception if the data is null
9292
throw new \Exception('Error decoding JSON');
9393
}
9494
return $data;
@@ -101,7 +101,7 @@ public function json(): mixed
101101
public function blob(): string
102102
{
103103
$bin = "";
104-
for($i = 0, $j = strlen($this->body); $i < $j; $i++) {
104+
for ($i = 0, $j = strlen($this->body); $i < $j; $i++) {
105105
$bin .= decbin(ord($this->body)) . " ";
106106
}
107107
return $bin;

tests/ClientTest.php

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,4 +357,65 @@ public function getFileDataset(): array
357357
],
358358
];
359359
}
360+
361+
/**
362+
* Test for retry functionality
363+
* @return void
364+
*/
365+
public function testRetry(): void
366+
{
367+
$client = new Client();
368+
$client->setMaxRetries(3);
369+
$client->setRetryDelay(1000);
370+
371+
$this->assertEquals(3, $client->getMaxRetries());
372+
$this->assertEquals(1000, $client->getRetryDelay());
373+
374+
$res = $client->fetch('localhost:8000/mock-retry');
375+
$this->assertEquals(200, $res->getStatusCode());
376+
377+
unlink(__DIR__ . '/state.json');
378+
379+
// Test if we get a 500 error if we go under the server's max retries
380+
$client->setMaxRetries(1);
381+
$res = $client->fetch('localhost:8000/mock-retry');
382+
$this->assertEquals(503, $res->getStatusCode());
383+
384+
unlink(__DIR__ . '/state.json');
385+
}
386+
387+
/**
388+
* Test if the retry delay is working
389+
* @return void
390+
*/
391+
public function testRetryWithDelay(): void
392+
{
393+
$client = new Client();
394+
$client->setMaxRetries(3);
395+
$client->setRetryDelay(3000);
396+
$now = microtime(true);
397+
398+
$res = $client->fetch('localhost:8000/mock-retry');
399+
$this->assertGreaterThan($now + 3.0, microtime(true));
400+
$this->assertEquals(200, $res->getStatusCode());
401+
unlink(__DIR__ . '/state.json');
402+
}
403+
404+
/**
405+
* Test custom retry status codes
406+
* @return void
407+
*/
408+
public function testCustomRetryStatusCodes(): void
409+
{
410+
$client = new Client();
411+
$client->setMaxRetries(3);
412+
$client->setRetryDelay(3000);
413+
$client->setRetryStatusCodes([401]);
414+
$now = microtime(true);
415+
416+
$res = $client->fetch('localhost:8000/mock-retry-401');
417+
$this->assertEquals(200, $res->getStatusCode());
418+
$this->assertGreaterThan($now + 3.0, microtime(true));
419+
unlink(__DIR__ . '/state.json');
420+
}
360421
}

tests/ResponseTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ public function testClassMethods(
5050
$jsonBody = \json_decode($body, true); // Convert JSON string to object
5151
$this->assertEquals($jsonBody, $resp->json()); // Assert that the JSON body is equal to the response's JSON body
5252
$bin = ""; // Convert string to binary
53-
for($i = 0, $j = strlen($body); $i < $j; $i++) {
53+
for ($i = 0, $j = strlen($body); $i < $j; $i++) {
5454
$bin .= decbin(ord($body)) . " ";
5555
}
5656
$this->assertEquals($bin, $resp->blob()); // Assert that the blob body is equal to the response's blob body

tests/router.php

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,84 @@
77
$body = file_get_contents("php://input"); // Get the request body
88
$files = $_FILES; // Get the request files
99

10+
$stateFile = __DIR__ . '/state.json';
11+
12+
/**
13+
* Get the state from the state file
14+
* @return array<string, mixed>
15+
*/
16+
function getState(): array
17+
{
18+
global $stateFile;
19+
if (file_exists($stateFile)) {
20+
$data = file_get_contents($stateFile);
21+
22+
if ($data === false) {
23+
throw new \Exception('Failed to read state file');
24+
}
25+
26+
return json_decode($data, true) ?? [];
27+
}
28+
return [];
29+
}
30+
31+
/**
32+
* Set the state to the state file
33+
* @param array<string, mixed> $newState
34+
* @return void
35+
*/
36+
function setState(array $newState): void
37+
{
38+
global $stateFile;
39+
file_put_contents($stateFile, json_encode($newState, JSON_PRETTY_PRINT));
40+
}
41+
1042
$curPageName = substr($_SERVER['REQUEST_URI'], strrpos($_SERVER['REQUEST_URI'], "/") + 1);
1143

12-
if($curPageName == 'redirect') {
44+
if ($curPageName == 'redirect') {
1345
header('Location: http://localhost:8000/redirectedPage');
1446
exit;
1547
}
16-
if($curPageName == 'image') {
48+
if ($curPageName == 'image') {
1749
$filename = __DIR__."/resources/logo.png";
1850
header("Content-disposition: attachment;filename=$filename");
1951
header("Content-type: application/octet-stream");
2052
readfile($filename);
2153
exit;
22-
} elseif($curPageName == 'text') {
54+
} elseif ($curPageName == 'text') {
2355
$filename = __DIR__."/resources/test.txt";
2456
header("Content-disposition: attachment;filename=$filename");
2557
header("Content-type: application/octet-stream");
2658
readfile($filename);
2759
exit;
60+
} elseif ($curPageName == 'mock-retry') {
61+
$state = getState();
62+
$state['attempts'] = isset($state['attempts']) ? $state['attempts'] + 1 : 1;
63+
setState($state);
64+
65+
if ($state['attempts'] <= 2) {
66+
http_response_code(503);
67+
throw new \Exception('Mock retry error');
68+
}
69+
70+
$body = json_encode([
71+
'success' => true,
72+
'attempts' => $state['attempts']
73+
]);
74+
} elseif ($curPageName == 'mock-retry-401') {
75+
$state = getState();
76+
$state['attempts'] = isset($state['attempts']) ? $state['attempts'] + 1 : 1;
77+
setState($state);
78+
79+
if ($state['attempts'] <= 2) {
80+
http_response_code(401);
81+
throw new \Exception('Mock retry error');
82+
}
83+
84+
$body = json_encode([
85+
'success' => true,
86+
'attempts' => $state['attempts']
87+
]);
2888
}
2989
$resp = [
3090
'method' => $method,

0 commit comments

Comments
 (0)