Completed
Push — master ( dea9a8...eedbb1 )
by Casey
03:32 queued 01:16
created

GuzzleRetryMiddleware::__invoke()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 18
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 11
cts 11
cp 1
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 9
nc 2
nop 2
crap 2
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 60
        }
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 60
            );
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 48
                ? $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 9
            }
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 12
        switch (true) {
177 12
            case $options['retry_enabled'] === false:
178
                return false;
179
180 12
            case $this->countRemainingRetries($options) == 0:
181 3
                return false;
182
183
            // Test if this was a connection or response timeout exception
184 12
            case isset($e->getHandlerContext()['errno']) && $e->getHandlerContext()['errno'] == 28:
185 9
                return isset($options['retry_on_timeout']) && $options['retry_on_timeout'] == true;
186
187
            // No conditions met, so return false
188 3
            default:
189 3
                return false;
190 3
        }
191
    }
192
193
    /**
194
     * Check to see if a request can be retried
195
     *
196
     * This checks two things:
197
     *
198
     * 1. The response status code against the status codes that should be retried
199
     * 2. The number of attempts made thus far for this request
200
     *
201
     * @param array $options
202
     * @param ResponseInterface|null $response
203
     * @return bool  TRUE if the response should be retried, FALSE if not
204
     */
205 48
    protected function shouldRetryHttpResponse(array $options, ResponseInterface $response)
206
    {
207 48
        $statuses = array_map('intval', (array) $options['retry_on_status']);
208
209 48
        switch (true) {
210 48
            case $options['retry_enabled'] === false:
211 3
                return false;
212
213 48
            case $this->countRemainingRetries($options) == 0:
214 9
                return false;
215
216
            // No Retry-After header, and it is required?  Give up
217 48
            case (! $response->hasHeader('Retry-After') && $options['retry_only_if_retry_after_header']):
218 3
                return false;
219
220
            // Conditions met; see if status code matches one that can be retried
221 45
            default:
222 45
                return in_array($response->getStatusCode(), $statuses);
223 45
        }
224
    }
225
226
    /**
227
     * Count the number of retries remaining.  Always returns 0 or greater.
228
     * @param array $options
229
     * @return int
230
     */
231 57
    protected function countRemainingRetries(array $options)
232
    {
233 57
        $retryCount  = isset($options['retry_count']) ? (int) $options['retry_count'] : 0;
234
235 57
        $numAllowed  = isset($options['max_retry_attempts'])
236 57
            ? (int) $options['max_retry_attempts']
237 57
            : $this->defaultOptions['max_retry_attempts'];
238
239 57
        return max([$numAllowed - $retryCount, 0]);
240
    }
241
242
    /**
243
     * Retry the request
244
     *
245
     * Increments the retry count, determines the delay (timeout), executes callbacks, sleeps, and re-send the request
246
     *
247
     * @param RequestInterface $request
248
     * @param array $options
249
     * @param ResponseInterface|null $response
250
     * @return
251
     */
252 45
    protected function doRetry(RequestInterface $request, array $options, ResponseInterface $response = null)
253
    {
254
        // Increment the retry count
255 45
        ++$options['retry_count'];
256
257
        // Determine the delay timeout
258 45
        $delayTimeout = $this->determineDelayTimeout($options, $response);
259
260
        // Callback?
261 45
        if ($options['on_retry_callback']) {
262 39
            call_user_func(
263 39
                $options['on_retry_callback'],
264 39
                (int) $options['retry_count'],
265 39
                (float) $delayTimeout,
266 39
                $request,
267 39
                $options,
268
                $response
269 39
            );
270 39
        }
271
272
        // Delay!
273 45
        usleep($delayTimeout * 1000000);
274
275
        // Return
276 45
        return $this($request, $options);
277
    }
278
279
    /**
280
     * Determine the delay timeout
281
     *
282
     * Attempts to read and interpret the HTTP `Retry-After` header, or defaults
283
     * to a built-in incremental back-off algorithm.
284
     *
285
     * @param ResponseInterface $response
286
     * @param array $options
287
     * @return float  Delay timeout, in seconds
288
     */
289 45
    protected function determineDelayTimeout(array $options, ResponseInterface $response = null)
290
    {
291 45
        if (is_callable($options['default_retry_multiplier'])) {
292 3
            $defaultDelayTimeout = (float) call_user_func(
293 3
                $options['default_retry_multiplier'],
294 3
                $options['retry_count'],
295
                $response
296 3
            );
297 3
        } else {
298 42
            $defaultDelayTimeout = (float) $options['default_retry_multiplier'] * $options['retry_count'];
299
        }
300
301
        // Retry-After can be a delay in seconds or a date
302
        // (see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After)
303 45
        if ($response && $response->hasHeader('Retry-After')) {
304
            return
305 9
                $this->deriveTimeoutFromHeader($response->getHeader('Retry-After')[0])
306 9
                ?: $defaultDelayTimeout;
307
        } else {
308 36
            return $defaultDelayTimeout;
309
        }
310
    }
311
312
    /**
313
     * Attempt to derive the timeout from the HTTP `Retry-After` header
314
     *
315
     * The spec allows the header value to either be a number of seconds or a datetime.
316
     *
317
     * @param string $headerValue
318
     * @return float|null  The number of seconds to wait, or NULL if unsuccessful (invalid header)
319
     */
320 9
    protected function deriveTimeoutFromHeader($headerValue)
321
    {
322
        // The timeout will either be a number or a HTTP-formatted date,
323
        // or seconds (integer)
324 9
        if ((string) intval(trim($headerValue)) == $headerValue) {
325 3
            return intval(trim($headerValue));
326 6
        } elseif ($date = \DateTime::createFromFormat(self::DATE_FORMAT, trim($headerValue))) {
327 3
            return (int) $date->format('U') - time();
328
        } else {
329 3
            return null;
330
        }
331
    }
332
}
333