Skip to content

Commit 39be826

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

File tree

2 files changed

+262
-0
lines changed

2 files changed

+262
-0
lines changed

src/ExponentialBackoff.php

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
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+
/**
20+
* Create a backoff duration with exponential strategy implementation.
21+
*
22+
* @param int $min The minimum wait time to be used in milliseconds.
23+
* @param int $max The maximum wait time to be used in milliseconds.
24+
*/
25+
public function __construct(int $retries = 1, int $min = 0, int $max = 0)
26+
{
27+
$this->retries = $retries;
28+
29+
$this->setMin($min);
30+
$this->setMax($max);
31+
}
32+
33+
/**
34+
* Get the minimum wait time
35+
*
36+
* @return int
37+
*/
38+
public function getMin(): int
39+
{
40+
return $this->min;
41+
}
42+
43+
/**
44+
* Set the minimum wait time
45+
*
46+
* @param int $min
47+
*
48+
* @return $this
49+
*/
50+
public function setMin(int $min): self
51+
{
52+
if ($min <= 0) {
53+
$min = 100; // Default minimum wait time 100 ms
54+
}
55+
56+
$this->min = $min;
57+
58+
return $this;
59+
}
60+
61+
/**
62+
* Get the maximum wait time
63+
*
64+
* @return int
65+
*/
66+
public function getMax(): int
67+
{
68+
return $this->max;
69+
}
70+
71+
/**
72+
* Set the maximum wait time
73+
*
74+
* @param int $max
75+
*
76+
* @return $this
77+
* @throws LogicException When the configured minim wait time is greater than the maximum wait time
78+
*/
79+
public function setMax(int $max): self
80+
{
81+
if ($max <= 0) {
82+
$max = 10000; // Default max wait time 10 seconds
83+
}
84+
85+
$this->max = $max;
86+
87+
if ($this->min > $this->max) {
88+
throw new LogicException('Max must be larger than min');
89+
}
90+
91+
return $this;
92+
}
93+
94+
/**
95+
* Get the configured number of retries
96+
*
97+
* @return int
98+
*/
99+
public function getRetries(): int
100+
{
101+
return $this->retries;
102+
}
103+
104+
/**
105+
* Set number of retries to be used
106+
*
107+
* @param int $retries
108+
*
109+
* @return $this
110+
*/
111+
public function setRetries(int $retries): self
112+
{
113+
$this->retries = $retries;
114+
115+
return $this;
116+
}
117+
118+
/**
119+
* Get a new wait time for the given attempt
120+
*
121+
* Encapsulates a backoff duration implementation for a specific retry attempt with exponential strategy.
122+
*
123+
* @param int $attempt
124+
*
125+
* @return int
126+
*/
127+
public function getWaitTime(int $attempt): int
128+
{
129+
$next = $this->min << $attempt;
130+
if ($next <= 0) {
131+
// Can be caused by an integer out of range error, so use the max value.
132+
$next = $this->max;
133+
}
134+
135+
return $this->jitter(min($next, $this->max));
136+
}
137+
138+
/**
139+
* Execute and retry the given callback
140+
*
141+
* @param callable $callback The callback to be retried
142+
*
143+
* @return mixed
144+
* @throws Exception When the given callback throws an exception that can't be retried or max retries is reached
145+
*/
146+
public function retry(callable $callback)
147+
{
148+
$attempt = 0;
149+
$previousErr = null;
150+
151+
do {
152+
try {
153+
return $callback($previousErr);
154+
} catch (Exception $err) {
155+
if ($attempt >= $this->getRetries() || $err === $previousErr) {
156+
throw $err;
157+
}
158+
159+
$previousErr = $err;
160+
161+
$sleep = $this->getWaitTime($attempt++);
162+
usleep($sleep * 1000);
163+
}
164+
} while ($attempt <= $this->getRetries());
165+
}
166+
167+
/**
168+
* Returns a unique random generated number in the range [$number/2...$number]
169+
*
170+
* @param int $number
171+
*
172+
* @return int
173+
*/
174+
protected function jitter(int $number): int
175+
{
176+
if ($number === 0) {
177+
return $number;
178+
}
179+
180+
$halfN = (int) ($number / 2);
181+
182+
return $halfN + mt_rand($halfN, $number);
183+
}
184+
}

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();
45+
46+
$this->assertGreaterThan($backoff->getMin() - 1, $backoff->getWaitTime(0));
47+
$this->assertGreaterThan($backoff->getWaitTime(0), $backoff->getWaitTime(1));
48+
$this->assertGreaterThan($backoff->getWaitTime(1), $backoff->getWaitTime(2));
49+
$this->assertGreaterThan($backoff->getWaitTime(2), $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)