Completed
Push — master ( 472381...f113ca )
by Casey
02:46
created

GuzzleRetryMiddleware   B

Complexity

Total Complexity 38

Size/Duplication

Total Lines 319
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 3

Test Coverage

Coverage 99.03%

Importance

Changes 0
Metric Value
wmc 38
lcom 1
cbo 3
dl 0
loc 319
ccs 102
cts 103
cp 0.9903
rs 8.3999
c 0
b 0
f 0

12 Methods

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