Completed
Push — master ( eeba1a...91793e )
by Casey
02:24
created

GuzzleRetryMiddleware::deriveTimeoutFromHeader()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 12
ccs 6
cts 6
cp 1
rs 9.8666
c 0
b 0
f 0
cc 3
nc 3
nop 1
crap 3
1
<?php
2
/**
3
 * Guzzle Retry Middleware Library
4
 *
5
 * @license http://opensource.org/licenses/MIT
6
 * @link https://github.com/caseyamcl/guzzle_retry_middleware
7
 * @version 2.0
8
 * @package caseyamcl/guzzle_retry_middleware
9
 * @author Casey McLaughlin <[email protected]>
10
 *
11
 * For the full copyright and license information, please view the LICENSE
12
 * file that was distributed with this source code.
13
 *
14
 * ------------------------------------------------------------------
15
 */
16
17
declare(strict_types=1);
18
19
namespace GuzzleRetry;
20
21
use Closure;
22
use DateTime;
23
use GuzzleHttp\Exception\BadResponseException;
24
use GuzzleHttp\Exception\ConnectException;
25
use GuzzleHttp\Promise\Promise;
26
use Psr\Http\Message\RequestInterface;
27
use Psr\Http\Message\ResponseInterface;
28
use function call_user_func;
29
use function call_user_func_array;
30
use function GuzzleHttp\Promise\rejection_for;
31
use function in_array;
32
use function is_callable;
33
34
/**
35
 * Retry After Middleware
36
 *
37
 * Guzzle 6 middleware that retries requests when encountering responses
38
 * with certain conditions (429 or 503).  This middleware also respects
39
 * the `RetryAfter` header
40
 *
41
 * @author Casey McLaughlin <[email protected]>
42
 */
43
class GuzzleRetryMiddleware
44
{
45
    // HTTP date format
46
    const DATE_FORMAT = 'D, d M Y H:i:s T';
47
48
    // Default retry header (off by default; configurable)
49
    const RETRY_HEADER = 'X-Retry-Counter';
50
51
    /**
52
     * @var array
53
     */
54
    private $defaultOptions = [
55
56
        // Retry enabled.  Toggle retry on or off per request
57
        'retry_enabled'                    => true,
58
59
        // If server doesn't provide a Retry-After header, then set a default back-off delay
60
        // NOTE: This can either be a float, or it can be a callable that returns a (accepts count and response|null)
61
        'default_retry_multiplier'         => 1.5,
62
63
        // Set a maximum number of attempts per request
64
        'max_retry_attempts'               => 10,
65
66
        // Set this to TRUE to retry only if the HTTP Retry-After header is specified
67
        'retry_only_if_retry_after_header' => false,
68
69
        // Only retry when status is equal to these response codes
70
        'retry_on_status'                  => ['429', '503'],
71
72
        // Callback to trigger when delay occurs (accepts count, delay, request, response, options)
73
        'on_retry_callback'                => null,
74
75
        // Retry on connect timeout?
76
        'retry_on_timeout'                 => false,
77
78
        // Add the number of retries to an X-Header
79
        'expose_retry_header'              => false,
80
81
        // The header key
82
        'retry_header'                     => self::RETRY_HEADER
83
    ];
84
85
    /**
86
     * @var callable
87
     */
88
    private $nextHandler;
89
90
    /**
91
     * Provides a closure that can be pushed onto the handler stack
92
     *
93
     * Example:
94
     * <code>$handlerStack->push(GuzzleRetryMiddleware::factory());</code>
95
     *
96
     * @param array $defaultOptions
97
     * @return Closure
98
     */
99 69
    public static function factory(array $defaultOptions = []): Closure
100
    {
101
        return function (callable $handler) use ($defaultOptions) {
102 69
            return new static($handler, $defaultOptions);
103 69
        };
104
    }
105
106
    /**
107
     * GuzzleRetryMiddleware constructor.
108
     *
109
     * @param callable $nextHandler
110
     * @param array $defaultOptions
111
     */
112 72
    public function __construct(callable $nextHandler, array $defaultOptions = [])
113
    {
114 72
        $this->nextHandler = $nextHandler;
115 72
        $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions);
116 72
    }
117
118
    /**
119
     * @param RequestInterface $request
120
     * @param array $options
121
     * @return Promise
122
     */
123 69
    public function __invoke(RequestInterface $request, array $options): Promise
124
    {
125
        // Combine options with defaults specified by this middleware
126 69
        $options = array_replace($this->defaultOptions, $options);
127
128
        // Set the retry count if not already set
129 69
        if (! isset($options['retry_count'])) {
130 69
            $options['retry_count'] = 0;
131
        }
132
133
        /** @var callable $next */
134 69
        $next = $this->nextHandler;
135 69
        return $next($request, $options)
136 69
            ->then(
137 69
                $this->onFulfilled($request, $options),
138 69
                $this->onRejected($request, $options)
139
            );
140
    }
