Skip to content

A PHP library implementing backoff strategies with jitter

License

Notifications You must be signed in to change notification settings

code-distortion/backoff

Repository files navigation

Backoff

Latest Version on Packagist PHP Version GitHub Workflow Status Buy The World a Tree Contributor Covenant

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.

Table of Contents

Installation

Install the package via composer:

composer require code-distortion/backoff

General Backoff Tips

  • 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.

Further Reading

Cheatsheet

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, 2Backoff::linear(5)                        // 5, 10, 15, 20, 25Backoff::linear(5, 10)                    // 5, 15, 25, 35, 45Backoff::exponential(1)                   // 1, 2, 4, 8, 16Backoff::exponential(1, 1.5)              // 1, 1.5, 2.25, 3.375, 5.0625Backoff::polynomial(1)                    // 1, 4, 9, 16, 25Backoff::polynomial(1, 1.5)               // 1, 2.8284271247462, 5.1961524227066, 8, 11.180339887499Backoff::fibonacci(1)                     // 1, 1, 2, 3, 5Backoff::decorrelated(1)                  // 1.6147780669, 2.9651922732, 5.7128698436, 10.3225378844, 2.3890401166Backoff::random(2, 5)                     // 2.7361497528, 2.8163467878, 4.6468904857, 3.3016198676, 3.3810068137Backoff::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, 10Backoff::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, 2Backoff::linearMs(5)                        // 5, 10, 15, 20, 25Backoff::linearMs(5, 10)                    // 5, 15, 25, 35, 45Backoff::exponentialMs(1)                   // 1, 2, 4, 8, 16Backoff::exponentialMs(1, 1.5)              // 1, 1.5, 2.25, 3.375, 5.0625Backoff::polynomialMs(1)                    // 1, 4, 9, 16, 25Backoff::polynomialMs(1, 1.5)               // 1, 2.8284271247462, 5.1961524227066, 8, 11.180339887499Backoff::fibonacciMs(1)                     // 1, 1, 2, 3, 5Backoff::decorrelatedMs(1)                  // 1.6147780669, 2.9651922732, 5.7128698436, 10.3225378844, 2.3890401166Backoff::randomMs(2, 5)                     // 2.7361497528, 2.8163467878, 4.6468904857, 3.3016198676, 3.3810068137Backoff::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, 10Backoff::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, 2Backoff::linearUs(5)                        // 5, 10, 15, 20, 25Backoff::linearUs(5, 10)                    // 5, 15, 25, 35, 45Backoff::exponentialUs(1)                   // 1, 2, 4, 8, 16Backoff::exponentialUs(1, 1.5)              // 1, 1.5, 2.25, 3.375, 5.0625Backoff::polynomialUs(1)                    // 1, 4, 9, 16, 25Backoff::polynomialUs(1, 1.5)               // 1, 2.8284271247462, 5.1961524227066, 8, 11.180339887499Backoff::fibonacciUs(1)                     // 1, 1, 2, 3, 5Backoff::decorrelatedUs(1)                  // 1.6147780669, 2.9651922732, 5.7128698436, 10.3225378844, 2.3890401166Backoff::randomUs(2, 5)                     // 2.7361497528, 2.8163467878, 4.6468904857, 3.3016198676, 3.3810068137Backoff::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, 10Backoff::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, 0Backoff::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

Usage

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

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

Fixed Backoff

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

Linear Backoff

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

Exponential Backoff

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

Polynomial Backoff

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

Fibonacci Backoff

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…

Decorrelated Backoff

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

Random Backoff

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

Sequence Backoff

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.

Callback Backoff

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.

Custom Backoff Algorithm Class

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.

Noop Backoff

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…

No Backoff

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)

Configuration

Max Attempts

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);

Delay

Max-Delay

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);

Immediate First Retry

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.

Jitter

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

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

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);

Custom Jitter Range

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

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);

Custom Jitter Class

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);

No Jitter

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).

Managing Exceptions

By default, Backoff will retry whenever an exception occurs. You can customise this behaviour using the following methods.

Retry When Any Exception Occurs

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.

Retry When Particular Exceptions Occur

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);

Don't Retry When Exceptions Occur

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.

Managing "Invalid" Return Values

By default, Backoff will not retry based on the value returned by your $action callback. However, you can choose to if you like.

Retry When…

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.

Retry Until…

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).

Callbacks

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.

Exception Callback

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);

Invalid Result Callback

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);

Success Callback

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);

Failure Callback

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);

Finally Callback

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);

Logging

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

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();

Working With Test Suites

When running your test-suite, you might want to disable the backoff delays, or stop retries altogether.

Disabling Backoff

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 is true, this is:

  • equivalent to setting ->maxDelay(0), and
  • is largely equivalent to using the Backoff::noop() backoff.

Disabling Retries

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 is true, this is equivalent to:

  • setting ->maxAttempts(1), or
  • using the Backoff::none() backoff algorithm.

Managing the Retry Loop Yourself

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:

The Basic Loop

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
};

Catching Exceptions in Your Loop

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
    }
};

Deconstructing the Backoff Logic

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
};

Working With Logs

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

Helpers When Managing The Loop Yourself

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

Modelling

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, which Backoff uses behind the scenes to calculate the delays.

Generate delays with it, and then call $delayCalculator->reset() before generating a new set.

Testing This Package

  • Clone this package: git clone https://github.com/code-distortion/backoff.git .
  • Run composer install to install dependencies
  • Run the tests: composer test

Changelog

Please see CHANGELOG for more information on what has changed recently.

SemVer

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.

Treeware

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.

Contributing

Please see CONTRIBUTING for details.

Code of Conduct

Please see CODE_OF_CONDUCT for details.

Security

If you discover any security related issues, please email tim@code-distortion.net instead of using the issue tracker.

Credits

License

The MIT License (MIT). Please see License File for more information.

About

A PHP library implementing backoff strategies with jitter

Resources

License

Code of conduct

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages