Completed
Push — master ( f0fde2...742494 )
by Casey
01:55
created

shouldRetryConnectException()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 6

Importance

Changes 0
Metric Value
dl 0
loc 16
ccs 8
cts 8
cp 1
rs 9.1111
c 0
b 0
f 0
cc 6
nc 5
nop 2
crap 6
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 GuzzleHttp\Promise\rejection_for;
30
use function in_array;
31
use function is_callable;
32
33
/**
34
 * Retry After Middleware
35
 *
36
 * Guzzle 6 middleware that retries requests when encountering responses
37
 * with certain conditions (429 or 503).  This middleware also respects
38
 * the `RetryAfter` header
39
 *
40
 * @author Casey McLaughlin <[email protected]>
41
 */
42
class GuzzleRetryMiddleware
43
{
44
    // HTTP date format
45
    const DATE_FORMAT = 'D, d M Y H:i:s T';
46
47
    // Default retry header (off by default; configurable)
48
    const RETRY_HEADER = 'X-Retry-Counter';
49
50
    /**
51
     * @var array
52
     */
53
    private $defaultOptions = [
54
55
        // Retry enabled.  Toggle retry on or off per request
56
        'retry_enabled'                    => true,
57
58
        // If server doesn't provide a Retry-After header, then set a default back-off delay
59
        // NOTE: This can either be a float, or it can be a callable that returns a (accepts count and response|null)
60
        'default_retry_multiplier'         => 1.5,
61
62
        // Set a maximum number of attempts per request
63
        'max_retry_attempts'               => 10,
64
65
        // Set this to TRUE to retry only if the HTTP Retry-After header is specified
66
        'retry_only_if_retry_after_header' => false,
67
68
        // Only retry when status is equal to these response codes
69
        'retry_on_status'                  => ['429', '503'],
70
71
        // Callback to trigger when delay occurs (accepts count, delay, request, response, options)
72
        'on_retry_callback'                => null,
73
74
        // Retry on connect timeout?
75
        'retry_on_timeout'                 => false,
76
77
        // Add the number of retries to an X-Header
78
        'expose_retry_header'              => false,
79
80
        // The header key
81
        'retry_header'                     => self::RETRY_HEADER
82
    ];
83
84
    /**
85
     * @var callable
86
     */
87
    private $nextHandler;
88
89
    /**
90
     * Provides a closure that can be pushed onto the handler stack
91
     *
92
     * Example:
93
     * <code>$handlerStack->push(GuzzleRetryMiddleware::factory());</code>
94
     *
95
     * @param array $defaultOptions
96
     * @return Closure
97
     */
98 66
    public static function factory(array $defaultOptions = []): callable
99
    {
100
        return function (callable $handler) use ($defaultOptions) {
101 66
            return new static($handler, $defaultOptions);
102 66
        };
103
    }
104
105
    /**
106
     * GuzzleRetryMiddleware constructor.
107
     *
108
     * @param callable $nextHandler
109
     * @param array $defaultOptions
110
     */
111 69
    public function __construct(callable $nextHandler, array $defaultOptions = [])
112
    {
113 69
        $this->nextHandler = $nextHandler;
114 69
        $this->defaultOptions = array_replace($this->defaultOptions, $defaultOptions);
115 69
    }
116
117
    /**
118
     * @param RequestInterface $request
119
     * @param array $options
120
     * @return Promise
121
     */
122 66
    public function __invoke(RequestInterface $request, array $options): Promise
123
    {
124
        // Combine options with defaults specified by this middleware
125 66
        $options = array_replace($this->defaultOptions, $options);
126
127
        // Set the retry count if not already set
128 66
        if (! isset($options['retry_count'])) {
129 66
            $options['retry_count'] = 0;
130
        }
131
132
        /** @var callable $next */
133 66
        $next = $this->nextHandler;
134 66
        return $next($request, $options)
135 66
            ->then(
136 66
                $this->onFulfilled($request, $options),
137 66
                $this->onRejected($request, $options)
138
            );
139
    }
140
141
    /**
142
     * No exceptions were thrown during processing
143
     *
144
     * Depending on where this middleware is in the stack, the response could still
145
     * be unsuccessful (e.g. 429 or 503), so check to see if it should be retried
146
     *
147
     * @param RequestInterface $request
148
     * @param array $options
149
     * @return callable
150
     */
151 66
    protected function onFulfilled(RequestInterface $request, array $options): callable
152
    {
153
        return function (ResponseInterface $response) use ($request, $options) {
154 54
            return $this->shouldRetryHttpResponse($options, $response)
155 39
                ? $this->doRetry($request, $options, $response)
156 54
                : $this->returnResponse($options, $response);
157 66
        };
158
    }
159
160
    /**
161
     * An exception or error was thrown during processing
162
     *
163
     * If the reason is a BadResponseException exception, check to see if
164
     * the request can be retried.  Otherwise, pass it on.
165
     *
166
     * @param RequestInterface $request
167
     * @param array $options
168
     * @return callable
169
     */
170 66
    protected function onRejected(RequestInterface $request, array $options): callable
171
    {
172
        return function ($reason) use ($request, $options) {
173
            // If was bad response exception, test if we retry based on the response headers
174 18
            if ($reason instanceof BadResponseException) {
175 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...
176 3
                    return $this->doRetry($request, $options, $reason->getResponse());
177
                }
178
            // If this was a connection exception, test to see if we should retry based on connect timeout rules
179 15
            } elseif ($reason instanceof ConnectException) {
180
                // If was another type of exception, test if we should retry based on timeout rules
181 12
                if ($this->shouldRetryConnectException($reason, $options)) {
182 6
                    return $this->doRetry($request, $options);
183
                }
184
            }
185
186
            // If made it here, then we have decided not to retry the request
187 12
            return rejection_for($reason);
188 66
        };
189
    }