141
142
    /**
143
     * No exceptions were thrown during processing
144
     *
145
     * Depending on where this middleware is in the stack, the response could still
146
     * be unsuccessful (e.g. 429 or 503), so check to see if it should be retried
147
     *
148
     * @param RequestInterface $request
149
     * @param array $options
150
     * @return callable
151
     */
152 69
    protected function onFulfilled(RequestInterface $request, array $options): callable
153
    {
154
        return function (ResponseInterface $response) use ($request, $options) {
155 57
            return $this->shouldRetryHttpResponse($options, $response)
156 42
                ? $this->doRetry($request, $options, $response)
157 57
                : $this->returnResponse($options, $response);
158 69
        };
159
    }
160
161
    /**
162
     * An exception or error was thrown during processing
163
     *
164
     * If the reason is a BadResponseException exception, check to see if
165
     * the request can be retried.  Otherwise, pass it on.
166
     *
167
     * @param RequestInterface $request
168
     * @param array $options
169
     * @return callable
170
     */
171 69
    protected function onRejected(RequestInterface $request, array $options): callable
172
    {
173
        return function ($reason) use ($request, $options) {
174
            // If was bad response exception, test if we retry based on the response headers
175 18
            if ($reason instanceof BadResponseException) {
176 3
                if ($this->shouldRetryHttpResponse($options, $reason->getResponse())) {
0 ignored issues
show
Bug introduced by
It seems like $reason->getResponse() can be null; however, shouldRetryHttpResponse() does not accept null, maybe add an additional type check?

Unless you are absolutely sure that the expression can never be null because of other conditions, we strongly recommend to add an additional type check to your code:

/** @return stdClass|null */
function mayReturnNull() { }

function doesNotAcceptNull(stdClass $x) { }

// With potential error.
function withoutCheck() {
    $x = mayReturnNull();
    doesNotAcceptNull($x); // Potential error here.
}

// Safe - Alternative 1
function withCheck1() {
    $x = mayReturnNull();
    if ( ! $x instanceof stdClass) {
        throw new \LogicException('$x must be defined.');
    }
    doesNotAcceptNull($x);
}

// Safe - Alternative 2
function withCheck2() {
    $x = mayReturnNull();
    if ($x instanceof stdClass) {
        doesNotAcceptNull($x);
    }
}
Loading history...
177 3
                    return $this->doRetry($request, $options, $reason->getResponse());
178
                }
179
            // If this was a connection exception, test to see if we should retry based on connect timeout rules
180 15
            } elseif ($reason instanceof ConnectException) {
181
                // If was another type of exception, test if we should retry based on timeout rules
182 12
                if ($this->shouldRetryConnectException($reason, $options)) {
183 6
                    return $this->doRetry($request, $options);
184
                }
185
            }
186
187
            // If made it here, then we have decided not to retry the request
188 12
            return rejection_for($reason);
189 69
        };
190
    }
191
192
    /**
193
     * @param ConnectException $e
194
     * @param array $options
195
     * @return bool
196
     */
197 12
    protected function shouldRetryConnectException(ConnectException $e, array $options): bool
198
    {
199 8
        switch (true) {
200 12
            case $options['retry_enabled'] === false:
201 12
            case $this->countRemainingRetries($options) === 0:
202 3
                return false;
203
204
            // Test if this was a connection or response timeout exception
205 12
            case isset($e->getHandlerContext()['errno']) && $e->getHandlerContext()['errno'] == 28:
206 9
                return isset($options['retry_on_timeout']) && $options['retry_on_timeout'] === true;
207
208
            // No conditions met, so return false
209
            default:
210 3
                return false;
211
        }
212
    }
213
214
    /**
215
     * Check to see if a request can be retried
216
     *
217
     * This checks two things:
218
     *
219
     * 1. The response status code against the status codes that should be retried
220
     * 2. The number of attempts made thus far for this request
221
     *
222
     * @param array $options
223
     * @param ResponseInterface|null $response
224
     * @return bool  TRUE if the response should be retried, FALSE if not
225
     */
226 57
    protected function shouldRetryHttpResponse(array $options, ResponseInterface $response): bool
227
    {
228 57
        $statuses = array_map('\intval', (array) $options['retry_on_status']);
229
230 38
        switch (true) {
231 57
            case $options['retry_enabled'] === false:
232 57
            case $this->countRemainingRetries($options) === 0: // No Retry-After header, and it is required?  Give up
233 57
            case (! $response->hasHeader('Retry-After') && $options['retry_only_if_retry_after_header']):
234 18
                return false;
235
236
            // Conditions met; see if status code matches one that can be retried
237
            default:
238 54
                return in_array($response->getStatusCode(), $statuses, true);
239
        }
240
    }
