Completed
Push — master ( f525e0...7c9bef )
by Casey
14:54
created

GuzzleRetryMiddleware::determineDelayTimeout()   B

Complexity

Conditions 6
Paths 12

Size

Total Lines 30

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 6

Importance

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

This function has been deprecated. The supplier of the file has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed from the class and what other function to use instead.

Loading history...
206 12
            }
207
        };
208 12
    }
209 12
210 12
    /**
211
     * Decide whether or not to retry on connect exception
212
     *
213
     * @param array $options
214
     * @return bool
215
     */
216
    protected function shouldRetryConnectException(array $options): bool
217
    {
218
        return $options['retry_enabled']
219
            && ($options['retry_on_timeout'] ?? false)
220
            && $this->countRemainingRetries($options) > 0;
221
    }
222
223
    /**
224
     * Check to see if a request can be retried
225 63
     *
226
     * This checks two things:
227 63
     *
228
     * 1. The response status code against the status codes that should be retried
229 21
     * 2. The number of attempts made thus far for this request
230 63
     *
231 63
     * @param array $options
232 63
     * @param ResponseInterface|null $response
233 18
     * @return bool  TRUE if the response should be retried, FALSE if not
234
     */
235
    protected function shouldRetryHttpResponse(array $options, ResponseInterface $response): bool
236
    {
237 60
        $statuses = array_map('\intval', (array) $options['retry_on_status']);
238
239
        switch (true) {
240
            case $options['retry_enabled'] === false:
241
            case $this->countRemainingRetries($options) === 0: // No Retry-After header, and it is required?  Give up
242
            case (! $response->hasHeader('Retry-After') && $options['retry_only_if_retry_after_header']):
243
                return false;
244
245
            // Conditions met; see if status code matches one that can be retried
246 66
            default:
247
                return in_array($response->getStatusCode(), $statuses, true);
248 66
        }
249
    }
250 66
251 66
    /**
252 66
     * Count the number of retries remaining.  Always returns 0 or greater.
253
     * @param array $options
254 66
     * @return int
255
     */
256
    protected function countRemainingRetries(array $options): int
257
    {
258
        $retryCount  = isset($options['retry_count']) ? (int) $options['retry_count'] : 0;
259
260
        $numAllowed  = isset($options['max_retry_attempts'])
261
            ? (int) $options['max_retry_attempts']
262
            : $this->defaultOptions['max_retry_attempts'];
263
264
        return max([$numAllowed - $retryCount, 0]);
265
    }
266
267 57
    /**
268
     * Retry the request
269
     *
270 57
     * Increments the retry count, determines the delay (timeout), executes callbacks, sleeps, and re-send the request
271
     *
272
     * @param RequestInterface $request
273 57
     * @param array $options
274
     * @param ResponseInterface|null $response
275
     * @return Promise
276 57
     */
277
    protected function doRetry(RequestInterface $request, array $options, ResponseInterface $response = null): Promise
278 48
    {
279
        // Increment the retry count
280 48
        ++$options['retry_count'];
281 48
282 48
        // Determine the delay timeout
283 48
        $delayTimeout = $this->determineDelayTimeout($options, $response);
284 48
285
        // Callback?
286
        if ($options['on_retry_callback']) {
287
            call_user_func_array(
288
                $options['on_retry_callback'],
289
                [
290 57
                    (int) $options['retry_count'],
291
                    (float) $delayTimeout,
292
                    &$request,
293 57
                    &$options,
294
                    $response
295
                ]
296
            );
297
        }
298
299
        // Delay!
300
        usleep((int) ($delayTimeout * 1e6));
301 63
302
        // Return
303
        return $this($request, $options);
304 63
    }
305 63
306
    /**
307 60
     * @param array $options
308
     * @param ResponseInterface $response
309
     * @return ResponseInterface
310 3
     */
311
    protected function returnResponse(array $options, ResponseInterface $response): ResponseInterface
312
    {
313
        if ($options['expose_retry_header'] === false || $options['retry_count'] === 0) {
314
            return $response;
315
        }
316
317
        return $response->withHeader($options['retry_header'], $options['retry_count']);
318
    }
319
320
    /**
321
     * Determine the delay timeout
322
     *
323 57
     * Attempts to read and interpret the configured retry after header, or defaults
324
     * to a built-in incremental back-off algorithm.
325 57
     *
326
     * @param ResponseInterface $response
327 3
     * @param array $options
328 3
     * @return float  Delay timeout, in seconds
329 3
     */
330
    protected function determineDelayTimeout(array $options, ResponseInterface $response = null): float
331
    {
332 54
        if (is_callable($options['default_retry_multiplier'])) {
333
            $defaultDelayTimeout = (float) call_user_func(
334
                $options['default_retry_multiplier'],
335
                $options['retry_count'],
336
                $response
337 57
            );
338
        } else {
339 12
            $defaultDelayTimeout = (float) $options['default_retry_multiplier'] * $options['retry_count'];
340 12
        }
341
342
        // Retry-After can be a delay in seconds or a date
343 45
        // (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
344
        if ($response && $response->hasHeader($options['retry_after_header'])) {
345
            $timeout = $this->deriveTimeoutFromHeader(
346
                $response->getHeader($options['retry_after_header'])[0],
347
                $options['retry_after_date_format']
348
            ) ?: $defaultDelayTimeout;
349
        } else {
350
            $timeout = abs($defaultDelayTimeout);
351
        }
352
353
        // If the max_allowable_timeout_secs is set
354 12
        if ((float) abs($options['max_allowable_timeout_secs']) > 0) {
355
            return min(abs($timeout), (float) abs($options['max_allowable_timeout_secs']));
356
        } else {
357
            return abs($timeout);
358 12
        }
359 6
    }
360 6
361 3
    /**
362
     * Attempt to derive the timeout from the `Retry-After` (or custom) header value
363
     *
364 3
     * The spec allows the header value to either be a number of seconds or a datetime.
365
     *
366
     * @param string $headerValue
367
     * @param string|null $dateFormat
368
     * @return float|null  The number of seconds to wait, or NULL if unsuccessful (invalid header)
369
     */
370
    protected function deriveTimeoutFromHeader(string $headerValue, ?string $dateFormat = self::DATE_FORMAT): ?float
371
    {
372
        // The timeout will either be a number or a HTTP-formatted date,
373
        // or seconds (integer)
374
        if (is_numeric($headerValue)) {
375
            return (float) trim($headerValue);
376
        } elseif ($date = DateTime::createFromFormat($dateFormat, trim($headerValue))) {
377
            return (float) $date->format('U') - time();
378
        }
379
380
        return null;
381
    }
382
}
383