Skip to content

Commit e66eec5

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

File tree

2 files changed

+253
-0
lines changed

2 files changed

+253
-0
lines changed

src/ExponentialBackoff.php

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

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)