Skip to content

4.0.0

Choose a tag to compare

@deminy deminy released this 28 Aug 19:33
· 2 commits to master since this release
ad78b57

Composer users: require 4.0.1 instead. Packagist published 4.0.0 at an earlier commit, before the
timeout-to-delay rename landed, and a stable version there cannot be republished at a different reference, so
4.0.0 is not installable through Composer. 4.0.1 is this same code under a number Packagist accepts.

The list below is long, but nearly all of it is on API surface you had to opt into. An ordinary 3.x caller has a line or
two to change, often none. Where you stand:

Nothing to change if you are on PHP 8.1 or above, retry with one of the shipped conditions —
CrowdStar\Backoff\EmptyValueCondition, ExceptionBasedCondition, NullCondition — and never called ::setType(),
never passed the second constructor argument, and never called ::getTimeoutSeconds(), ::getTimeoutMicroseconds(),
::getCurrentAttempts() or ExceptionBasedCondition::setException()/::getException(). Your code runs unchanged.

One behavior change reaches you anyway, and it needs no code: a delay is now randomized over its whole length rather
than being lengthened by up to 10%, so the same run finishes sooner — 1.795 s against 0.465 s, measured with everything
left at its default. Pass Jitter::None to wait exactly as long as calculated, which is as close to 3.x as this gets.

A line or two if you used one of the settings that moved. Every one has a direct equivalent; the step numbers are
the ones under "Migration from 3.x" below.

If you called Use instead Step
::setType(TYPE_SECONDS) ::setInitialDelay(1_000_000) 3
::setType(TYPE_MICROSECONDS) nothing — it was, and is, the default 3
new ExponentialBackoff($condition, 1) new ExponentialBackoff($condition, Mode::Blocking) 4
new ExponentialBackoff($condition, 2) new ExponentialBackoff($condition, Mode::Swoole), or nothing at all 4
::getTimeoutMicroseconds() ::getDelayMicroseconds() 3
::getTimeoutSeconds() intdiv(::getDelayMicroseconds(…), 1_000_000) 3
::getCurrentAttempts() nothing; drop the call 6
ExceptionBasedCondition::setException() ::setExceptions(), which takes one or more types 5

Actual work, and only in these two cases: a retry condition of your own has to rename met() to ::shouldRetry()
and return the opposite (step 2), and a subclass that overrides ::run() has to declare mixed ...$params and a
mixed return type. Both fail loudly rather than quietly — a leftover met() is a fatal error naming the method, and a
mismatched ::run() is rejected at declaration — so neither can slip into production unnoticed.

Two further behavior changes, neither of them needing a code change: a single delay is capped at 30 seconds, which only
affects runs that would have waited longer than that — with the default initial delay, nine attempts or more; and PHP
8.0 and below are no longer supported, so stay on 3.x there. Against that, the Fixed entries below are two silent
correctness bugs: an instance built outside a coroutine used to block forever inside one, and two callers sharing an
instance used to cut each other's runs short while reporting success.

Changed

  • BREAKING PHP 8.1 or above is now required. PHP 8.0 and below are no longer supported; use version 3.x there.

  • BREAKING The unit of a delay is no longer a choice: delays are always expressed in microseconds, and how long to
    wait before the first retry is set with ExponentialBackoff::setInitialDelay() rather than picked from two hardcoded
    values. This is what the removed setType() was standing in for, and unlike it, any delay can now be expressed — a
    tenth of a second, or a second and a half.

  • BREAKING The second parameter of _ExponentialBackoff::_construct() is now ?CrowdStar\Backoff\Mode instead of
    an integer, and defaults to NULL (autodetect) instead of 0.

  • BREAKING Method AbstractRetryCondition::met() is now ::shouldRetry(), and it answers the opposite question:
    return TRUE to try the call again, where met() returned TRUE to stop. Every other retry library phrases this the way
    around shouldRetry() does, and met() conflated "this succeeded" with "give up on this", which is why the shipped
    conditions read as double negatives. The method also declares its first parameter as mixed now.

    Renaming and inverting together is deliberate: because met() is gone, a condition that still implements it fails to
    declare itself at all — a fatal error naming the method — rather than quietly retrying whenever it used to stop.

  • BREAKING A single delay is now capped at 30 seconds by default, configurable with
    ExponentialBackoff::setMaxDelay(). Delays double until they reach the cap and stay there, where before they doubled
    without limit. With the default of 4 attempts the longest delay is 1 second, so the default behavior is unchanged;
    runs configured with more attempts than that now wait considerably less.

  • BREAKING Method ExponentialBackoff::getTimeoutMicroseconds() is now ::getDelayMicroseconds(), it takes a
    maximum delay as its third parameter and a CrowdStar\Backoff\Jitter case as its fourth, and its second and third
    parameters are named $initialDelay and $maxDelay, which matters only where they are passed as named arguments.
    Pass the third and fourth explicitly to opt out of the default cap and of the default randomness.

  • BREAKING A delay is now randomized over its whole length instead of being lengthened by up to 10%, and the amount
    of randomness is configurable with ExponentialBackoff::setJitter(). Waits are therefore shorter on average and no
    longer predictable: a delay has become the longest a wait may take rather than the shortest. Clients that failed
    together are what this spreads out; the measurements behind it are in the AWS article linked from enum
    CrowdStar\Backoff\Jitter. Pass Jitter::None for the previous predictability, or Jitter::Equal to keep at least
    half of every delay.

  • BREAKING Method ExponentialBackoff::run() now declares mixed ...$params and a mixed return type. Calling it
    is unchanged; a subclass that overrides ::run() has to declare both, or PHP rejects the declaration outright.

  • The randomness is no longer rounded away for delays of about a second. Where a seconds-mode delay used to be rounded
    down to whole seconds after being randomized — which for a one-second delay discarded the randomness entirely, leaving
    every client to retry in lockstep — nothing rounds any more.

