Skip to content

Commit 4230092

Browse files
committed
Introduce Backoff duration implementation with exponential strategy
1 parent 1150af1 commit 4230092

File tree

2 files changed

+254
-0
lines changed

2 files changed

+254
-0
lines changed

src/ExponentialBackoff.php

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
<?php
2+
3+
namespace ipl\Stdlib;
4+
5+
use Exception;
6+
use LogicException;
7+
8+
class ExponentialBackoff
9+
{
10+
/** @var int The minimum wait time for each retry in ms */
11+
protected $min;
12+
13+
/** @var int The maximum wait time for each retry in ms */
14+
protected $max;
15+
16+
/** @var int Number of retries to be performed before giving up */
17+
protected $retries;
18+
19+
/** @var ?int The previous used retry wait time */
20+
protected $previousWaitTime;
21+
22+
/**
23+
* Create a backoff duration with exponential strategy implementation.
24+
*
25+
* @param int $retries The number of retries to be used before given up.
26+
* @param int $min The minimum wait time to be used in milliseconds.
27+
* @param int $max The maximum wait time to be used in milliseconds.
28+
*/
29+
public function __construct(int $retries = 1, int $min = 0, int $max = 0)
30+
{
31+
$this->retries = $retries;
32+
33+
$this->setMin($min);
34+
$this->setMax($max);
35+
}
36+
37+
/**
38+
* Get the minimum wait time
39+
*
40+
* @return int
41+
*/
42+
public function getMin(): int
43+
{
44+
return $this->min;
45+
}
46+
47+
/**
48+
* Set the minimum wait time
49+
*
50+
* @param int $min
51+
*
52+
* @return $this
53+
*/
54+
public function setMin(int $min): self
55+
{
56+
if ($min <= 0) {
57+
$min = 100; // Default minimum wait time 100 ms
58+
}
59+
60+
$this->min = $min;
61+
62+
return $this;
63+
}
64+
65+
/**
66+
* Get the maximum wait time
67+
*
68+
* @return int
69+
*/
70+
public function getMax(): int
71+
{
72+
return $this->max;
73+
}
74+
75+
/**
76+
* Set the maximum wait time
77+
*
78+
* @param int $max
79+
*
80+
* @return $this
81+
* @throws LogicException When the configured minimum wait time is greater than the maximum wait time
82+
*/
83+
public function setMax(int $max): self
84+
{
85+
if ($max <= 0) {
86+
$max = 10000; // Default max wait time 10 seconds
87+
}
88+
89+
$this->max = $max;
90+
91+
if ($this->min > $this->max) {
92+
throw new LogicException('Max must be larger than min');
93+
}
94+
95+
return $this;
96+
}
97+
98+
/**
99+
* Get the configured number of retries
100+
*
101+
* @return int
102+
*/
103+
public function getRetries(): int
104+
{
105+
return $this->retries;
106+
}
107+
108+
/**
109+
* Set number of retries to be used
110+
*
111+
* @param int $retries
112+
*
113+
* @return $this
114+
*/
115+
public function setRetries(int $retries): self
116+
{
117+
$this->retries = $retries;
118+
119+
return $this;
120+
}
121+
122+
/**
123+
* Get a new wait time for the given attempt
124+
*
125+
* If the given attempt is the initial one, the min wait time is used. For all subsequent requests,
126+
* the previous wait time is simply multiplied by 2.
127+
*
128+
* @param int $attempt
129+
*
130+
* @return int
131+
*/
132+
public function getWaitTime(int $attempt): int
133+
{
134+
if ($attempt === 0) {
135+
$this->previousWaitTime = null;
136+
}
137+
138+
if ($this->previousWaitTime >= $this->max) {
139+
return $this->max;
140+
}
141+
142+
$next = min(! $this->previousWaitTime ? $this->min : $this->previousWaitTime * 2, $this->max);
143+
$this->previousWaitTime = $next;
144+
145+
return $next;
146+
}
147+
148+
/**
149+
* Execute and retry the given callback
150+
*
151+
* @param callable(?Exception $err): mixed $callback The callback to be retried
152+
*
153+
* @return mixed
154+
* @throws Exception When the given callback throws an exception that can't be retried or max retries is reached
155+
*/
156+
public function retry(callable $callback)
157+
{
158+
$attempt = 0;
159+
$previousErr = null;
160+
161+
do {
162+
try {
163+
return $callback($previousErr);
164+
} catch (Exception $err) {
165+
if ($attempt >= $this->getRetries() || $err === $previousErr) {
166+
throw $err;
167+
}
168+
169+
$previousErr = $err;
170+
171+
$sleep = $this->getWaitTime($attempt++);
172+
usleep($sleep * 1000);
173+
}
174+
} while ($attempt <= $this->getRetries());
175+
}
176+
}

