Skip to content

Commit

Permalink
Introduce Backoff duration implementation with exponential strategy
Browse files Browse the repository at this point in the history
  • Loading branch information
yhabteab committed Sep 13, 2023
1 parent 1150af1 commit 7ec528f
Show file tree
Hide file tree
Showing 2 changed files with 254 additions and 0 deletions.
176 changes: 176 additions & 0 deletions src/ExponentialBackoff.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
<?php

namespace ipl\Stdlib;

use Exception;
use LogicException;

class ExponentialBackoff
{
/** @var int The minimum wait time for each retry in ms */
protected $min;

/** @var int The maximum wait time for each retry in ms */
protected $max;

/** @var int Number of retries to be performed before giving up */
protected $retries;

/** @var ?int The previous used retry wait time */
protected $previousWaitTime;

/**
* Create a backoff duration with exponential strategy implementation.
*
* @param int $retries The number of retries to be used before given up.
* @param int $min The minimum wait time to be used in milliseconds.
* @param int $max The maximum wait time to be used in milliseconds.
*/
public function __construct(int $retries = 1, int $min = 0, int $max = 0)
{
$this->retries = $retries;

$this->setMin($min);
$this->setMax($max);
}

/**
* Get the minimum wait time
*
* @return int
*/
public function getMin(): int
{
return $this->min;
}

/**
* Set the minimum wait time
*
* @param int $min
*
* @return $this
*/
public function setMin(int $min): self
{
if ($min <= 0) {
$min = 100; // Default minimum wait time 100 ms
}

$this->min = $min;

return $this;
}

/**
* Get the maximum wait time
*
* @return int
*/
public function getMax(): int
{
return $this->max;
}

/**
* Set the maximum wait time
*
* @param int $max
*
* @return $this
* @throws LogicException When the configured minimum wait time is greater than the maximum wait time
*/
public function setMax(int $max): self
{
if ($max <= 0) {
$max = 10000; // Default max wait time 10 seconds
}

$this->max = $max;

if ($this->min > $this->max) {
throw new LogicException('Max must be larger than min');
}

return $this;
}

/**
* Get the configured number of retries
*
* @return int
*/
public function getRetries(): int
{
return $this->retries;
}

/**
* Set number of retries to be used
*
* @param int $retries
*
* @return $this
*/
public function setRetries(int $retries): self
{
$this->retries = $retries;

return $this;
}

/**
* Get a new wait time for the given attempt
*
* If the given attempt is the initial one, the min wait time is used. For all subsequent requests,
* the previous wait time is simply multiplied by 2.
*
* @param int $attempt
*
* @return int
*/
public function getWaitTime(int $attempt): int
{
if ($attempt === 0) {
$this->previousWaitTime = null;
}

if ($this->previousWaitTime >= $this->max) {
return $this->max;
}

$next = min(! $this->previousWaitTime ? $this->min : $this->previousWaitTime * 2, $this->max);
$this->previousWaitTime = $next;

return $next;
}

/**
* Execute and retry the given callback
*
* @param callable(Exception $err): mixed $callback The callback to be retried
*
* @return mixed
* @throws Exception When the given callback throws an exception that can't be retried or max retries is reached
*/
public function retry(callable $callback)
{
$attempt = 0;
$previousErr = null;

do {
try {
return $callback($previousErr);

Check failure on line 163 in src/ExponentialBackoff.php

View workflow job for this annotation

GitHub Actions / Static analysis for php 7.2 on ubuntu-latest

Parameter #1 $err of callable callable(Exception): mixed expects Exception, Exception|null given.

Check failure on line 163 in src/ExponentialBackoff.php

View workflow job for this annotation

GitHub Actions / Static analysis for php 7.3 on ubuntu-latest

Parameter #1 $err of callable callable(Exception): mixed expects Exception, Exception|null given.

Check failure on line 163 in src/ExponentialBackoff.php

View workflow job for this annotation

GitHub Actions / Static analysis for php 7.4 on ubuntu-latest

Parameter #1 $err of callable callable(Exception): mixed expects Exception, Exception|null given.

Check failure on line 163 in src/ExponentialBackoff.php

View workflow job for this annotation

GitHub Actions / Static analysis for php 8.0 on ubuntu-latest

Parameter #1 $err of callable callable(Exception): mixed expects Exception, Exception|null given.

Check failure on line 163 in src/ExponentialBackoff.php

View workflow job for this annotation

GitHub Actions / Static analysis for php 8.1 on ubuntu-latest

Parameter #1 $err of callable callable(Exception): mixed expects Exception, Exception|null given.

Check failure on line 163 in src/ExponentialBackoff.php

View workflow job for this annotation

GitHub Actions / Static analysis for php 8.2 on ubuntu-latest

Parameter #1 $err of callable callable(Exception): mixed expects Exception, Exception|null given.
} catch (Exception $err) {
if ($attempt >= $this->getRetries() || $err === $previousErr) {
throw $err;
}

$previousErr = $err;

$sleep = $this->getWaitTime($attempt++);
usleep($sleep * 1000);
}
} while ($attempt <= $this->getRetries());
}
}
78 changes: 78 additions & 0 deletions tests/ExponentialBackoffTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

namespace ipl\Tests\Stdlib;

use Exception;
use ipl\Stdlib\ExponentialBackoff;
use LogicException;

class ExponentialBackoffTest extends \PHPUnit\Framework\TestCase
{
public function testInvalidMaxWaitTime()
{
$this->expectException(LogicException::class);
$this->expectExceptionMessage('Max must be larger than min');

new ExponentialBackoff(1, 500, 100);
}

public function testMinAndMaxWaitTime()
{
$backoff = new ExponentialBackoff();
$this->assertSame(100, $backoff->getMin());
$this->assertSame(10 * 1000, $backoff->getMax());

$backoff
->setMin(200)
->setMax(500);

$this->assertSame(200, $backoff->getMin());
$this->assertSame(500, $backoff->getMax());
}

public function testRetriesSetCorrectly()
{
$backoff = new ExponentialBackoff();

$this->assertSame(1, $backoff->getRetries());
$this->assertSame(5, $backoff->setRetries(5)->getRetries());
$this->assertNotSame(10, $backoff->setRetries(5)->getRetries());
}

public function testGetWaitTime()
{
$backoff = new ExponentialBackoff(100, 1000);

$this->assertSame($backoff->getMin(), $backoff->getWaitTime(0));
$this->assertGreaterThan($backoff->getWaitTime(0), $backoff->getWaitTime(1));
$this->assertGreaterThan($backoff->getWaitTime(1), $backoff->getWaitTime(2));
$this->assertSame($backoff->getMax(), $backoff->getWaitTime(3));
}

public function testExecutionRetries()
{
$backoff = new ExponentialBackoff(10);
$attempt = 0;
$result = $backoff->retry(function (Exception $err = null) use (&$attempt) {
if (++$attempt < 5) {
throw new Exception('SQLSTATE[HY000] [2002] No such file or directory');
}

return 'succeeded';
});

$this->assertSame(5, $attempt);
$this->assertSame('succeeded', $result);
}

public function testExecutionRetriesGivesUpAfterMaxRetries()
{
$this->expectException(Exception::class);
$this->expectExceptionMessage('SQLSTATE[HY000] [2002] No such file or directory');

$backoff = new ExponentialBackoff(3);
$backoff->retry(function (Exception $err = null) {
throw new Exception('SQLSTATE[HY000] [2002] No such file or directory');
});
}
}

0 comments on commit 7ec528f

Please sign in to comment.