Added

  • Enum CrowdStar\Backoff\Mode with cases Blocking, Swoole and Sleeper. The first two can be passed as the second
    constructor parameter instead of having the mode worked out per wait, and only Mode::Blocking changes anything: it
    opts out of non-blocking waits altogether, where Mode::Swoole and NULL both mean "wait non-blockingly where a
    coroutine is running". Mode::Sleeper is only ever reported by ::getMode(), for when a callback set with
    ::setSleeper() is doing the waiting; the constructor rejects it, since no case can stand in for a callback.
  • Methods ExponentialBackoff::setInitialDelay(), ::getInitialDelay(), ::setMaxDelay() and ::getMaxDelay(), plus
    constants ExponentialBackoff::DEFAULT_INITIAL_DELAY and ::DEFAULT_MAX_DELAY. A delay is what one wait between two
    attempts lasts, and ::setMaxDelay() caps a single one of them; the wall-clock budget for a whole run is a separate
    setting, ::setMaxElapsedTime().
  • Method ExponentialBackoff::getMode(), telling which mode a wait would happen in right now.
  • Enum CrowdStar\Backoff\Jitter with cases None, Full and Equal, along with methods
    ExponentialBackoff::setJitter() and ::getJitter() and constant ExponentialBackoff::DEFAULT_JITTER.
  • Class CrowdStar\Backoff\CallbackCondition and method ExponentialBackoff::when(), for deciding whether to retry
    with a closure instead of a condition class of your own:
    ExponentialBackoff::when(fn (mixed $result): bool => empty($result))->run($c). The closure receives what the call
    returned and what it threw; a second argument to ::when() says whether an exception the last attempt was left with
    should be thrown out, and a third takes the same Mode case the constructor does.
  • Methods ExceptionBasedCondition::setIgnoredExceptions() and ::getIgnoredExceptions(), listing types that are
    never retried. Ignored types take priority over the types being retried on, so "retry every HttpException except
    HttpBadRequestException" no longer means enumerating every sibling of the one exception to be left alone. An
    ignored exception ends the run at once and is thrown out, the same as one that was never covered.
  • Method ExponentialBackoff::setSleeper(), handing the waiting over to a callback that receives the wait in
    microseconds. It takes precedence over both other modes, which ::getMode() reports as Mode::Sleeper, and is for
    waiting on an event loop this library knows nothing about — ReactPHP, Amp, Revolt, a Fiber of your own — or for tests,
    where a callback that records and returns makes a retrying test instant and lets it assert the delays that would have
    been waited for.
  • Methods ExponentialBackoff::setMaxElapsedTime() and ::getMaxElapsedTime(), giving a whole run a wall-clock budget
    in microseconds on top of its maximum number of attempts. Once the next wait would not finish inside the budget it is
    not started at all, and the run hands back whatever the last attempt produced. Worth having because attempts say
    nothing about how long they take, and because PHP's own max_execution_time does not count time spent in usleep()
    on Unix — a runaway backoff is otherwise killed mid-wait by PHP-FPM or a proxy, with no error to log.

Fixed

  • Whether to wait in non-blocking mode is now decided per wait instead of once at construction. An instance built
    outside a coroutine — a service put together during bootstrap, say — used to block forever afterwards, even when used
    by coroutines, which is the way it is normally wired up in a Swoole application. Passing Mode::Swoole where no
    coroutine is running no longer raises the Swoole\Error it would produce either; the wait falls back to blocking.
  • One instance of ExponentialBackoff can now be used by several callers at once. The attempt counter was kept on the
    object and reset at the start of every ::run(), so a closure that called ::run() again on the same instance reset
    the count of the run it was part of, and that run then gave up after a single attempt while returning as if it had
    succeeded. The same went for concurrent Swoole coroutines sharing an instance, which is how a service tends to be
    wired up there — and which the 3.0.11 note about reusing an instance did not warn about, being true only for runs
    happening one after another.
  • Delays no longer overflow. Doubling an uncapped delay left the integer range from the 46th attempt on, where
    ExponentialBackoff::getDelayMicroseconds() threw a TypeError, and from the 65th attempt on the bit shift it used
    returned 0, silently disabling the backoff altogether. Both were reachable through ::setMaxAttempts(), and neither
    could be caught by ::run(), which handles exceptions rather than errors. Delays now stop at the maximum instead of
    growing past what an integer holds, and a non-positive iteration is treated as the first one rather than raising an
    ArithmeticError.