241
242
    /**
243
     * Count the number of retries remaining.  Always returns 0 or greater.
244
     * @param array $options
245
     * @return int
246
     */
247 66
    protected function countRemainingRetries(array $options): int
248
    {
249 66
        $retryCount  = isset($options['retry_count']) ? (int) $options['retry_count'] : 0;
250
251 66
        $numAllowed  = isset($options['max_retry_attempts'])
252 66
            ? (int) $options['max_retry_attempts']
253 66
            : $this->defaultOptions['max_retry_attempts'];
254
255 66
        return max([$numAllowed - $retryCount, 0]);
256
    }
257
258
    /**
259
     * Retry the request
260
     *
261
     * Increments the retry count, determines the delay (timeout), executes callbacks, sleeps, and re-send the request
262
     *
263
     * @param RequestInterface $request
264
     * @param array $options
265
     * @param ResponseInterface|null $response
266
     * @return Promise
267
     */
268 51
    protected function doRetry(RequestInterface $request, array $options, ResponseInterface $response = null): Promise
269
    {
270
        // Increment the retry count
271 51
        ++$options['retry_count'];
272
273
        // Determine the delay timeout
274 51
        $delayTimeout = $this->determineDelayTimeout($options, $response);
275
276
        // Callback?
277 51
        if ($options['on_retry_callback']) {
278
            call_user_func_array(
279 42
                $options['on_retry_callback'],
280
                [
281 42
                    (int) $options['retry_count'],
282 42
                    (float) $delayTimeout,
283 42
                    &$request,
284 42
                    &$options,
285 42
                    $response
286
                ]
287
            );
288
        }
289
290
        // Delay!
291 51
        usleep(((int) $delayTimeout) * 1000000);
292
293
        // Return
294 51
        return $this($request, $options);
295
    }
296
297
    /**
298
     * @param array $options
299
     * @param ResponseInterface $response
300
     * @return ResponseInterface
301
     */
302 57
    protected function returnResponse(array $options, ResponseInterface $response): ResponseInterface
303
    {
304 57
        if ($options['expose_retry_header'] === false
305 57
            || $options['retry_count'] === 0
306
        ) {
307 54
            return $response;
308
        }
309
310 3
        return $response->withHeader($options['retry_header'], $options['retry_count']);
311
    }
312
313
    /**
314
     * Determine the delay timeout
315
     *
316
     * Attempts to read and interpret the HTTP `Retry-After` header, or defaults
317
     * to a built-in incremental back-off algorithm.
318
     *
319
     * @param ResponseInterface $response
320
     * @param array $options
321
     * @return float  Delay timeout, in seconds
322
     */
323 51
    protected function determineDelayTimeout(array $options, ResponseInterface $response = null): float
324
    {
325 51
        if (is_callable($options['default_retry_multiplier'])) {
326
            $defaultDelayTimeout = (float) call_user_func(
327 3
                $options['default_retry_multiplier'],
328 3
                $options['retry_count'],
329 3
                $response
330
            );
331
        } else {
332 48
            $defaultDelayTimeout = (float) $options['default_retry_multiplier'] * $options['retry_count'];
333
        }
334
335
        // Retry-After can be a delay in seconds or a date
336
        // (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
337 51
        if ($response && $response->hasHeader('Retry-After')) {
338
            return
339 9
                $this->deriveTimeoutFromHeader($response->getHeader('Retry-After')[0])
340 9
                ?: $defaultDelayTimeout;
341
        }
342
343 42
        return $defaultDelayTimeout;
344
    }
345
346
    /**
347
     * Attempt to derive the timeout from the HTTP `Retry-After` header
348
     *
349
     * The spec allows the header value to either be a number of seconds or a datetime.
350
     *
351
     * @param string $headerValue
352
     * @return float|null  The number of seconds to wait, or NULL if unsuccessful (invalid header)
353
     */
354 9
    protected function deriveTimeoutFromHeader(string $headerValue): ?float
355
    {
356
        // The timeout will either be a number or a HTTP-formatted date,
357
        // or seconds (integer)
358 9
        if (trim($headerValue) === (string) (int) $headerValue) {
359 3
            return (float) trim($headerValue);
360 6
        } elseif ($date = DateTime::createFromFormat(self::DATE_FORMAT, trim($headerValue))) {
361 3
            return (float) $date->format('U') - time();
362
        }
363
364 3
        return null;
365
    }
366
}
367