diff --git a/src/ExponentialBackoff.php b/src/ExponentialBackoff.php new file mode 100644 index 0000000..aba569e --- /dev/null +++ b/src/ExponentialBackoff.php @@ -0,0 +1,184 @@ +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 minim 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 + * + * Encapsulates a backoff duration implementation for a specific retry attempt with exponential strategy. + * + * @param int $attempt + * + * @return int + */ + public function getWaitTime(int $attempt): int + { + $next = $this->min << $attempt; + if ($next <= 0) { + // Can be caused by an integer out of range error, so use the max value. + $next = $this->max; + } + + return $this->jitter(min($next, $this->max)); + } + + /** + * Execute and retry the given callback + * + * @param callable $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); + } catch (Exception $err) { + if ($attempt >= $this->getRetries() || $err === $previousErr) { + throw $err; + } + + $previousErr = $err; + + $sleep = $this->getWaitTime($attempt++); + usleep($sleep * 1000); + } + } while ($attempt <= $this->getRetries()); + } + + /** + * Returns a unique random generated number in the range [$number/2...$number] + * + * @param int $number + * + * @return int + */ + protected function jitter(int $number): int + { + if ($number === 0) { + return $number; + } + + $halfN = (int) ($number / 2); + + return $halfN + mt_rand($halfN, $number); + } +} diff --git a/tests/ExponentialBackoffTest.php b/tests/ExponentialBackoffTest.php new file mode 100644 index 0000000..e35aae5 --- /dev/null +++ b/tests/ExponentialBackoffTest.php @@ -0,0 +1,78 @@ +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(); + + $this->assertGreaterThan($backoff->getMin() - 1, $backoff->getWaitTime(0)); + $this->assertGreaterThan($backoff->getWaitTime(0), $backoff->getWaitTime(1)); + $this->assertGreaterThan($backoff->getWaitTime(1), $backoff->getWaitTime(2)); + $this->assertGreaterThan($backoff->getWaitTime(2), $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'); + }); + } +}