RetryMiddleware   A
last analyzed

Complexity

Total Complexity 39

Size/Duplication

Total Lines 190
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 3
Bugs 1 Features 1
Metric Value
wmc 39
eloc 96
c 3
b 1
f 1
dl 0
loc 190
ccs 0
cts 92
cp 0
rs 9.28

12 Methods

Rating   Name   Duplication   Size   Complexity  
A countRemainingRetries() 0 8 3
A onFulfilled() 0 6 2
A onRejected() 0 14 5
A deriveTimeoutFromHeader() 0 11 4
A shouldRetryConnectException() 0 5 3
A __invoke() 0 13 2
A __construct() 0 4 1
B shouldRetryHttpResponse() 0 13 7
A factory() 0 4 1
A determineDelayTimeout() 0 27 6
A doRetry() 0 21 2
A returnResponse() 0 7 3
1
<?php
2
3
/**
4
 * Take from https://github.com/caseyamcl/guzzle_retry_middleware/edit/master/src/GuzzleRetryMiddleware.php
5
 */
6
7
declare(strict_types=1);
8
9
namespace App\SharedKernel\Infrastructure\HttpClient\Middleware;
10
11
use Closure;
12
use DateTime;
13
use GuzzleHttp\Exception\BadResponseException;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Exception\BadResponseException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
14
use GuzzleHttp\Exception\ConnectException;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Exception\ConnectException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
15
use GuzzleHttp\Promise\Create;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Promise\Create was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
use GuzzleHttp\Promise\Promise;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Promise\Promise was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
17
use GuzzleHttp\Promise\PromiseInterface;
0 ignored issues
show
Bug introduced by
The type GuzzleHttp\Promise\PromiseInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
18
use Psr\Http\Message\RequestInterface;
0 ignored issues
show
Bug introduced by
The type Psr\Http\Message\RequestInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
19
use Psr\Http\Message\ResponseInterface;
0 ignored issues
show
Bug introduced by
The type Psr\Http\Message\ResponseInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
20
21
final class RetryMiddleware
22
{
23
    public const DATE_FORMAT = 'D, d M Y H:i:s T';
24
    public const RETRY_HEADER = 'X-Retry-Counter';
25
    public const RETRY_AFTER_HEADER = 'Retry-After';
26
27
    private array $defaultOptions = [
28
        'retry_enabled' => true,
29
        // If server doesn't provide a Retry-After header, then set a default back-off delay
30
        // NOTE: This can either be a float, or it can be a callable that returns a (accepts count and response|null)
31
        'default_retry_multiplier' => 1.5,
32
        'max_retry_attempts' => 3,
33
        'max_allowable_timeout_secs' => null,
34
        // Set this to TRUE to retry only if the HTTP Retry-After header is specified
35
        'retry_only_if_retry_after_header' => false,
36
        'retry_on_status' => ['429', '503'],
37
        // Callback to trigger before delay occurs (accepts count, delay, request, response, options)
38
        'on_retry_callback' => null,
39
        'retry_on_timeout' => false,
40
        'expose_retry_header' => false,
41
        'retry_header' => self::RETRY_HEADER,
42
        'retry_after_header' => self::RETRY_AFTER_HEADER,
43
        'retry_after_date_format' => self::DATE_FORMAT
44
    ];
45
46
    /**
47
     * @var callable
48
     */
49
    private $nextHandler;
50
51
    public static function factory(array $defaultOptions = []): Closure
52
    {
53
        return static function (callable $handler) use ($defaultOptions): self {
54
            return new self($handler, $defaultOptions);
55
        };
56
    }
57
58
    public function __construct(callable $nextHandler, array $defaultOptions = [])
59
    {
60
        $this->nextHandler = $nextHandler;
61
        $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions);
62
    }
63
64
    public function __invoke(RequestInterface $request, array $options): Promise
65
    {
66
        $options = array_replace($this->defaultOptions, $options);
67
68
        if (!isset($options['retry_count'])) {
69
            $options['retry_count'] = 0;
70
        }
71
72
        $next = $this->nextHandler;
73
        return $next($request, $options)
74
            ->then(
75
                $this->onFulfilled($request, $options),
76
                $this->onRejected($request, $options)
77
            );
78
    }
79
80
    private function onFulfilled(RequestInterface $request, array $options): callable
81
    {
82
        return function (ResponseInterface $response) use ($request, $options) {
83
            return $this->shouldRetryHttpResponse($options, $response)
84
                ? $this->doRetry($request, $options, $response)
85
                : $this->returnResponse($options, $response);
86
        };
87
    }
88
89
    private function onRejected(RequestInterface $request, array $options): callable
