Completed
Push — master ( d375bf...47128d )
by Casey
01:54
created

GuzzleRetryMiddleware   A

Complexity

Total Complexity 32

Size/Duplication

Total Lines 285
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 3

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 32
lcom 1
cbo 3
dl 0
loc 285
ccs 85
cts 85
cp 1
rs 9.6
c 0
b 0
f 0

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