190
191
    /**
192
     * @param ConnectException $e
193
     * @param array $options
194
     * @return bool
195
     */
196 12
    protected function shouldRetryConnectException(ConnectException $e, array $options): bool
197
    {
198 8
        switch (true) {
199 12
            case $options['retry_enabled'] === false:
200 12
            case $this->countRemainingRetries($options) === 0:
201 3
                return false;
202
203
            // Test if this was a connection or response timeout exception
204 12
            case isset($e->getHandlerContext()['errno']) && $e->getHandlerContext()['errno'] == 28:
205 9
                return isset($options['retry_on_timeout']) && $options['retry_on_timeout'] === true;
206
207
            // No conditions met, so return false
208
            default:
209 3
                return false;
210
        }
211
    }
212
213
    /**
214
     * Check to see if a request can be retried
215
     *
216
     * This checks two things:
217
     *
218
     * 1. The response status code against the status codes that should be retried
219
     * 2. The number of attempts made thus far for this request
220
     *
221
     * @param array $options
222
     * @param ResponseInterface|null $response
223
     * @return bool  TRUE if the response should be retried, FALSE if not
224
     */
225 54
    protected function shouldRetryHttpResponse(array $options, ResponseInterface $response): bool
226
    {
227 54
        $statuses = array_map('\intval', (array) $options['retry_on_status']);
228
229 36
        switch (true) {
230 54
            case $options['retry_enabled'] === false:
231 54
            case $this->countRemainingRetries($options) === 0:
232 54
            case (! $response->hasHeader('Retry-After') && $options['retry_only_if_retry_after_header']):
233 18
                return false;
234
235
            // Conditions met; see if status code matches one that can be retried
236
            default:
237 51
                return in_array($response->getStatusCode(), $statuses, true);
238
        }
239
    }
240
241
    /**
242
     * Count the number of retries remaining.  Always returns 0 or greater.
243
     * @param array $options
244
     * @return int
245
     */
246 63
    protected function countRemainingRetries(array $options): int
