From ace731437ef98a9396dd07a5ad08e7eb2ee97261 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Tue, 6 Oct 2020 14:41:27 +0200 Subject: [PATCH] Add jitter to RetryBackof --- .../DependencyInjection/Configuration.php | 5 +++-- .../FrameworkExtension.php | 3 ++- .../HttpClient/Retry/ExponentialBackOff.php | 15 ++++++++++++-- .../Tests/Retry/ExponentialBackOffTest.php | 20 ++++++++++++++++++- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 89a3b379ede9b..75a4221c10d59 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -1641,8 +1641,8 @@ private function addHttpClientRetrySection() ->addDefaultsIfNotSet() ->beforeNormalization() ->always(function ($v) { - if (isset($v['backoff_service']) && (isset($v['delay']) || isset($v['multiplier']) || isset($v['max_delay']))) { - throw new \InvalidArgumentException('The "backoff_service" option cannot be used along with the "delay", "multiplier" or "max_delay" options.'); + if (isset($v['backoff_service']) && (isset($v['delay']) || isset($v['multiplier']) || isset($v['max_delay']) || isset($v['jitter']))) { + throw new \InvalidArgumentException('The "backoff_service" option cannot be used along with the "delay", "multiplier", "max_delay" or "jitter" options.'); } if (isset($v['decider_service']) && (isset($v['http_codes']))) { throw new \InvalidArgumentException('The "decider_service" option cannot be used along with the "http_codes" options.'); @@ -1670,6 +1670,7 @@ private function addHttpClientRetrySection() ->integerNode('delay')->defaultValue(1000)->min(0)->info('Time in ms to delay (or the initial value when multiplier is used)')->end() ->floatNode('multiplier')->defaultValue(2)->min(1)->info('If greater than 1, delay will grow exponentially for each retry: (delay * (multiple ^ retries))')->end() ->integerNode('max_delay')->defaultValue(0)->min(0)->info('Max time in ms that a retry should ever be delayed (0 = infinite)')->end() + ->floatNode('jitter')->defaultValue(0.1)->min(0)->max(1)->info('Randomness in percent (between 0 and 1)) to apply to the delay')->end() ->end() ; } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index a862351bf6540..84712e554ac37 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -2075,7 +2075,8 @@ private function registerHttpClientRetry(array $retryOptions, string $name, Cont $retryDefinition ->replaceArgument(0, $retryOptions['delay']) ->replaceArgument(1, $retryOptions['multiplier']) - ->replaceArgument(2, $retryOptions['max_delay']); + ->replaceArgument(2, $retryOptions['max_delay']) + ->replaceArgument(3, $retryOptions['jitter']); $container->setDefinition($retryServiceId, $retryDefinition); $backoffReference = new Reference($retryServiceId); diff --git a/src/Symfony/Component/HttpClient/Retry/ExponentialBackOff.php b/src/Symfony/Component/HttpClient/Retry/ExponentialBackOff.php index 9f970f912bccb..205fb9c82a688 100644 --- a/src/Symfony/Component/HttpClient/Retry/ExponentialBackOff.php +++ b/src/Symfony/Component/HttpClient/Retry/ExponentialBackOff.php @@ -17,7 +17,7 @@ /** * A retry backOff with a constant or exponential retry delay. * - * For example, if $delayMilliseconds=10000 & $multiplier=1 (default), + * For example, if $delayMilliseconds=10000 & $multiplier=1, * each retry will wait exactly 10 seconds. * * But if $delayMilliseconds=10000 & $multiplier=2: @@ -33,13 +33,15 @@ final class ExponentialBackOff implements RetryBackOffInterface private $delayMilliseconds; private $multiplier; private $maxDelayMilliseconds; + private $jitter; /** * @param int $delayMilliseconds Amount of time to delay (or the initial value when multiplier is used) * @param float $multiplier Multiplier to apply to the delay each time a retry occurs * @param int $maxDelayMilliseconds Maximum delay to allow (0 means no maximum) + * @param float $jitter Probability of randomness int delay (0 = none, 1 = 100% random) */ - public function __construct(int $delayMilliseconds = 1000, float $multiplier = 2.0, int $maxDelayMilliseconds = 0) + public function __construct(int $delayMilliseconds = 1000, float $multiplier = 2.0, int $maxDelayMilliseconds = 0, float $jitter = 0.1) { if ($delayMilliseconds < 0) { throw new InvalidArgumentException(sprintf('Delay must be greater than or equal to zero: "%s" given.', $delayMilliseconds)); @@ -55,11 +57,20 @@ public function __construct(int $delayMilliseconds = 1000, float $multiplier = 2 throw new InvalidArgumentException(sprintf('Max delay must be greater than or equal to zero: "%s" given.', $maxDelayMilliseconds)); } $this->maxDelayMilliseconds = $maxDelayMilliseconds; + + if ($jitter < 0 || $jitter > 1) { + throw new InvalidArgumentException(sprintf('Jitter must be between 0 and 1: "%s" given.', $jitter)); + } + $this->jitter = $jitter; } public function getDelay(int $retryCount, string $requestMethod, string $requestUrl, array $requestOptions, int $responseStatusCode, array $responseHeaders, ?string $responseContent, ?TransportExceptionInterface $exception): int { $delay = $this->delayMilliseconds * $this->multiplier ** $retryCount; + if ($this->jitter > 0) { + $randomness = $delay * $this->jitter; + $delay = $delay + random_int(-$randomness, +$randomness); + } if ($delay > $this->maxDelayMilliseconds && 0 !== $this->maxDelayMilliseconds) { return $this->maxDelayMilliseconds; diff --git a/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php b/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php index 91ba50e59da0c..70351df6cbcb9 100644 --- a/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php +++ b/src/Symfony/Component/HttpClient/Tests/Retry/ExponentialBackOffTest.php @@ -21,7 +21,7 @@ class ExponentialBackOffTest extends TestCase */ public function testGetDelay(int $delay, int $multiplier, int $maxDelay, int $previousRetries, int $expectedDelay) { - $backOff = new ExponentialBackOff($delay, $multiplier, $maxDelay); + $backOff = new ExponentialBackOff($delay, $multiplier, $maxDelay, 0); self::assertSame($expectedDelay, $backOff->getDelay($previousRetries, 'GET', 'http://example.com/', [], 200, [], null, null)); } @@ -50,4 +50,22 @@ public function provideDelay(): iterable yield [0, 2, 10000, 0, 0]; yield [0, 2, 10000, 1, 0]; } + + public function testJitter() + { + $backOff = new ExponentialBackOff(1000, 1, 0, 1); + $belowHalf = 0; + $aboveHalf = 0; + for ($i = 0; $i < 20; ++$i) { + $delay = $backOff->getDelay(0, 'GET', 'http://example.com/', [], 200, [], null, null); + if ($delay < 500) { + ++$belowHalf; + } elseif ($delay > 1500) { + ++$aboveHalf; + } + } + + $this->assertGreaterThanOrEqual(1, $belowHalf); + $this->assertGreaterThanOrEqual(1, $aboveHalf); + } }