crowdstar / exponential-backoff
Prevent overloading an unavailable service by doubling the timeout each iteration.
Requires
- php: >=8.1
Requires (Dev)
- deminy/counit: ~0.3
- swoole/ide-helper: ~6.0
Suggests
- ext-swoole: Allow to do exponential backoff in non-blocking mode in Swoole
Provides
None
Conflicts
None
Replaces
None
README
Summary
Exponential back-offs prevent overloading an unavailable service by doubling the delay each iteration. This class uses an exponential back-off algorithm to calculate the delay for the next request.
This library allows doing exponential backoff in non-blocking mode in Swoole. Coroutines are detected before every wait, so one instance can be shared by coroutines and by ordinary code alike; pass \CrowdStar\Backoff\Mode::Blocking as the second constructor parameter to opt out, and \CrowdStar\Backoff\ExponentialBackoff::getMode() tells which mode a wait would happen in.
Installation
This library requires PHP 8.1 or above. For PHP 8.0 and below, use version 3.x instead.
composer require crowdstar/exponential-backoff:~4.0.0
Sample Usage
In following code pieces, we assume that you want to store return value of method MyClass::fetchData() in variable $result, and you want to do exponential backoff on that because something unexpected could happen when running method MyClass::fetchData().
1. Retry When Return Value Is Empty
Following code is to try to fetch some non-empty data back with method MyClass::fetchData(). This piece of code will try a few more times (by default 4) until either we get some non-empty data back, or we have reached maximum numbers of retries.
<?php use CrowdStar\Backoff\EmptyValueCondition; use CrowdStar\Backoff\ExponentialBackoff; $result = (new ExponentialBackoff(new EmptyValueCondition()))->run( function () { return MyClass::fetchData(); } ); ?>
2. Retry When Certain Exceptions Thrown Out
Following code is to try to fetch some data back with method MyClass::fetchData(), which may throw out exceptions. This piece of code will try a few more times (by default 4) until either we get some data back, or we have reached maximum numbers of retries.
NOTE: Internal PHP errors (class Error) won't trigger exponential backoff. They should be fixed manually. Nothing you list can change that: only exceptions ever reach a retry condition, so a TypeError ends a run however the condition is set up. Error and its subclasses are rejected outright when listed; Throwable is accepted, but no error ever reaches the condition for it to match.
<?php use CrowdStar\Backoff\ExceptionBasedCondition; use CrowdStar\Backoff\ExponentialBackoff; // Allow to catch multiple types of exceptions. $backoff = new ExponentialBackoff(new ExceptionBasedCondition(LogicException::class, RuntimeException::class)); try { $result = $backoff->run( function () { return MyClass::fetchData(); } ); } catch (Throwable $t) { // Handle the errors here. } ?>
To retry a whole family of exceptions except for one of its members, list that one through method \CrowdStar\Backoff\ExceptionBasedCondition::setIgnoredExceptions(). Ignored types are never retried, whichever types are being retried on, so there is no need to enumerate every sibling of the one you want left alone:
<?php use CrowdStar\Backoff\ExceptionBasedCondition; use CrowdStar\Backoff\ExponentialBackoff; // Retry any HttpException -- a 503 or a timeout is worth another attempt -- but give up on a 400 right away, since // sending the same bad request again will fail the same way. $condition = (new ExceptionBasedCondition(HttpException::class)) ->setIgnoredExceptions(HttpBadRequestException::class); $result = (new ExponentialBackoff($condition))->run( function () { return MyClass::fetchData(); } ); ?>
An ignored exception ends the run at once and is thrown out, the same as one that was never covered to begin with.
Don't Throw Out an Exception When Finally Failed
When method call MyClass::fetchData() finally fails with an exception caught, we can silence the exception without throwing it out by overriding method AbstractRetryCondition::throwable():
<?php use CrowdStar\Backoff\AbstractRetryCondition; use CrowdStar\Backoff\ExponentialBackoff; $backoff = new ExponentialBackoff( new class extends AbstractRetryCondition { public function throwable(): bool { return false; } public function shouldRetry(mixed $result, ?Exception $e): bool { return ($e instanceof Exception); } } ); $backoff->run( function () { return MyClass::fetchData(); } ); ?>
If needed, you can have more complex logic defined when overriding method AbstractRetryCondition::throwable().
3. Retry When Customized Condition Met
Following code is to try to fetch some non-empty data back with method MyClass::fetchData(). This piece of code works the same as the first example, except that here the condition to retry on is written out instead of coming from class \CrowdStar\Backoff\EmptyValueCondition. Method \CrowdStar\Backoff\ExponentialBackoff::when() takes a closure that receives what the call returned and what it threw, and returns TRUE for as long as another attempt should be made:
<?php use CrowdStar\Backoff\ExponentialBackoff; $result = ExponentialBackoff::when(fn (mixed $result): bool => empty($result))->run( function () { return MyClass::fetchData(); } ); ?>
The closure is given both the return value and the exception, so conditions about exceptions work the same way:
<?php use CrowdStar\Backoff\ExponentialBackoff; // Retry when a \RuntimeException was thrown, and don't throw it out when the last attempt still fails. $backoff = ExponentialBackoff::when( fn (mixed $result, ?Exception $e): bool => ($e instanceof RuntimeException), false ); $result = $backoff->run( function () { return MyClass::fetchData(); } ); ?>
Where a condition is worth naming and reusing, write a class for it instead, the way \CrowdStar\Backoff\EmptyValueCondition and \CrowdStar\Backoff\ExceptionBasedCondition do:
<?php use CrowdStar\Backoff\AbstractRetryCondition; use CrowdStar\Backoff\ExponentialBackoff; final class UntilRateLimitLifts extends AbstractRetryCondition { public function shouldRetry(mixed $result, ?Exception $e): bool { return ($result?->getStatusCode() === 429); } } $result = (new ExponentialBackoff(new UntilRateLimitLifts()))->run( function () { return MyClass::fetchData(); } ); ?>
4. More Options When Doing Exponential Backoff
Following code is to try to fetch some data back with method MyClass::fetchData(). This piece of code works the same as the second example, except that here the condition to retry on is written out instead of coming from class \CrowdStar\Backoff\ExceptionBasedCondition.
In this piece of code, we also show what options are available when doing exponential backoff with the package.
<?php use CrowdStar\Backoff\EmptyValueCondition; use CrowdStar\Backoff\ExceptionBasedCondition; use CrowdStar\Backoff\ExponentialBackoff; use CrowdStar\Backoff\Jitter; $backoff = new ExponentialBackoff(new EmptyValueCondition()); $backoff = new ExponentialBackoff(new ExceptionBasedCondition()); $backoff = new ExponentialBackoff(new ExceptionBasedCondition(LogicException::class, RuntimeException::class)); $backoff = ExponentialBackoff::when(fn (mixed $result, ?Exception $e): bool => ($e instanceof Exception)); $backoff ->setInitialDelay(1_000_000) // Wait up to about 1 second before the first retry; jitter decides. ->setInitialDelay(ExponentialBackoff::DEFAULT_INITIAL_DELAY) ->setMaxAttempts(3) ->setMaxAttempts(4) ->setMaxDelay(5_000_000) // Wait at most 5 seconds between two attempts. ->setMaxDelay(ExponentialBackoff::DEFAULT_MAX_DELAY) ->setMaxElapsedTime(2_000_000) // Give the whole run 2 seconds, however many attempts fit inside it. ->setMaxElapsedTime(null) // No budget at all. The default. ->setJitter(Jitter::Equal) // Wait at least half of the calculated delay, and randomly up to all of it. ->setJitter(Jitter::None) // Wait exactly as long as calculated; predictable, but no protection from collisions. ->setJitter(Jitter::Full); // Wait anywhere between nothing and the calculated delay. The default. $result = $backoff->run( function () { return MyClass::fetchData(); } ); ?>
Blocking and Non-Blocking Waits
Inside a Swoole coroutine the wait happens with Swoole\Coroutine::sleep(),
which suspends that coroutine instead of the process, so sibling coroutines keep running. Anywhere else it happens with
usleep(). Which one applies is worked out before every wait, so a single instance can be built during bootstrap and
then shared by coroutines and by ordinary code alike.
Pass a \CrowdStar\Backoff\Mode case as the second constructor argument to say so explicitly:
<?php use CrowdStar\Backoff\EmptyValueCondition; use CrowdStar\Backoff\ExponentialBackoff; use CrowdStar\Backoff\Mode; // Never wait with Swoole's coroutine sleep, even inside a coroutine. $backoff = new ExponentialBackoff(new EmptyValueCondition(), Mode::Blocking); // Wait non-blockingly wherever a coroutine is running -- which is what happens anyway, so this is the same as // passing nothing at all. $backoff = new ExponentialBackoff(new EmptyValueCondition(), Mode::Swoole); $backoff->getMode(); // Mode::Swoole or Mode::Blocking, whichever the next wait would use. ?>
There is a third case, Mode::Sleeper, which getMode() returns while a callback set with setSleeper() is doing the
waiting instead — see below. That one is an answer rather than a request: passing it to the constructor throws a
\CrowdStar\Backoff\Exception, because handing over the waiting is what setSleeper() is for and no case can stand in
for a callback.
Mode::Swoole where no coroutine is running falls back to a blocking wait rather than raising the Swoole\Error that
Coroutine::sleep() produces there.
One caveat on Mode::Blocking: it selects usleep(), which is not the same as a promise to block. Swoole's runtime
hooks turn usleep() into a coroutine yield, and SWOOLE_HOOK_SLEEP is enabled by default inside
Swoole\Coroutine\run(), so a wait made this way still does not block the coroutine it runs in unless those hooks are
switched off.
Doing the Waiting Elsewhere
Method \CrowdStar\Backoff\ExponentialBackoff::setSleeper() hands the waiting over to a callback of yours, which receives the wait in microseconds. Two things it is for: waiting on an event loop this library knows nothing about, and tests — a callback that records and returns makes a retrying test instant, and lets it assert the delays that would have been waited for:
<?php use CrowdStar\Backoff\EmptyValueCondition; use CrowdStar\Backoff\ExponentialBackoff; use CrowdStar\Backoff\Jitter; $slept = []; $backoff = (new ExponentialBackoff(new EmptyValueCondition())) ->setJitter(Jitter::None) ->setSleeper(function (int $microSeconds) use (&$slept): void { $slept[] = $microSeconds; }); $backoff->run(function () { return MyClass::fetchData(); }); // $slept is now [250000, 500000, 1000000], and the test took no time at all. ?>
A sleeper takes precedence over blocking and non-blocking mode both, and getMode() reports Mode::Sleeper while one
is set. Pass NULL to hand the waiting back.
5. To Disable Exponential Backoff Temporarily
There are two ways to disable exponential backoff temporarily for code piece like following:
<?php $result = MyClass::fetchData(); ?>
First, you may disable exponential backoff temporarily by calling method \CrowdStar\Backoff\ExponentialBackoff::disable(). For example:
<?php use CrowdStar\Backoff\EmptyValueCondition; use CrowdStar\Backoff\ExponentialBackoff; $backoff = new ExponentialBackoff(new EmptyValueCondition()); $backoff->disable(); $result = $backoff->run(function () {return MyClass::fetchData();}); ?>
You may also disable exponential backoff temporarily by using class \CrowdStar\Backoff\NullCondition:
<?php use CrowdStar\Backoff\ExponentialBackoff; use CrowdStar\Backoff\NullCondition; $result = (new ExponentialBackoff(new NullCondition())) ->setRetryCondition(new NullCondition()) // The method here is for demonstration purpose. ->run(function () {return MyClass::fetchData();}); ?>
All these 3 code piece work the same, having return value of method call MyClass::fetchData() assigned to variable $result.
Things to Keep in Mind
Not Every Call Is Safe to Retry
A retry sends the same call again, so it is only safe where sending it twice is as good as sending it once. Reads usually are. Anything that creates or changes something may well not be: a request that timed out on the way back may have been carried out in full, and retrying it then does the work twice.
Where a call is not naturally repeatable, make it so before retrying — a payment provider taking an idempotency key, a database statement written to be a no-op the second time — or accept the duplicate knowingly. This library retries whatever closure it is handed and cannot tell the difference.
Retries Multiply When They Nest
Attempts multiply through layers rather than adding up. Retrying 4 times around a call that itself retries 4 times is 16 attempts, and three such layers is 64; a five-deep stack of three retries each reaches 243 attempts on whatever sits at the bottom, which is usually the thing that was already struggling.
Method \CrowdStar\Backoff\ExponentialBackoff::run() takes any closure at all, including one that retries inside. When several layers of your own code could each retry, pick one of them — as a rule the one closest to the failing call — and let the failure travel up from the others.
Sample Scripts
Sample scripts can be found under folder examples/. Before running them under CLI, please do a composer update first:
composer update -n