Completed
Push — master ( 6922d6...dea9a8 )
by Casey
04:32
created

shouldRetryConnectException()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 19
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 6.027

Importance

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