Removed

  • BREAKING Constants ExponentialBackoff::TYPE_MICROSECONDS and ExponentialBackoff::TYPE_SECONDS along with
    methods ::setType() and ::getType(). Use ::setInitialDelay() instead.
  • BREAKING Method ExponentialBackoff::getTimeoutSeconds(). It rounded delays down to whole seconds, which
    discarded the randomness of anything under ten seconds, and it existed only to serve the removed seconds mode. Divide
    the result of ::getDelayMicroseconds() by 1000000 where seconds are wanted.
  • BREAKING Method ExponentialBackoff::getCurrentAttempts(), deprecated since 3.x. There is no replacement: the
    attempt counter belongs to a single run and is no longer kept on the object.
  • BREAKING Methods ExceptionBasedCondition::getException() and ExceptionBasedCondition::setException(),
    deprecated since 3.0.10. Use ::getExceptions() and ::setExceptions() instead, which handle one or more types.
  • The exception previously thrown for an invalid backoff type. The backoff type is gone entirely, so there is nothing
    left to reject. _ExponentialBackoff::_construct() still rejects one second argument — Mode::Sleeper, which
    ::getMode() answers but nobody asks for — where before it rejected any integer outside the two SAPI constants.

Migration from 3.x

  1. Require PHP 8.1 or above.
  2. Rename met() to shouldRetry() in every condition of your own, and negate what it returns:
    public function met(mixed $result, ?Exception $e): bool          // 3.x
    {
        return !empty($result);                                      // TRUE meant "stop, this worked"
    }
    
    public function shouldRetry(mixed $result, ?Exception $e): bool  // 4.0
    {
        return empty($result);                                       // TRUE means "try again"
    }
    Conditions written around exceptions usually get shorter: return (empty($e) || (!($e instanceof Exception)));
    becomes return ($e instanceof Exception);. A condition left implementing met() raises a fatal error saying
    shouldRetry() is not implemented, so nothing silently starts retrying where it used to stop.
  3. Replace the type constants with an initial delay in microseconds:
    $backoff->setType(ExponentialBackoff::TYPE_SECONDS);        // 3.x
    $backoff->setInitialDelay(1_000_000);                       // 4.0
    
    $backoff->setType(ExponentialBackoff::TYPE_MICROSECONDS);   // 3.x — this was the default
    $backoff->setInitialDelay(250_000);                         // 4.0 — still the default, so drop the call
    Any other delay works as well now: setInitialDelay(100_000) waits up to about a tenth of a second before the
    first retry. Method getTimeoutSeconds() is gone, and getTimeoutMicroseconds() is now getDelayMicroseconds():
    divide its result by 1000000 where seconds are wanted, and pass Jitter::None to get the whole delay the way the old
    method gave it — with the default randomness left on, a delay of a second divides down to 0 more often than to 1:
    ExponentialBackoff::getTimeoutSeconds($i, 1);                          // 3.x
    intdiv(
        ExponentialBackoff::getDelayMicroseconds($i, 1_000_000, jitter: \CrowdStar\Backoff\Jitter::None),
        1_000_000
    );                                                                     // 4.0
  4. If you passed the second constructor parameter, pass an enum case instead of an integer:
    new ExponentialBackoff($condition, 1);                                 // 3.x, SAPI_DEFAULT
    new ExponentialBackoff($condition, \CrowdStar\Backoff\Mode::Blocking); // 4.0
    
    new ExponentialBackoff($condition, 2);                                 // 3.x, SAPI_SWOOLE
    new ExponentialBackoff($condition, \CrowdStar\Backoff\Mode::Swoole);   // 4.0
    Pass NULL, or nothing at all, to keep autodetecting Swoole coroutines — which is also all Mode::Swoole does, since
    a non-blocking wait needs a running coroutine either way.
  5. Replace the singular exception accessors on ExceptionBasedCondition with the plural ones:
    $condition->setException(Exception::class);      // 3.x
    $condition->setExceptions(Exception::class);     // 4.0, accepts one or more types
    Method getExceptions() returns a string[] where getException() returned a single class name.
  6. Drop any call to ExponentialBackoff::getCurrentAttempts(). There is no replacement; the attempt counter is
    internal.