├── LICENSE.md ├── README.md ├── composer.json └── src ├── Backoff.php ├── Strategies ├── AbstractStrategy.php ├── ConstantStrategy.php ├── ExponentialStrategy.php ├── LinearStrategy.php └── PolynomialStrategy.php └── helpers.php /LICENSE.md: -------------------------------------------------------------------------------- 1 | # The MIT License (MIT) 2 | 3 | Copyright (c) Signature Tech Studio, Inc 4 | 5 | > Permission is hereby granted, free of charge, to any person obtaining a copy 6 | > of this software and associated documentation files (the "Software"), to deal 7 | > in the Software without restriction, including without limitation the rights 8 | > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | > copies of the Software, and to permit persons to whom the Software is 10 | > furnished to do so, subject to the following conditions: 11 | > 12 | > The above copyright notice and this permission notice shall be included in 13 | > all copies or substantial portions of the Software. 14 | > 15 | > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | > THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHP Backoff 2 | 3 | [![Latest Version on Packagist](https://img.shields.io/packagist/v/stechstudio/backoff.svg?style=flat-square)](https://packagist.org/packages/stechstudio/backoff) 4 | ![Tests](https://img.shields.io/github/actions/workflow/status/stechstudio/backoff/run-tests.yml?style=flat-square) 5 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat-square)](LICENSE.md) 6 | [![Total Downloads](https://img.shields.io/packagist/dt/stechstudio/backoff.svg?style=flat-square)](https://packagist.org/packages/stechstudio/backoff) 7 | 8 | Easily wrap your code with retry functionality. This library provides: 9 | 10 | 1. 4 backoff strategies (plus the ability to use your own) 11 | 2. Optional jitter / randomness to spread out retries and minimize collisions 12 | 3. Wait time cap 13 | 4. Callbacks for custom retry logic or error handling 14 | 15 | ## Installation 16 | 17 | ``` 18 | composer require stechstudio/backoff 19 | ``` 20 | 21 | ## Defaults 22 | 23 | This library provides sane defaults so you can hopefully just jump in for most of your use cases. 24 | 25 | By default the backoff is quadratic with a 100ms base time (`attempt^2 * 100`), a max of 5 retries, and no jitter. 26 | 27 | ## Quickstart 28 | 29 | The simplest way to use Backoff is with the global `backoff` helper function: 30 | 31 | ```php 32 | $result = backoff(function() { 33 | return doSomeWorkThatMightFail(); 34 | }); 35 | ``` 36 | 37 | If successful `$result` will contain the result of the closure. If max attempts are exceeded the inner exception is re-thrown. 38 | 39 | You can of course provide other options via the helper method if needed. 40 | 41 | Method parameters are `$callback`, `$maxAttempts`, `$strategy`, `$waitCap`, `$useJitter`. 42 | 43 | ## Backoff class usage 44 | 45 | The Backoff class constructor parameters are `$maxAttempts`, `$strategy`, `$waitCap`, `$useJitter`. 46 | 47 | ```php 48 | $backoff = new Backoff(10, 'exponential', 10000, true); 49 | $result = $backoff->run(function() { 50 | return doSomeWorkThatMightFail(); 51 | }); 52 | ``` 53 | 54 | Or if you are injecting the Backoff class with a dependency container, you can set it up with setters after the fact. Note that setters are chainable. 55 | 56 | ```php 57 | // Assuming a fresh instance of $backoff was handed to you 58 | $result = $backoff 59 | ->setStrategy('constant') 60 | ->setMaxAttempts(10) 61 | ->enableJitter() 62 | ->run(function() { 63 | return doSomeWorkThatMightFail(); 64 | }); 65 | ``` 66 | 67 | ## Changing defaults 68 | 69 | If you find you want different defaults, you can modify them via static class properties: 70 | 71 | ```php 72 | Backoff::$defaultMaxAttempts = 10; 73 | Backoff::$defaultStrategy = 'exponential'; 74 | Backoff::$defaultJitterEnabled = true; 75 | ``` 76 | 77 | You might want to do this somewhere in your application bootstrap for example. These defaults will be used anytime you create an instance of the Backoff class or use the `backoff()` helper function. 78 | 79 | ## Strategies 80 | 81 | There are four built-in strategies available: constant, linear, polynomial, and exponential. 82 | 83 | The default base time for all strategies is 100 milliseconds. 84 | 85 | ### Constant 86 | 87 | ```php 88 | $strategy = new ConstantStrategy(500); 89 | ``` 90 | 91 | This strategy will sleep for 500 milliseconds on each retry loop. 92 | 93 | ### Linear 94 | 95 | ```php 96 | $strategy = new LinearStrategy(200); 97 | ``` 98 | 99 | This strategy will sleep for `attempt * baseTime`, providing linear backoff starting at 200 milliseconds. 100 | 101 | ### Polynomial 102 | 103 | ```php 104 | $strategy = new PolynomialStrategy(100, 3); 105 | ``` 106 | 107 | This strategy will sleep for `(attempt^degree) * baseTime`, so in this case `(attempt^3) * 100`. 108 | 109 | The default degree if none provided is 2, effectively quadratic time. 110 | 111 | ### Exponential 112 | 113 | ```php 114 | $strategy = new ExponentialStrategy(100); 115 | ``` 116 | 117 | This strategy will sleep for `(2^attempt) * baseTime`. 118 | 119 | ## Specifying strategy 120 | 121 | In our earlier code examples we specified the strategy as a string: 122 | 123 | ```php 124 | backoff(function() { 125 | ... 126 | }, 10, 'constant'); 127 | 128 | // OR 129 | 130 | $backoff = new Backoff(10, 'constant'); 131 | ``` 132 | 133 | This would use the `ConstantStrategy` with defaults, effectively giving you a 100 millisecond sleep time. 134 | 135 | You can create the strategy instance yourself in order to modify these defaults: 136 | 137 | ```php 138 | backoff(function() { 139 | ... 140 | }, 10, new LinearStrategy(500)); 141 | 142 | // OR 143 | 144 | $backoff = new Backoff(10, new LinearStrategy(500)); 145 | ``` 146 | 147 | You can also pass in an integer as the strategy, will translates to a ConstantStrategy with the integer as the base time in milliseconds: 148 | 149 | ```php 150 | backoff(function() { 151 | ... 152 | }, 10, 1000); 153 | 154 | // OR 155 | 156 | $backoff = new Backoff(10, 1000); 157 | ``` 158 | 159 | Finally, you can pass in a closure as the strategy if you wish. This closure should receive an integer `attempt` and return a sleep time in milliseconds. 160 | 161 | ```php 162 | backoff(function() { 163 | ... 164 | }, 10, function($attempt) { 165 | return (100 * $attempt) + 5000; 166 | }); 167 | 168 | // OR 169 | 170 | $backoff = new Backoff(10); 171 | $backoff->setStrategy(function($attempt) { 172 | return (100 * $attempt) + 5000; 173 | }); 174 | ``` 175 | 176 | ## Wait cap 177 | 178 | You may want to use a fast growing backoff time (like exponential) but then also set a max wait time so that it levels out after a while. 179 | 180 | This cap can be provided as the fourth argument to the `backoff` helper function, or using the `setWaitCap()` method on the Backoff class. 181 | 182 | ## Jitter 183 | 184 | If you have a lot of clients starting a job at the same time and encountering failures, any of the above backoff strategies could mean the workers continue to collide at each retry. 185 | 186 | The solution for this is to add randomness. See here for a good explanation: 187 | 188 | https://www.awsarchitectureblog.com/2015/03/backoff.html 189 | 190 | You can enable jitter by passing `true` in as the fifth argument to the `backoff` helper function, or by using the `enableJitter()` method on the Backoff class. 191 | 192 | We use the "FullJitter" approach outlined in the above article, where a random number between 0 and the sleep time provided by your selected strategy is used. 193 | 194 | ## Custom retry decider 195 | 196 | By default Backoff will retry if an exception is encountered, and if it has not yet hit max retries. 197 | 198 | You may provide your own retry decider for more advanced use cases. Perhaps you want to retry based on time rather than number of retries, or perhaps there are scenarios where you would want retry even when an exception was not encountered. 199 | 200 | Provide the decider as a callback, or an instance of a class with an `__invoke` method. Backoff will hand it four parameters: the current attempt, max attempts, the last result received, and the exception if one was encountered. Your decider needs to return true or false. 201 | 202 | ```php 203 | $backoff->setDecider(function($attempt, $maxAttempts, $result, $exception = null) { 204 | return someCustomLogic(); 205 | }); 206 | ``` 207 | 208 | ## Error handler callback 209 | 210 | You can provide a custom error handler to be notified anytime an exception occurs, even if we have yet to reach max attempts. This is a useful place to do logging for example. 211 | 212 | ```php 213 | $backoff->setErrorHandler(function($exception, $attempt, $maxAttempts) { 214 | Log::error("On run $attempt we hit a problem: " . $exception->getMessage()); 215 | }); 216 | ``` 217 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "stechstudio/backoff", 3 | "description": "PHP library providing retry functionality with multiple backoff strategies and jitter support", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Joseph Szobody", 8 | "email": "joseph@stechstudio.com" 9 | } 10 | ], 11 | "require": {}, 12 | "require-dev": { 13 | "phpunit/phpunit": "^10.0" 14 | }, 15 | "autoload": { 16 | "psr-4":{ 17 | "STS\\Backoff\\": "src" 18 | }, 19 | "files": [ 20 | "src/helpers.php" 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Backoff.php: -------------------------------------------------------------------------------- 1 | ConstantStrategy::class, 44 | 'linear' => LinearStrategy::class, 45 | 'polynomial' => PolynomialStrategy::class, 46 | 'exponential' => ExponentialStrategy::class 47 | ]; 48 | 49 | /** 50 | * @var int 51 | */ 52 | protected $maxAttempts; 53 | 54 | /** 55 | * The max wait time you want to allow, regardless of what the strategy says 56 | * 57 | * @var int|null In milliseconds 58 | */ 59 | protected $waitCap; 60 | 61 | /** 62 | * @var bool 63 | */ 64 | protected $useJitter = false; 65 | 66 | /** 67 | * @var array 68 | */ 69 | protected $exceptions = []; 70 | 71 | /** 72 | * This will decide whether to retry or not. 73 | * @var callable 74 | */ 75 | protected $decider; 76 | 77 | /** 78 | * This receive any exceptions we encounter. 79 | * @var callable 80 | */ 81 | protected $errorHandler; 82 | 83 | /** 84 | * @param int $maxAttempts 85 | * @param mixed $strategy 86 | * @param int $waitCap 87 | * @param bool $useJitter 88 | * @param callable $decider 89 | */ 90 | public function __construct( 91 | $maxAttempts = null, 92 | $strategy = null, 93 | $waitCap = null, 94 | $useJitter = null, 95 | $decider = null 96 | ) { 97 | $this->setMaxAttempts($maxAttempts ?: self::$defaultMaxAttempts); 98 | $this->setStrategy($strategy ?: self::$defaultStrategy); 99 | $this->setJitter($useJitter ?: self::$defaultJitterEnabled); 100 | $this->setWaitCap($waitCap); 101 | $this->setDecider($decider ?: $this->getDefaultDecider()); 102 | } 103 | 104 | /** 105 | * @param integer $attempts 106 | */ 107 | public function setMaxAttempts($attempts) 108 | { 109 | $this->maxAttempts = $attempts; 110 | 111 | return $this; 112 | } 113 | 114 | /** 115 | * @return integer 116 | */ 117 | public function getMaxAttempts() 118 | { 119 | return $this->maxAttempts; 120 | } 121 | 122 | /** 123 | * @param int|null $cap 124 | * 125 | * @return $this 126 | */ 127 | public function setWaitCap($cap) 128 | { 129 | $this->waitCap = $cap; 130 | 131 | return $this; 132 | } 133 | 134 | /** 135 | * @return int|null 136 | */ 137 | public function getWaitCap() 138 | { 139 | return $this->waitCap; 140 | } 141 | 142 | /** 143 | * @param bool $useJitter 144 | * 145 | * @return $this 146 | */ 147 | public function setJitter($useJitter) 148 | { 149 | $this->useJitter = $useJitter; 150 | 151 | return $this; 152 | } 153 | 154 | /** 155 | * 156 | */ 157 | public function enableJitter() 158 | { 159 | $this->setJitter(true); 160 | 161 | return $this; 162 | } 163 | 164 | /** 165 | * 166 | */ 167 | public function disableJitter() 168 | { 169 | $this->setJitter(false); 170 | 171 | return $this; 172 | } 173 | 174 | public function jitterEnabled() 175 | { 176 | return $this->useJitter; 177 | } 178 | 179 | /** 180 | * @return callable 181 | */ 182 | public function getStrategy() 183 | { 184 | return $this->strategy; 185 | } 186 | 187 | /** 188 | * @param mixed $strategy 189 | * 190 | * @return $this 191 | */ 192 | public function setStrategy($strategy) 193 | { 194 | $this->strategy = $this->buildStrategy($strategy); 195 | 196 | return $this; 197 | } 198 | 199 | /** 200 | * Builds a callable strategy. 201 | * 202 | * @param mixed $strategy Can be a string that matches a key in $strategies, an instance of AbstractStrategy 203 | * (or any other instance that has an __invoke method), a callback function, or 204 | * an integer (which we interpret to mean you want a ConstantStrategy) 205 | * 206 | * @return callable 207 | */ 208 | protected function buildStrategy($strategy) 209 | { 210 | if (is_string($strategy) && array_key_exists($strategy, $this->strategies)) { 211 | return new $this->strategies[$strategy]; 212 | } 213 | 214 | if (is_callable($strategy)) { 215 | return $strategy; 216 | } 217 | 218 | if (is_int($strategy)) { 219 | return new ConstantStrategy($strategy); 220 | } 221 | 222 | throw new InvalidArgumentException("Invalid strategy: " . $strategy); 223 | } 224 | 225 | /** 226 | * @template T 227 | * @param callable():T $callback 228 | * 229 | * @phpstan-return (T is void ? null : T) 230 | * @return mixed 231 | * @throws Exception 232 | */ 233 | public function run($callback) 234 | { 235 | $attempt = 0; 236 | $try = true; 237 | 238 | while ($try) { 239 | 240 | $result = null; 241 | $exception = null; 242 | 243 | $this->wait($attempt); 244 | try { 245 | $result = call_user_func($callback); 246 | } catch (\Throwable $e) { 247 | if ($e instanceof \Error) { 248 | $e = new Exception($e->getMessage(), $e->getCode(), $e); 249 | } 250 | $this->exceptions[] = $e; 251 | $exception = $e; 252 | } catch (Exception $e) { 253 | $this->exceptions[] = $e; 254 | $exception = $e; 255 | } 256 | $try = call_user_func($this->decider, ++$attempt, $this->getMaxAttempts(), $result, $exception); 257 | 258 | if($try && isset($this->errorHandler)) { 259 | call_user_func($this->errorHandler, $exception, $attempt, $this->getMaxAttempts()); 260 | } 261 | } 262 | 263 | return $result; 264 | } 265 | 266 | /** 267 | * Sets the decider callback 268 | * @param callable $callback 269 | * @return $this 270 | */ 271 | public function setDecider($callback) 272 | { 273 | $this->decider = $callback; 274 | return $this; 275 | } 276 | 277 | /** 278 | * Sets the error handler callback 279 | * @param callable $callback 280 | * @return $this 281 | */ 282 | public function setErrorHandler($callback) 283 | { 284 | $this->errorHandler = $callback; 285 | return $this; 286 | } 287 | 288 | /** 289 | * Gets a default decider that simply check exceptions and maxattempts 290 | * @return \Closure 291 | */ 292 | protected function getDefaultDecider() 293 | { 294 | return function ($retry, $maxAttempts, $result = null, $exception = null) { 295 | if($retry >= $maxAttempts && ! is_null($exception)) { 296 | throw $exception; 297 | } 298 | 299 | return $retry < $maxAttempts && !is_null($exception); 300 | }; 301 | } 302 | 303 | /** 304 | * @param int $attempt 305 | */ 306 | public function wait($attempt) 307 | { 308 | if ($attempt == 0) { 309 | return; 310 | } 311 | 312 | usleep(intval($this->getWaitTime($attempt) * 1000)); 313 | } 314 | 315 | /** 316 | * @param int $attempt 317 | * 318 | * @return int 319 | */ 320 | public function getWaitTime($attempt) 321 | { 322 | $waitTime = call_user_func($this->getStrategy(), $attempt); 323 | 324 | return $this->jitter($this->cap($waitTime)); 325 | } 326 | 327 | /** 328 | * @param int $waitTime 329 | * 330 | * @return mixed 331 | */ 332 | protected function cap($waitTime) 333 | { 334 | $waitTime = $waitTime < 0 ? PHP_INT_MAX : $waitTime; 335 | return is_int($this->getWaitCap()) 336 | ? min($this->getWaitCap(), $waitTime) 337 | : $waitTime; 338 | } 339 | 340 | /** 341 | * @param int $waitTime 342 | * 343 | * @return int 344 | */ 345 | protected function jitter($waitTime) 346 | { 347 | return $this->jitterEnabled() 348 | ? mt_rand(0, $waitTime) 349 | : $waitTime; 350 | } 351 | } 352 | -------------------------------------------------------------------------------- /src/Strategies/AbstractStrategy.php: -------------------------------------------------------------------------------- 1 | base = $base; 30 | } 31 | } 32 | 33 | /** 34 | * @param int $attempt 35 | * 36 | * @return int Time to wait in ms 37 | */ 38 | abstract public function getWaitTime($attempt); 39 | 40 | /** 41 | * @param int $attempt 42 | * 43 | * @return int 44 | */ 45 | public function __invoke($attempt) 46 | { 47 | return $this->getWaitTime($attempt); 48 | } 49 | 50 | /** 51 | * @return int 52 | */ 53 | public function getBase() 54 | { 55 | return $this->base; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Strategies/ConstantStrategy.php: -------------------------------------------------------------------------------- 1 | base; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Strategies/ExponentialStrategy.php: -------------------------------------------------------------------------------- 1 | base * (pow(2, $attempt - 1))); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Strategies/LinearStrategy.php: -------------------------------------------------------------------------------- 1 | base; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Strategies/PolynomialStrategy.php: -------------------------------------------------------------------------------- 1 | degree = $degree; 25 | } 26 | 27 | parent::__construct($base); 28 | } 29 | 30 | /** 31 | * @param int $attempt 32 | * 33 | * @return int 34 | */ 35 | public function getWaitTime($attempt) 36 | { 37 | return (int) pow($attempt, $this->degree) * $this->base; 38 | } 39 | 40 | /** 41 | * @return int|null 42 | */ 43 | public function getDegree() 44 | { 45 | return $this->degree; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/helpers.php: -------------------------------------------------------------------------------- 1 | run($callback); 6 | } 7 | } 8 | --------------------------------------------------------------------------------