Completed
Pull Request — master (#5)
by
unknown
10:31 queued 09:08
created

shouldRetryConnectException()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 18
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6.027

Importance

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