code-distortion/backoff is a PHP Library that provides retries and backoff delays when actions fail.
It's useful when you're working with services that might be temporarily unavailable, such as APIs.
// let Backoff manage the delays and trigger retries for you
$action = fn() => …; // do some work
$result = Backoff::exponential(2)->maxAttempts(10)->maxDelay(30)->attempt($action);
See the cheatsheet for an overview of what's possible.
- Installation
- General Backoff Tips
- Cheatsheet
- Usage
- Backoff Algorithms
- Configuration
- Managing Exceptions
- Managing "Invalid" Return Values
- Callbacks
- Logging
- Working With Test Suites
- Managing the Retry Loop Yourself
- Modelling
Install the package via composer:
composer require code-distortion/backoff
- Backoff attempts are intended to be used when actions fail because of transient issues (such as temporary service outages). When permanent errors occur (such as a 404 HTTP response), retrying should stop as it won't help.
- Be careful when nesting backoff attempts. This can unexpectedly increase the number of attempts and time taken.
- Actions taken during backoff attempts should be idempotent. Meaning, if the same action is performed multiple times, the outcome should be the same as if it were only performed once.
- The article Timeouts, retries, and backoff with jitter by Marc Brooker at AWS does a good job of explaining the concepts involved when using backoff strategies.
- The article Exponential Backoff And Jitter also by Marc Brooker is a good read if you're interested in the theory behind backoff algorithms and jitter. Marc explains the same concepts in a 2019 talk.
Quick examples…
// the usual case
$action = fn() => …; // do some work
$result = Backoff::exponential(2)->maxAttempts(10)->maxDelay(30)->attempt($action);
// selection of examples
$result = Backoff::exponential(1)->attempt($action, $default);
Backoff::polynomial(1)->attempt($action);
Backoff::sequence([1, 2, 3, 5, 10])->attempt($action);
Backoff::exponential(1)->equalJitter()->immediateFirstRetry()->attempt($action);
Backoff::exponential(1)->retryExceptions(MyException::class)->attempt($action);
Backoff::exponential(1)->retryWhen(false)->attempt($action);
Backoff::exponential(1)->retryUntil(true)->attempt($action);
Backoff::exponential(1)->failureCallback($failed)->attempt($action);
Start by picking an algorithm to use…
// backoff algorithms - in seconds
Backoff::fixed(2) // 2, 2, 2, 2, 2…
Backoff::linear(5) // 5, 10, 15, 20, 25…
Backoff::linear(5, 10) // 5, 15, 25, 35, 45…
Backoff::exponential(1) // 1, 2, 4, 8, 16…
Backoff::exponential(1, 1.5) // 1, 1.5, 2.25, 3.375, 5.0625…
Backoff::polynomial(1) // 1, 4, 9, 16, 25…
Backoff::polynomial(1, 1.5) // 1, 2.8284271247462, 5.1961524227066, 8, 11.180339887499…
Backoff::fibonacci(1) // 1, 1, 2, 3, 5…
Backoff::decorrelated(1) // 1.6147780669, 2.9651922732, 5.7128698436, 10.3225378844, 2.3890401166…
Backoff::random(2, 5) // 2.7361497528, 2.8163467878, 4.6468904857, 3.3016198676, 3.3810068137…
Backoff::sequence([1, 2, 3, 5, 10]) // 1, 2, 3, 5, 10
Backoff::sequence([1, 2, 3, 5, 10], true) // 1, 2, 3, 5, 10, 10, 10, 10, 10…
Backoff::callback($callback) // $callback(1, $prev), $callback(2, $prev), $callback(3, $prev)…
Backoff::custom($backoffAlgorithm) // delay managed by a custom backoff algorithm class
// backoff algorithms - in milliseconds
Backoff::fixedMs(2) // 2, 2, 2, 2, 2…
Backoff::linearMs(5) // 5, 10, 15, 20, 25…
Backoff::linearMs(5, 10) // 5, 15, 25, 35, 45…
Backoff::exponentialMs(1) // 1, 2, 4, 8, 16…
Backoff::exponentialMs(1, 1.5) // 1, 1.5, 2.25, 3.375, 5.0625…
Backoff::polynomialMs(1) // 1, 4, 9, 16, 25…
Backoff::polynomialMs(1, 1.5) // 1, 2.8284271247462, 5.1961524227066, 8, 11.180339887499…
Backoff::fibonacciMs(1) // 1, 1, 2, 3, 5…
Backoff::decorrelatedMs(1) // 1.6147780669, 2.9651922732, 5.7128698436, 10.3225378844, 2.3890401166…
Backoff::randomMs(2, 5) // 2.7361497528, 2.8163467878, 4.6468904857, 3.3016198676, 3.3810068137…
Backoff::sequenceMs([1, 2, 3, 5, 10]) // 1, 2, 3, 5, 10
Backoff::sequenceMs([1, 2, 3, 5, 10], true) // 1, 2, 3, 5, 10, 10, 10, 10, 10…
Backoff::callbackMs($callback) // $callback(1, $prev), $callback(2, $prev), $callback(3, $prev)…
Backoff::customMs($backoffAlgorithm) // delay managed by a custom backoff algorithm class
// backoff algorithms - in microseconds
Backoff::fixedUs(2) // 2, 2, 2, 2, 2…
Backoff::linearUs(5) // 5, 10, 15, 20, 25…
Backoff::linearUs(5, 10) // 5, 15, 25, 35, 45…
Backoff::exponentialUs(1) // 1, 2, 4, 8, 16…
Backoff::exponentialUs(1, 1.5) // 1, 1.5, 2.25, 3.375, 5.0625…
Backoff::polynomialUs(1) // 1, 4, 9, 16, 25…
Backoff::polynomialUs(1, 1.5) // 1, 2.8284271247462, 5.1961524227066, 8, 11.180339887499…
Backoff::fibonacciUs(1) // 1, 1, 2, 3, 5…
Backoff::decorrelatedUs(1) // 1.6147780669, 2.9651922732, 5.7128698436, 10.3225378844, 2.3890401166…
Backoff::randomUs(2, 5) // 2.7361497528, 2.8163467878, 4.6468904857, 3.3016198676, 3.3810068137…
Backoff::sequenceUs([1, 2, 3, 5, 10]) // 1, 2, 3, 5, 10
Backoff::sequenceUs([1, 2, 3, 5, 10], true) // 1, 2, 3, 5, 10, 10, 10, 10, 10…
Backoff::callbackUs($callback) // $callback(1, $prev), $callback(2, $prev), $callback(3, $prev)…
Backoff::customUs($backoffAlgorithm) // delay managed by a custom backoff algorithm class
// utility backoff algorithms
Backoff::noop() // 0, 0, 0, 0, 0…
Backoff::none() // (1 attempt, no retries)
Then customise the retry logic…
// max-attempts (default = no limit)
->maxAttempts(10) // the maximum number of attempts allowed
->maxAttempts(null) // remove the limit, or
->noMaxAttempts() // remove the limit, or
->noAttemptLimit() // alias for noMaxAttempts()
// max-delay - the maximum delay to wait between each attempt (default = no limit)
->maxDelay(30) // set the max-delay, in the current unit-of-measure
->maxDelay(null) // remove the limit, or
->noMaxDelay() // remove the limit, or
->noDelayLimit() // alias for noMaxDelay()
// choose the type of jitter to apply to the delay (default = full jitter)
->fullJitter() // apply full jitter, between 0 and 100% of the base-delay (applied by default)
->equalJitter() // apply equal jitter, between 50% and 100% of the base-delay
->jitterRange(0.75, 1.25) // apply jitter between $min and $max (e.g. 0.75 = 75%, 1.25 = 125%) of the base-delay
->jitterCallback($callback) // specify a callback that applies the jitter
->customJitter($jitter) // jitter managed by a custom jitter class
->noJitter() // disable jitter - the base-delay will be used as-is
// insert an initial retry that happens straight away
// before the backoff algorithm starts generating delays (default = off)
->immediateFirstRetry() // insert an immediate retry
->immediateFirstRetry(false) // don't insert an immediate retry, or
->noImmediateFirstRetry() // don't insert an immediate retry
// turn off delays or retries altogether - may be useful when running tests (default = enabled)
->onlyDelayWhen(!$runningTests) // enable or disable delays (disabled means delays are 0)
->onlyRetryWhen(!$runningTests) // enable or disable retries (disabled means only 1 attempt will be made)
Retry only in certain situations if you'd like…
// retry based on exceptions…
// retry when any exception occurs (this is the default setting)
// along with $default which is returned if all attempts fail
// if $default is omitted, the final exception will be rethrown
->retryAllExceptions()
->retryAllExceptions($default)
// retry when these particular exceptions occur
// (you can specify multiple types of exceptions by passing
// them as an array, or by calling this multiple times)
->retryExceptions(MyException::class)
->retryExceptions(MyException::class, $default)
// you can also specify a callback that chooses whether to retry or not
// (return true to retry, false to end)
// $callback(Throwable $e, AttemptLog $log): bool
->retryExceptions($callback);
->retryExceptions($callback, $default);
// or choose to NOT retry when exceptions occur
// if $default is omitted, any exceptions will be rethrown
->retryExceptions(false) // or
->dontRetryExceptions()
->retryExceptions(false, $default) // or
->dontRetryExceptions($default)
// retry based on the return value…
// (by default, retries won't happen based on the return value)
// retry WHEN this value is returned,
// along with $default which is returned if all attempts fail
// (you can call this multiple times to add different values)
->retryWhen($match, $strict = false)
->retryWhen($match, $strict, $default)
// you can also specify a callback that chooses whether to retry or not
// (return true to retry, false to end)
// $callback(mixed $result, AttemptLog $log): bool
->retryWhen($callback)
->retryWhen($callback, false, $default) // strict doesn't matter when using a callback
// retry UNTIL this value is returned
// (you can call this multiple times to add different values)
->retryUntil($match, $strict = false)
// you can also pass a callback that chooses whether to retry or not
// (unlike ->retryWhen(…), here you return false to retry, true to end)
// $callback(mixed $result, AttemptLog $log): bool
->retryUntil($callback)
Add callbacks if desired…
// (you can specify multiple callbacks at a time by passing
// them as an array, or by calling these methods multiple times)
// called when any exception occurs
// $callback(Throwable $e, AttemptLog $log, bool $willRetry): void
->exceptionCallback($callback)
// called when an "invalid" value is returned
// $callback(mixed $result, AttemptLog $log, bool $willRetry): void
->invalidResultCallback($callback)
// called after an attempt succeeds
// $callback(AttemptLog[] $logs): void
->successCallback($callback)
// called after all attempts fail, including when no
// attempts occur, and when an exception is thrown
// $callback(AttemptLog[] $logs): void
->failureCallback($callback)
// called afterwards regardless of the outcome, including
// when no attempts occur, and when an exception is thrown
// $callback(AttemptLog[] $logs): void
->finallyCallback($callback)
And finally, run your work…
->attempt($action); // run your callback and retry it when needed
->attempt($action, $default); // run your callback, retry it when needed, and return $default if all attempts fail
Start by picking an algorithm to use (which calculates the delay durations), configure it as needed, and then use it to run your work by passing a closure to ->attempt()
.
By default, your closure will be retried when exceptions occur. The value returned by your closure will be returned when it succeeds.
use CodeDistortion\Backoff\Backoff;
$action = fn() => …; // do some work
$result = Backoff::exponential(1)->maxDelay(30)->maxAttempts(10)->attempt($action);
When exceptions occur, the final exception is rethrown. However, you can pass a default value to be returned instead if all attempts fail.
$result = Backoff::exponential(1)->maxDelay(30)->maxAttempts(10)->attempt($action, $default);
Note: If you pass a callable default value, it will be called when the default is needed. Its return value will be returned.
Backoff algorithms are used to calculate how long to wait for between attempts. They usually increase the delay between attempts in some way.
Note: The actual delays will vary because Jitter is applied to the base-delays the algorithms generate. This is designed to make the retries less predictable.
By default, delays are in seconds. However, each algorithm has a millisecond and microsecond variation.
Note: Delays in any unit-of-measure can have decimal places, including seconds.
Note: Microseconds are probably small enough that the numbers start to become inaccurate because of PHP overheads when sleeping. For example, on my computer, while code can run quicker than a microsecond, running usleep(1) to sleep for 1 microsecond actually takes about 55 microseconds.
A range of backoff algorithms have been included to choose from, and you can also create your own…
The fixed backoff algorithm waits the same amount of time between each attempt.
// Backoff::fixed($delay)
Backoff::fixed(2)->attempt($action); // 2, 2, 2, 2, 2…
Backoff::fixedMs(2)->attempt($action); // in milliseconds
Backoff::fixedUs(2)->attempt($action); // in microseconds
The linear backoff algorithm increases the waiting period by a specific amount each time.
The amount to increase by defaults to $initialDelay
when not set.
Logic: $delay = $initialDelay + (($retryNumber - 1) * $delayIncrease)
// Backoff::linear($initalDelay, $delayIncrease = null)
Backoff::linear(5)->attempt($action); // 5, 10, 15, 20, 25…
Backoff::linear(5, 10)->attempt($action); // 5, 15, 25, 35, 45…
Backoff::linearMs(5)->attempt($action); // in milliseconds
Backoff::linearUs(5)->attempt($action); // in microseconds
The exponential backoff algorithm increases the waiting period exponentially.
By default, the delay is doubled each time, but you can change the factor it multiplies by.
Logic: $delay = $initialDelay * pow($factor, $retryNumber - 1)
// Backoff::exponential($initalDelay, $factor = 2)
Backoff::exponential(1)->attempt($action); // 1, 2, 4, 8, 16…
Backoff::exponential(1, 1.5)->attempt($action); // 1, 1.5, 2.25, 3.375, 5.0625…
Backoff::exponentialMs(1)->attempt($action); // in milliseconds
Backoff::exponentialUs(1)->attempt($action); // in microseconds
The polynomial backoff algorithm increases the waiting period in a polynomial manner.
By default, the retry number is raised to the power of 2, but you can change this.
Logic: $delay = $initialDelay * pow($retryNumber, $power)
// Backoff::polynomial($initialDelay, $power = 2)
Backoff::polynomial(1)->attempt($action); // 1, 4, 9, 16, 25…
Backoff::polynomial(1, 1.5)->attempt($action); // 1, 2.8284271247462, 5.1961524227066, 8, 11.180339887499…
Backoff::polynomialMs(1)->attempt($action); // in milliseconds
Backoff::polynomialUs(1)->attempt($action); // in microseconds
The Fibonacci backoff algorithm increases waiting period by following a Fibonacci sequence. This is where each delay is the sum of the previous two delays.
Logic: $delay = $previousDelay1 + $previousDelay2
// Backoff::fibonacci($initialDelay, $includeFirst = false)
Backoff::fibonacci(1)->attempt($action); // 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89…
Backoff::fibonacci(5)->attempt($action); // 5, 5, 10, 15, 25, 40, 65, 105, 170, 275…
Backoff::fibonacciMs(1)->attempt($action); // in milliseconds
Backoff::fibonacciUs(1)->attempt($action); // in microseconds
Seeing as the first and second delays in a Fibonacci sequence are the same, you can choose to skip the first delay if you like.
Backoff::fibonacci(1, false)->attempt($action); // 1, 2, 3, 5, 8, 13, 21, 34, 55, 89…
Backoff::fibonacci(5, false)->attempt($action); // 5, 10, 15, 25, 40, 65, 105, 170, 275, 445…
The decorrelated backoff algorithm is a feedback loop where the previous delay is used as input to help to determine the next delay.
A random delay between the $baseDelay
and the previous-delay * 3
is picked.
Jitter is not applied to this algorithm.
Logic: $delay = rand($baseDelay, $prevDelay * $multiplier)
// Backoff::random($baseDelay, $multiplier = 3)
Backoff::decorrelated(1)->attempt($action); // 2.6501523185, 7.4707976956, 12.3241439061, 25.1076970005, 46.598982162…
Backoff::decorrelated(1, 2)->attempt($action); // 1.6147780669, 2.9651922732, 5.7128698436, 10.3225378844, 2.3890401166…
Backoff::decorrelatedMs(1)->attempt($action); // in milliseconds
Backoff::decorrelatedUs(1)->attempt($action); // in microseconds
The random backoff algorithm waits for a random period of time within the range you specify.
Jitter is not applied to this algorithm.
Logic: $delay = rand($min, $max)
// Backoff::random($min, $max)
Backoff::random(2, 5)->attempt($action); // 2.7361497528, 2.8163467878, 4.6468904857, 3.3016198676, 3.3810068137…
Backoff::randomMs(2, 5)->attempt($action); // in milliseconds
Backoff::randomUs(2, 5)->attempt($action); // in microseconds
The sequence backoff algorithm lets you specify the particular delays to use.
An optional fixed delay can be used to continue with, after the sequence finishes. Otherwise, the attempts will stop when the sequence has been exhausted.
Note: You'll need to make sure the delay values you specify match the unit-of-measure being used.
Logic: $delay = $delays[$retryNumber - 1]
// Backoff::sequence($delays, $continuation = null)
Backoff::sequence([1, 1.25, 1.5, 2, 3])->attempt($action); // 1, 1.25, 1.5, 2, 3
Backoff::sequence([1, 1.25, 1.5, 2, 3], true)->attempt($action); // 1, 1.25, 1.5, 2, 3, 3, 3, 3, 3…
Backoff::sequenceMs([1, 1.25, 1.5, 2, 3])->attempt($action); // in milliseconds
Backoff::sequenceUs([1, 1.25, 1.5, 2, 3])->attempt($action); // in microseconds
Note: If you use
->immediateFirstRetry()
, one more retry will be made than the number of attempts in your sequence.
The callback backoff algorithm lets you specify a callback that chooses the waiting period.
Your callback is expected to return an int
or float
representing the delay, or null
to indicate that the attempts should stop.
Logic: $delay = $callback($retryNumber, $prevBaseDelay)
// $callback = function (int $retryNumber, int|float|null $prevBaseDelay): int|float|null …
Backoff::callback($callback)->attempt($action); // $callback(1, $prev), $callback(2, $prev), $callback(3, $prev)…
Backoff::callbackMs($callback)->attempt($action); // in milliseconds
Backoff::callbackUs($callback)->attempt($action); // in microseconds
Note: You'll need to make sure the delay values you return match the unit-of-measure being used.
Note: If you use
->immediateFirstRetry()
, the first retry will be made before delays from your callback are used.In this case,
$retryNumber
will start with 1, but it will really be for the second attempt onwards.
As well as the callback option above, you have the ability to create your own backoff algorithm class by implementing the BackoffAlgorithmInterface
.
// MyBackoffAlgorithm.php
use CodeDistortion\Backoff\Interfaces\BackoffAlgorithmInterface;
use CodeDistortion\Backoff\Support\BaseBackoffAlgorithm;
class MyBackoffAlgorithm extends BaseBackoffAlgorithm implements BackoffAlgorithmInterface
{
/** @var boolean Whether jitter may be applied to the delays calculated by this algorithm. */
public bool $jitterMayBeApplied = true;
public function __construct(
// e.g. private int|float $initialDelay,
// … and any other parameters you need
) {
}
public function calculateBaseDelay(int $retryNumber, int|float|null $prevBaseDelay): int|float|null
{
return …; // your logic here
}
}
Then use your custom backoff algorithm like this:
$algorithm = new MyBackoffAlgorithm(…);
Backoff::custom($algorithm)->attempt($action);
Backoff::customMs($algorithm)->attempt($action); // in milliseconds
Backoff::customUs($algorithm)->attempt($action); // in microseconds
Note: You'll need to make sure the delay values you return match the unit-of-measure being used.
Note: If you use
->immediateFirstRetry()
, the first retry will be made before delays from your callback are used.In this case,
$retryNumber
will start with 1, but it will really be for the second attempt onwards.
The "no-op" backoff algorithm is a utility algorithm that doesn't wait at all, retries are attempted straight away.
This might be useful for testing purposes. See Backoff and Test Suites for more options when running tests.
Backoff::noop()->attempt($action); // 0, 0, 0, 0, 0…
The "no backoff" algorithm is a utility algorithm that doesn't allow retries at all. Only the first attempt will be made.
This might be useful for testing purposes. See Backoff and Test Suites for more options when running tests.
Backoff::none()->attempt($action); // (no retries)
By default, Backoff will retry forever. To stop this from happening, you can specify the maximum number of attempts allowed.
Backoff::exponential(1)
->maxAttempts(5) // <<<
->attempt($action);
You can specify the maximum length each base-delay (which is the delay before jitter is applied) can be. This is useful for preventing the delays from becoming too long.
Note: You'll need to make sure the max-delay you specify matches the unit-of-measure being used.
Backoff::exponential(10)
->maxDelay(200) // <<<
->attempt($action);
If you'd like your first retry to occur immediately after the first failed attempt, you can add an initial 0 delay by calling ->immediateFirstRetry()
. This will be inserted before the normal backoff delays start.
Backoff::exponential(10)
->maxAttempts(5)
->immediateFirstRetry() // <<< 0, 10, 20, 40, 80…
->attempt($action);
This won't affect the maximum attempt limit. So if you set a maximum of 5 attempts, and you use ->immediateFirstRetry()
, there will still be up to 5 attempts in total.
Having a backoff algorithm probably isn't enough on its own to prevent a stampede when there are a large number of clients retrying at the same moments in time. Jitter is used to help mitigate this by adding a random factor to spread them out.
Jitter is the concept of making random adjustments to the delays generated by the backoff algorithm.
For example, if the backoff algorithm generates a delay of 100ms, jitter adjusts this to be somewhere between say, 75ms and 125ms. The actual range is determined by the type of jitter used.
This library applies Full Jitter by default. You can use No Jitter if you'd like to turn jitter off.
The article Exponential Backoff And Jitter by Marc Brooker at AWS does a good job of explaining what jitter is, and the reason for its use.
Full Jitter applies a random adjustment to the delay, within the range of 0 and the full delay. That is, between 0% and 100% of the base-delay.
Note: This is the type of jitter that is used by default.
$delay = rand(0, $delay)
Backoff::exponential(1)
->fullJitter() // <<< between 0% and 100%
->attempt($action);
Equal Jitter applies a random adjustment to the delay, within the range of half and the full delay. That is, between 50% and 100% of the base-delay.
$delay = rand($delay / 2, $delay)
Backoff::exponential(1)
->equalJitter() // <<< between 50% and 100%
->attempt($action);
If you'd like a different range compared to full and equal jitter above, jitter-range lets you specify your own custom range.
$delay = rand($delay * $min, $delay * $max)
Backoff::exponential(1)
->jitterRange(0.5, 1.5) // <<< between 50% and 150%
->attempt($action);
Jitter callback lets you specify a callback that applies jitter to the base-delay.
Your callback is expected to return an int
or float
representing the updated delay.
$delay = $callback($delay, $retryNumber)
// $callback = function (int|float $delay, int $retryNumber): int|float …
$callback = fn(int|float $delay, int $retryNumber): int|float => …; // your logic here
Backoff::exponential(1)
->jitterCallback($callback) // <<<
->attempt($action);
As well as customising jitter using the range and callback options above, you have the ability to create your own jitter class by implementing the JitterInterface
.
// MyJitter.php
use CodeDistortion\Backoff\Interfaces\JitterInterface;
use CodeDistortion\Backoff\Support\BaseJitter;
class MyJitter extends BaseJitter implements JitterInterface
{
public function __construct(
// … any configuration parameters you need
) {
}
public function apply(int|float $delay, int $retryNumber): int|float
{
return …; // your logic here
}
}
You can then use your custom jitter class like this:
$jitter = new MyJitter(…);
Backoff::exponential(1)
->customJitter($jitter) // <<< return the base-delay with no adjustment
->attempt($action);
Full Jitter is applied by default, however you can turn it off by calling ->noJitter()
.
Backoff::exponential(1)
->noJitter() // <<<
->attempt($action);
When disabled, the base-delays generated by the backoff algorithm will be used as-is (except for max-delay being applied).
By default, Backoff will retry whenever an exception occurs. You can customise this behaviour using the following methods.
Retry all exceptions - this is actually the default behaviour, so you don't need to call it unless you've previously set it to something else.
Backoff::exponential(1)
->retryAllExceptions() // <<<
->attempt($action);
By default, when all attempts have failed (e.g. when ->maxAttempts(…)
is used), the final exception is rethrown afterwards.
You can pass a default value to return instead when that happens.
Backoff::exponential(1)
->retryAllExceptions($default) // <<<
->attempt($action);
Note: If you pass a callable default value, it will be called when the default is needed. Its return value will be returned.
You can specify particular exception types to be caught and retried, along with the optional $default
value to return if all attempts fail.
Backoff::exponential(1)
->retryExceptions(MyException::class, $default) // <<<
->attempt($action);
If you'd like to specify more than one, you can pass them in an array, or call it multiple times. You can specify a different $default
value each call.
Backoff::exponential(1)
->retryExceptions([MyException1::class, MyException2::class], $default1) // <<<
->retryExceptions(MyException3::class, $default2) // <<<
->attempt($action);
Note: If you pass a callable default value, it will be called when the default is needed. Its return value will be returned.
You can also pass a callback that chooses whether to retry or not. The exception will be passed to your callback, and it should return true
to try again, or false
to end.
$callback = fn(Throwable $e, AttemptLog $log): bool => …; // your logic here
Backoff::exponential(1)
->retryExceptions($callback, $default) // <<<
->attempt($action);
And finally, you can turn this off so retries are not made when exceptions occur.
Backoff::exponential(1)
->retryExceptions(false) // <<<
->attempt($action);
// or
Backoff::exponential(1)
->dontRetryExceptions() // <<<
->attempt($action);
Normally, the exception will be rethrown. However, you can pass a $default
value to return instead.
Backoff::exponential(1)
->dontRetryExceptions($default) // <<<
->attempt($action);
Note: If you pass a callable default value, it will be called when the default is needed. Its return value will be returned.
By default, Backoff will not retry based on the value returned by your $action
callback. However, you can choose to if you like.
You can specify for retries to occur when particular values are returned, along with an optional $default
value to return if all attempts fail.
$strict
allows you to compare the returned value to $match
using strict comparison (===
).
When you don't specify a default, the final value returned by $action
will be returned.
Backoff::exponential(1)
->retryWhen($match, $strict = false, $default = null) // <<<
->attempt($action);
You can also pass a callback that chooses whether to retry or not. Your callback should return true
to try again, or false
to stop.
Note:
$strict
has no effect when using a callback.
$callback = fn(mixed $result, AttemptLog $log): bool => …; // your logic here
Backoff::exponential(1)
->retryWhen($callback, false, $default = null) // <<<
->attempt($action);
Note: If you pass a callable default value, it will be called when the default is needed. Its return value will be returned.
Conversely to ->retryWhen()
, you can specify $match
value/s to wait for, retrying until they're returned.
Backoff::exponential(1)
->retryUntil($match, $strict = false) // <<<
->attempt($action);
Similarly, $strict
allows you to compare the returned value to $value
using strict comparison (===
).
You can also specify a callback that chooses whether to retry or not. Contrasting with ->retryWhen()
above, your callback should return false
to try again, or true
to stop.
Note:
$strict
has no effect when using a callback.
$callback = fn(mixed $result, AttemptLog $log): bool => …; // your logic here
Backoff::exponential(1)
->retryUntil($callback) // <<<
->attempt($action);
Note: You can't specify a default value for retry until, but you can pass one to
->attempt($action, $default)
.
Several callback options are available to trigger your code at different points in the attempt lifecycle.
Backoff passes an AttemptLog
object (or an array of them, depending on the callback) to these callbacks. See below for information about the AttemptLog class.
If you'd like to run some code every time an exception occurs, you can pass a callback to ->exceptionCallback(…)
.
It doesn't matter if the exception is caught using ->retryExceptions(…) or not. These callbacks will be called regardless of a retry being made.
$callback = fn(…) => …; // do something here
// the callback can accept these parameters:
// $e - called 'e' - the exception that was thrown
// $exception - called 'exception' - the exception that was thrown
// Throwable $e - of type 'Throwable', or any particular exception type you'd like to catch
// $willRetry - called 'willRetry' - true if a retry will be made, false if not
// $log - called 'log' - the current AttemptLog object
// AttemptLog $log - of type 'AttemptLog' - the current AttemptLog object
// $logs - called 'logs' - an array of AttemptLog objects
Backoff::exponential(1)
->exceptionCallback($callback) // <<<
->attempt($action);
If you'd like to run some code each time an invalid result is returned, you can pass a callback to ->invalidResultCallback(…)
.
$callback = fn(…) => …; // do something here
// the callback can accept these parameters:
// $result - called 'result' - the result that was returned
// $willRetry - called 'willRetry' - true if a retry will be made, false if not
// $log - called 'log' - the current AttemptLog object
// AttemptLog $log - of type 'AttemptLog' - the current AttemptLog object
// $logs - called 'logs' - an array of AttemptLog objects
Backoff::exponential(1)
->invalidResultCallback($callback) // <<<
->attempt($action);
You can specify a callback to be called once, after the attempt succeeds by calling ->successCallback(…)
.
An array of AttemptLog
objects representing the attempts that were made will be passed to your callback.
$callback = fn(…) => …; // do something here
// the callback can accept these parameters:
// $result - called 'result' - the result that was returned
// $log - called 'log' - the current AttemptLog object
// AttemptLog $log - of type 'AttemptLog' - the current AttemptLog object
// $logs - called 'logs' - an array of AttemptLog objects
Backoff::exponential(1)
->successCallback($callback) // <<<
->attempt($action);
You can specify a callback to be called once, after all attempts have failed by calling ->failureCallback(…)
.
This includes if zero attempts were made, and when an exception is eventually thrown.
An array of AttemptLog
objects representing the attempts that were made will be passed to your callback.
/** @var AttemptLog[] $logs */
$callback = fn(…) => …; // do something here
// the callback can accept these parameters:
// $log - called 'log' - the current AttemptLog object
// AttemptLog $log - of type 'AttemptLog' - the current AttemptLog object
// $logs - called 'logs' - an array of AttemptLog objects
Backoff::exponential(1)
->failureCallback($callback) // <<<
->attempt($action);
If you would like to run some code once, afterwards, regardless of the outcome, you can pass a callback to ->finallyCallback(…)
.
This includes if zero attempts were made, and when an exception is eventually thrown.
An array of AttemptLog
objects representing the attempts that were made will be passed to your callback.
/** @var AttemptLog[] $logs */
$callback = fn(…) => …; // do something here
// the callback can accept these parameters:
// $log - called 'log' - the current AttemptLog object
// AttemptLog $log - of type 'AttemptLog' - the current AttemptLog object
// $logs - called 'logs' - an array of AttemptLog objects
Backoff::exponential(1)
->finallyCallback($callback) // <<<
->attempt($action);
Backoff collects some basic information about each attempt as they happen.
This history is made up of AttemptLog
objects which you can access objects via callbacks.
These AttemptLog objects provide the information you need to log.
Note: If you manage the looping process yourself, there are some extra ways to interact with these logs.
The AttemptLog
class contains basic information about each attempt that has happened.
They contain the following methods:
$log->attemptNumber(); // the attempt being made (1, 2, 3…)
$log->retryNumber(); // the retry being made (0, 1, 2…)
// the maximum possible attempts
// (returns null for unlimited attempts)
// note: it's possible for a backoff algorithm to return null
// so the attempts finish early. This won't be reflected here
$log->maxAttempts();
$log->firstAttemptOccurredAt(); // when the first attempt started
$log->thisAttemptOccurredAt(); // when the current attempt started
// the time spent on this attempt
// (will be null until known)
$log->workingTime(); // in the current unit-of-measure
$log->workingTimeInSeconds(); // in seconds
$log->workingTimeInMs(); // in milliseconds
$log->workingTimeInUs(); // in microseconds
// the overall time spent attempting the action (so far)
// (sum of all working time, will be null until known)
$log->overallWorkingTime(); // in the current unit-of-measure
$log->overallWorkingTimeInSeconds(); // in seconds
$log->overallWorkingTimeInMs(); // in milliseconds
$log->overallWorkingTimeInUs(); // in microseconds
// the delay that was applied before this attempt
// (will be null for the first attempt)
$log->prevDelay(); // in the current unit-of-measure
$log->prevDelayInSeconds(); // in seconds
$log->prevDelayInMs(); // in milliseconds
$log->prevDelayInUs(); // in microseconds
// the delay that will be used before the next attempt
// (will be null if there are no more attempts left)
$log->nextDelay(); // in the current unit-of-measure
$log->nextDelayInSeconds(); // in seconds
$log->nextDelayInMs(); // in milliseconds
$log->nextDelayInUs(); // in microseconds
// the overall delay so far (sum of all delays)
$log->overallDelay(); // in the current unit-of-measure
$log->overallDelayInSeconds(); // in seconds
$log->overallDelayInMs(); // in milliseconds
$log->overallDelayInUs(); // in microseconds
// the unit-of-measure used
// these are values from CodeDistortion\Backoff\Settings::UNIT_XXX
$log->unitType();
When running your test-suite, you might want to disable the backoff delays, or stop retries altogether.
You can remove the delay between attempts using ->onlyDelayWhen(false)
.
The action may still be retried, but there won't be any delays between attempts.
$runningTests = …;
Backoff::exponential(1)
->maxAttempts(10)
// 0, 0, 0, 0, 0… delays when running tests
->onlyDelayWhen(!$runningTests) // <<<
->attempt($action);
When
$runningTests
istrue
, this is:
- equivalent to setting
->maxDelay(0)
, and- is largely equivalent to using the
Backoff::noop()
backoff.
Alternatively, you can disable retries altogether using ->onlyRetryWhen(false)
.
$runningTests = …;
$backoff = Backoff::exponential(1)
->maxAttempts(10)
// no reties when running tests
->onlyRetryWhen(!$runningTests) // <<<
->attempt($action);
When
$runningTests
istrue
, this is equivalent to:
- setting
->maxAttempts(1)
, or- using the
Backoff::none()
backoff algorithm.
If you'd like more control over the process, you can manage the retry loop yourself. This involves setting up a loop, and using Backoff to handle the delays each iteration.
Please note that by doing this, you're skipping the part of Backoff that manages the loop and retry process, essentially handling them yourself. This means that you won't be able to use Backoff's functionality to catch and retry because of exceptions or when certain values are returned, or trigger callbacks. You'll need to manage these yourself.
If your aim is to do one of the following, you could use one of the already available options:
- Catch and retry exceptions - If you'd like Backoff to catch particular exceptions, you can use ->retryExceptions(…). This lets you specify which exceptions to retry, or specify a callback to make the decision.
- Retry based on return values - If you'd like to selectively retry based on particular return values, you can use ->retryWhen(…) or ->retryUntil(…). These let you specify values to check for, or specify a callback to make the decision.
- Trigger callbacks - If you'd like to perform tasks in certain situations (like logging before each retry delay), you could consider using the callback options such as ->exceptionCallback(…) or ->invalidResultCallback(…).
Start by picking a backoff algorithm and configure it as you normally would. Then incorporate it into your loop.
Call ->step()
to proceed to the next attempt. This sleeps for the appropriate amount of time, and returns false
when the attempts have been exhausted.
use CodeDistortion\Backoff\Backoff;
// choose a backoff algorithm and configure it as needed
$backoff = Backoff::exponential(1)->maxDelay(30)->maxAttempts(10);
// then use it in your loop
do {
$success = …; // do some work
} while ((!$success) && ($backoff->step())); // <<<
If you'd like to attempt your action zero or more times, you can place $backoff->step()
at the entrance of your loop, having called ->runsAtStartOfLoop()
beforehand.
This lets Backoff know, so it doesn't perform the delay and count the attempt the first time.
$maxAttempts = …; // possibly 0
// specify that $backoff->step() will be called at the entrance to your loop
$backoff = Backoff::exponential(1)->maxDelay(30)->maxAttempts($maxAttempts)->runsAtStartOfLoop(); // <<<
$success = false;
while ((!$success) && ($backoff->step())) { // <<<
$success = …; // do some work
};
Add a try-catch block to handle exceptions inside your loop, and handle the exception as you see fit.
$backoff = Backoff::exponential(1)->maxDelay(30)->maxAttempts(10)->runsAtStartOfLoop();
$success = false;
while ((!$success) && ($backoff->step())) {
try {
$success = …; // do some work
} catch (MyException $e) {
// handle the exception
}
};
You can separate the process into its parts if you'd like to have even more control over the process.
->step()
normally performs the sleep, but you can call ->step(false)
to skip the sleep, and then perform the sleep separately by calling ->sleep()
.
$backoff = Backoff::exponential(1)->maxDelay(30)->maxAttempts(10)->runsAtStartOfLoop();
$success = false;
while ((!$success) && ($backoff->step(false))) { // <<<
// there won't be a first delay because of ->runsAtStartOfLoop()
$backoff->sleep(); // <<<
$success = …; // do some work
};
You can also perform the sleep yourself (instead of calling ->sleep()
).
Call ->getDelayInSeconds()
, ->getDelayInMs()
, or ->getDelayInUs()
to retrieve the delay in the unit-of-measure you need.
$backoff = Backoff::exponential(1)->maxDelay(30)->maxAttempts(10)->runsAtStartOfLoop();
$success = false;
while ((!$success) && ($backoff->step(false))) { // <<<
// there won't be a first delay because of ->runsAtStartOfLoop()
// remember the ->getDelayInXXX() methods may return a float
if ($delay = (int) $backoff->getDelayInUs()) { // <<<
// note that usleep() might not support delays larger than 1 second
// https://www.php.net/usleep
usleep($delay);
}
$success = …; // do some work
};
When managing the loop yourself, add ->startOfAttempt()
and ->endOfAttempt()
around your work so the logs are built. You can then access:
- the current
AttemptLog
by calling$backoff->currentLog()
, - and the full history (so far) using
$backoff->logs()
.
$backoff = Backoff::exponential(1);
do {
$backoff->startOfAttempt(); // <<<
$success = …; // do some work
$backoff->endOfAttempt(); // <<<
$log = $backoff->currentLog(); // returns the current AttemptLog
// … perform some logging here based upon $log
} while ((!$success) && ($backoff->step()));
$logs = $backoff->logs(); // returns all the AttemptLogs in an array
There are the helpers you can use to help you manage the looping process.
// tell backoff where you'll call ->step() (default = at the end of the loop)
->runsAtStartOfLoop() // specify that $backoff->step() will be called at the entrance to your loop
->runsAtStartOfLoop(false) // specify that $backoff->step() will be called at the end of your loop (default), or
->runsAtEndOfLoop() // specify that $backoff->step() will be called at the end of your loop (default)
// trigger the backoff logic - placed in the structure of your loop
->step(); // calculate the delay and perform the sleep, returns false when the attempts are exhausted
// if you'd like to separate the sleep from ->step()
->step(false); // calculate delay without sleeping, returns false when the attempts are exhausted
->sleep(); // sleep for the delay calculated by ->step(false)
// if you'd like to perform the sleep yourself, call ->step(false) and then retrieve the delay
->getDelay(); // get the delay in the current unit-of-measure (note: may contain decimals)
->getDelayInSeconds(); // get the delay in seconds (note: may contain decimals)
->getDelayInMs(); // get the delay in milliseconds (note: may contain decimals)
->getDelayInUs(); // get the delay in microseconds (note: may contain decimals)
->getUnitType(); // get the unit-of-measure being used (from CodeDistortion\Backoff\Settings::UNIT_XXX)
// querying the state of the backoff
->currentAttemptNumber(); // get the current attempt number
->isFirstAttempt(); // check if the first attempt is currently being made
->isLastAttempt(); // check if the last attempt is currently being made (however it may run indefinitely)
->hasStopped(); // check if the attempts have been exhausted - this is the same value that ->step() returns
// working with logs
->startOfAttempt(); // start the attempt, so the log is built
->endOfAttempt(); // end the attempt, so the log is built
->currentLog(); // get the AttemptLog for the current attempt
->logs(); // get all of the AttemptLogs (so far)
// and finally
->reset(); // reset the backoff to its initial state, ready to be re-used
If you would like to run modelling on the backoff process, you can use a Backoff
instance to generate sets of delays without actually sleeping.
// generate delays in the current unit-of-measure
$backoff->simulate(1); // generate a single delay (e.g. for retry 1)
$backoff->simulate(10, 20); // generate a sequence of delays, returned as an array (e.g. for retries 10 - 20)
Equivalent methods exist to retrieve the delays in seconds, milliseconds and microseconds.
// generate delays in seconds (note: may contain decimals)
$backoff->simulateInSeconds(1);
$backoff->simulateInSeconds(1, 20);
// generate delays in milliseconds (note: may contain decimals)
$backoff->simulateInMs(1);
$backoff->simulateInMs(1, 20);
// generate delays in microseconds (note: may contain decimals)
$backoff->simulateInUs(1);
$backoff->simulateInUs(1, 20);
And just in case you need to check, you can retrieve the unit-of-measure being used.
// these are values from CodeDistortion\Backoff\Settings::UNIT_XXX
$backoff->getUnitType();
A null
value in the results indicates that the attempts have been exhausted.
Note: These methods will generate the same values when you call them again. Backoff maintains this state because some backoff algorithms base their delays on previously generated delays (e.g. the decorrelated backoff algorithm does this), so their values are important.
That is to say, when generating
$backoff->simulateDelays(1, 20);
and then$backoff->simulateDelays(21, 40);
, the second set may be based on the first set.To generate a new set of delays, call
$backoff->reset()
first.$first = $backoff->simulateDelays(1, 20); $second = $backoff->simulateDelays(1, 20); // $second will be the same as $first $third = $backoff->reset()->simulateDelays(1, 20); // however $third will be different
Info: If these methods don't work fast enough for you, you could look into the
DelayCalculator
class, whichBackoff
uses behind the scenes to calculate the delays.Generate delays with it, and then call
$delayCalculator->reset()
before generating a new set.
- Clone this package:
git clone https://github.com/code-distortion/backoff.git .
- Run
composer install
to install dependencies - Run the tests:
composer test
Please see CHANGELOG for more information on what has changed recently.
This library uses SemVer 2.0.0 versioning. This means that changes to X
indicate a breaking change: 0.0.X
, 0.X.y
, X.y.z
. When this library changes to version 1.0.0, 2.0.0 and so forth, it doesn't indicate that it's necessarily a notable release, it simply indicates that the changes were breaking.
This package is Treeware. If you use it in production, then we ask that you buy the world a tree to thank us for our work. By contributing to the Treeware forest you’ll be creating employment for local families and restoring wildlife habitats.
Please see CONTRIBUTING for details.
Please see CODE_OF_CONDUCT for details.
If you discover any security related issues, please email tim@code-distortion.net instead of using the issue tracker.
The MIT License (MIT). Please see License File for more information.