tests/ExponentialBackoffTest.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace ipl\Tests\Stdlib;
4+
5+
use Exception;
6+
use ipl\Stdlib\ExponentialBackoff;
7+
use LogicException;
8+
9+
class ExponentialBackoffTest extends \PHPUnit\Framework\TestCase
10+
{
11+
public function testInvalidMaxWaitTime()
12+
{
13+
$this->expectException(LogicException::class);
14+
$this->expectExceptionMessage('Max must be larger than min');
15+
16+
new ExponentialBackoff(1, 500, 100);
17+
}
18+
19+
public function testMinAndMaxWaitTime()
20+
{
21+
$backoff = new ExponentialBackoff();
22+
$this->assertSame(100, $backoff->getMin());
23+
$this->assertSame(10 * 1000, $backoff->getMax());
24+
25+
$backoff
26+
->setMin(200)
27+
->setMax(500);
28+
29+
$this->assertSame(200, $backoff->getMin());
30+
$this->assertSame(500, $backoff->getMax());
31+
}
32+
33+
public function testRetriesSetCorrectly()
34+
{
35+
$backoff = new ExponentialBackoff();
36+
37+
$this->assertSame(1, $backoff->getRetries());
38+
$this->assertSame(5, $backoff->setRetries(5)->getRetries());
39+
$this->assertNotSame(10, $backoff->setRetries(5)->getRetries());
40+
}
41+
42+
public function testGetWaitTime()
43+
{
44+
$backoff = new ExponentialBackoff(100, 1000);
45+
46+
$this->assertSame($backoff->getMin(), $backoff->getWaitTime(0));
47+
$this->assertGreaterThan($backoff->getWaitTime(0), $backoff->getWaitTime(1));
48+
$this->assertGreaterThan($backoff->getWaitTime(1), $backoff->getWaitTime(2));
49+
$this->assertSame($backoff->getMax(), $backoff->getWaitTime(3));
50+
}
51+
52+
public function testExecutionRetries()
53+
{
54+
$backoff = new ExponentialBackoff(10);
55+
$attempt = 0;
56+
$result = $backoff->retry(function (Exception $err = null) use (&$attempt) {
57+
if (++$attempt < 5) {
58+
throw new Exception('SQLSTATE[HY000] [2002] No such file or directory');
59+
}
60+
61+
return 'succeeded';
62+
});
63+
64+
$this->assertSame(5, $attempt);
65+
$this->assertSame('succeeded', $result);
66+
}
67+
68+
public function testExecutionRetriesGivesUpAfterMaxRetries()
69+
{
70+
$this->expectException(Exception::class);
71+
$this->expectExceptionMessage('SQLSTATE[HY000] [2002] No such file or directory');
72+
73+
$backoff = new ExponentialBackoff(3);
74+
$backoff->retry(function (Exception $err = null) {
75+
throw new Exception('SQLSTATE[HY000] [2002] No such file or directory');
76+
});
77+
}
78+
}

0 commit comments

Comments
 (0)