90
    {
91
        return function ($reason) use ($request, $options): PromiseInterface {
92
            if ($reason instanceof BadResponseException) {
93
                if ($this->shouldRetryHttpResponse($options, $reason->getResponse())) {
94
                    return $this->doRetry($request, $options, $reason->getResponse());
95
                }
96
            } elseif ($reason instanceof ConnectException) {
97
                if ($this->shouldRetryConnectException($options)) {
98
                    return $this->doRetry($request, $options);
99
                }
100
            }
101
102
            return Create::rejectionFor($reason);
103
        };
104
    }
105
106
    private function shouldRetryConnectException(array $options): bool
107
    {
108
        return $options['retry_enabled']
109
            && ($options['retry_on_timeout'] ?? false)
110
            && $this->countRemainingRetries($options) > 0;
111
    }
112
113
    private function shouldRetryHttpResponse(array $options, ?ResponseInterface $response = null): bool
114
    {
115
        $statuses = array_map('\intval', (array) $options['retry_on_status']);
116
        $hasRetryAfterHeader = $response && $response->hasHeader('Retry-After');
117
118
        switch (true) {
119
            case $options['retry_enabled'] === false:
120
            case $this->countRemainingRetries($options) === 0: // No Retry-After header, and it is required?  Give up
121
            case (!$hasRetryAfterHeader && $options['retry_only_if_retry_after_header']):
122
                return false;
123
            default:
124
                $statusCode = $response ? $response->getStatusCode() : 0;
125
                return in_array($statusCode, $statuses, true);
126
        }
127
    }
128
129
    private function countRemainingRetries(array $options): int
130
    {
131
        $retryCount = isset($options['retry_count']) ? (int) $options['retry_count'] : 0;
132
        $numAllowed = isset($options['max_retry_attempts'])
133
            ? (int) $options['max_retry_attempts']
134
            : $this->defaultOptions['max_retry_attempts'];
135
136
        return (int) max([$numAllowed - $retryCount, 0]);
137
    }
138
139
    private function doRetry(RequestInterface $request, array $options, ResponseInterface $response = null): Promise
140
    {
141
        ++$options['retry_count'];
142
        $delayTimeout = $this->determineDelayTimeout($options, $response);
143
144
        if ($options['on_retry_callback']) {
145
            call_user_func_array(
146
                $options['on_retry_callback'],
147
                [
148
                    (int) $options['retry_count'],
149
                    $delayTimeout,
150
                    &$request,
151
                    &$options,
152
                    $response
153
                ]
154
            );
155
        }
156
157
        usleep((int) ($delayTimeout * 1e6));
158
159
        return $this($request, $options);
160
    }
161
162
    private function returnResponse(array $options, ResponseInterface $response): ResponseInterface
163
    {
164
        if ($options['expose_retry_header'] === false || $options['retry_count'] === 0) {
165
            return $response;
166
        }
167
168
        return $response->withHeader($options['retry_header'], $options['retry_count']);
169
    }
170
171
    private function determineDelayTimeout(array $options, ?ResponseInterface $response = null): float
172
    {
173
        $defaultDelayTimeout = (float) $options['default_retry_multiplier'] * $options['retry_count'];
174
        if (is_callable($options['default_retry_multiplier'])) {
175
            $defaultDelayTimeout = (float) call_user_func(
176
                $options['default_retry_multiplier'],
177
                $options['retry_count'],
178
                $response
179
            );
180
        }
181
182
        // Retry-After can be a delay in seconds or a date
183
        // (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
184
        $timeout = abs($defaultDelayTimeout);
185
        if ($response && $response->hasHeader($options['retry_after_header'])) {
186
            $retryAfterHeader = $response->getHeader($options['retry_after_header']);
187
            $timeout = $this->deriveTimeoutFromHeader(
188
                $retryAfterHeader[0],
189
                $options['retry_after_date_format']
190
            ) ?? $defaultDelayTimeout;
191
        }
192
193
        if (!is_null($options['max_allowable_timeout_secs']) && abs($options['max_allowable_timeout_secs']) > 0) {
194
            return min(abs($timeout), (float) abs($options['max_allowable_timeout_secs']));
195
        }
196
197
        return abs($timeout);
198
    }
199
200
    private function deriveTimeoutFromHeader(string $headerValue, string $dateFormat = self::DATE_FORMAT): ?float
201
    {
202
        if (is_numeric($headerValue)) {
203
            return (float) trim($headerValue);
204
        }
205
206
        if ($date = DateTime::createFromFormat($dateFormat ?: self::DATE_FORMAT, trim($headerValue))) {
207
            return (float) $date->format('U') - time();
208
        }
209
210
        return null;
211
    }
212
}
213