247
    {
248 63
        $retryCount  = isset($options['retry_count']) ? (int) $options['retry_count'] : 0;
249
250 63
        $numAllowed  = isset($options['max_retry_attempts'])
251 63
            ? (int) $options['max_retry_attempts']
252 63
            : $this->defaultOptions['max_retry_attempts'];
253
254 63
        return max([$numAllowed - $retryCount, 0]);
255
    }
256
257
    /**
258
     * Retry the request
259
     *
260
     * Increments the retry count, determines the delay (timeout), executes callbacks, sleeps, and re-send the request
261
     *
262
     * @param RequestInterface $request
263
     * @param array $options
264
     * @param ResponseInterface|null $response
265
     * @return Promise
266
     */
267 48
    protected function doRetry(RequestInterface $request, array $options, ResponseInterface $response = null): Promise
268
    {
269
        // Increment the retry count
270 48
        ++$options['retry_count'];
271
272
        // Determine the delay timeout
273 48
        $delayTimeout = $this->determineDelayTimeout($options, $response);
274
275
        // Callback?
276 48
        if ($options['on_retry_callback']) {
277
            call_user_func(
278 39
                $options['on_retry_callback'],
279 39
                (int) $options['retry_count'],
280
                (float) $delayTimeout,
281
                $request,
282
                $options,
283
                $response
284
            );
285
        }
286
287
        // Delay!
288 48
        usleep(((int) $delayTimeout) * 1000000);
289
290
        // Return
291 48
        return $this($request, $options);
292
    }
293
294
    /**
295
     * @param array $options
296
     * @param ResponseInterface $response
297
     * @return ResponseInterface
298
     */
299 54
    protected function returnResponse(array $options, ResponseInterface $response): ResponseInterface
300
    {
301 54
        if ($options['expose_retry_header'] === false
302 54
            || $options['retry_count'] === 0
303
        ) {
304 51
            return $response;
305
        }
306
307 3
        return $response->withHeader($options['retry_header'], $options['retry_count']);
308
    }
309
310
    /**
311
     * Determine the delay timeout
312
     *
313
     * Attempts to read and interpret the HTTP `Retry-After` header, or defaults
314
     * to a built-in incremental back-off algorithm.
315
     *
316
     * @param ResponseInterface $response
317
     * @param array $options
318
     * @return float  Delay timeout, in seconds
319
     */
320 48
    protected function determineDelayTimeout(array $options, ResponseInterface $response = null): float
321
    {
322 48
        if (is_callable($options['default_retry_multiplier'])) {
323
            $defaultDelayTimeout = (float) call_user_func(
324 3
                $options['default_retry_multiplier'],
325 3
                $options['retry_count'],
326 3
                $response
327
            );
328
        } else {
329 45
            $defaultDelayTimeout = (float) $options['default_retry_multiplier'] * $options['retry_count'];
330
        }
331
332
        // Retry-After can be a delay in seconds or a date
333
        // (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
334 48
        if ($response && $response->hasHeader('Retry-After')) {
335
            return
336 9
                $this->deriveTimeoutFromHeader($response->getHeader('Retry-After')[0])
337 9
                ?: $defaultDelayTimeout;
338
        }
339
340 39
        return $defaultDelayTimeout;
341
    }
342
343
    /**
344
     * Attempt to derive the timeout from the HTTP `Retry-After` header
345
     *
346
     * The spec allows the header value to either be a number of seconds or a datetime.
347
     *
348
     * @param string $headerValue
349
     * @return float|null  The number of seconds to wait, or NULL if unsuccessful (invalid header)
350
     */
351 9
    protected function deriveTimeoutFromHeader(string $headerValue): ?float
352
    {
353
        // The timeout will either be a number or a HTTP-formatted date,
354
        // or seconds (integer)
355 9
        if (trim($headerValue) === $headerValue) {
356 9
            return (float) trim($headerValue);
357
        } elseif ($date = DateTime::createFromFormat(self::DATE_FORMAT, trim($headerValue))) {
358
            return (float) $date->format('U') - time();
359
        }
360
361
        return null;
362
    }
363
}
364