Test Failed
Branch develop (c8bc8f)
by Alexey
08:48
created

HttpClient::setRetryLimit()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 5
c 0
b 0
f 0
rs 10
cc 1
nc 1
nop 1
1
<?php
2
/** @noinspection PhpDocRedundantThrowsInspection */
3
declare(strict_types=1);
4
5
namespace Nelexa\GPlay\Http;
6
7
use GuzzleHttp\Client;
8
use GuzzleHttp\Cookie\CookieJar;
9
use GuzzleHttp\Exception\ConnectException;
10
use GuzzleHttp\Exception\GuzzleException;
11
use GuzzleHttp\Exception\RequestException;
12
use GuzzleHttp\HandlerStack;
13
use GuzzleHttp\Middleware;
14
use GuzzleHttp\RequestOptions;
15
use Psr\Http\Message\RequestInterface;
16
use Psr\Http\Message\ResponseInterface;
17
use Psr\SimpleCache\CacheInterface;
18
use function GuzzleHttp\Promise\each_limit_all;
19
20
class HttpClient extends Client
21
{
22
    public const OPTION_HANDLER_RESPONSE = 'handler_response';
23
    public const OPTION_CACHE_TTL = 'cache_ttl';
24
    public const OPTION_NO_CACHE = 'no_cache';
25
    public const OPTION_CACHE_KEY = 'cache_key';
26
27
    /**
28
     * @internal
29
     */
30
    private const CACHE_KEY = 'gplay.v1.%s.%s';
31
32
    /**
33
     * Number of attempts with HTTP error (except 404)
34
     *
35
     * @var int
36
     */
37
    private $retryLimit;
38
    /**
39
     * @var CacheInterface|null
40
     */
41
    private $cache;
42
43
    /**
44
     * HttpClient constructor.
45
     *
46
     * @param array $config
47
     * @param int $retryLimit
48
     * @param CacheInterface|null $cache
49
     */
50
    public function __construct(array $config = [], int $retryLimit = 4, ?CacheInterface $cache = null)
51
    {
52
        $this->setRetryLimit($retryLimit);
53
        $this->setCache($cache);
54
55
        $handlerStack = HandlerStack::create();
56
        $handlerStack->unshift(
57
            function (callable $handler) {
58
                return function (RequestInterface $request, array $options) use ($handler) {
59
                    if (!isset($options[self::OPTION_HANDLER_RESPONSE])) {
60
                        return $handler($request, $options);
61
                    }
62
63
                    if (!$options[self::OPTION_HANDLER_RESPONSE] instanceof ResponseHandlerInterface) {
64
                        throw new \RuntimeException("'" . self::OPTION_HANDLER_RESPONSE . "' option is not implements " . ResponseHandlerInterface::class);
65
                    }
66
67
                    if ($this->cache !== null) {
68
                        if (!isset($options[self::OPTION_CACHE_KEY])) {
69
                            $func = $options[self::OPTION_HANDLER_RESPONSE];
70
                            $ref = new \ReflectionClass($func);
71
                            if ($ref->isAnonymous()) {
72
                                static $hashes;
73
74
                                if ($hashes === null) {
75
                                    $hashes = new \SplObjectStorage();
76
                                }
77
                                if (!isset($hashes[$func])) {
78
                                    try {
79
                                        $file = new \SplFileObject($ref->getFileName());
80
                                        $file->seek($ref->getStartLine() - 1);
81
                                        $content = '';
82
                                        while ($file->key() < $ref->getEndLine()) {
83
                                            $content .= $file->current();
84
                                            $file->next();
85
                                        }
86
                                        $hashes[$func] = $content;
87
                                    } catch (\ReflectionException $e) {
88
                                        throw new \RuntimeException($e->getMessage(), $e->getCode(), $e);
89
                                    }
90
                                }
91
                                $handlerHash = (string)$hashes[$func];
92
                            } else {
93
                                $handlerHash = $ref->getName();
94
                            }
95
96
                            $options[self::OPTION_CACHE_KEY] = sprintf(
97
                                self::CACHE_KEY,
98
                                hash('crc32b', $handlerHash),
99
                                self::getRequestHash($request)
100
                            );
101
                        }
102
103
                        $value = $this->cache->get($options[self::OPTION_CACHE_KEY]);
104
                        if ($value !== null) {
105
                            return $value;
106
                        }
107
                    }
108
109
                    return $handler($request, $options)
110
                        ->then(
111
                            function (ResponseInterface $response) use ($options, $request) {
112
                                $result = call_user_func(
113
                                    $options[self::OPTION_HANDLER_RESPONSE],
114
                                    $request,
115
                                    $response
116
                                );
117
                                if ($this->cache !== null && $result !== null) {
118
                                    $ttl = $options[self::OPTION_CACHE_TTL] ?? \DateInterval::createFromDateString('1 hour');
119
                                    $noCache = $options[self::OPTION_NO_CACHE] ?? false;
120
                                    if (!$noCache) {
121
                                        $this->cache->set(
122
                                            $options[self::OPTION_CACHE_KEY],
123
                                            $result,
124
                                            $ttl
125
                                        );
126
                                    }
127
                                }
128
                                return $result;
129
                            }
130
                        );
131
                };
132
            }
133
        );
134
        $handlerStack->push(
135
            Middleware::retry(
136
                function (
137
                    $retries,
138
                    /** @noinspection PhpUnusedParameterInspection */
139
                    RequestInterface $request,
140
                    ResponseInterface $response = null,
141
                    RequestException $exception = null
142
                ) {
143
                    // retry decider
144
                    if ($retries >= $this->retryLimit) {
145
                        return false;
146
                    }
147
148
                    // Retry connection exceptions
149
                    if ($exception instanceof ConnectException) {
150
                        return true;
151
                    }
152
153
                    if (
0 ignored issues
show
Unused Code introduced by
This if statement, and the following return statement can be replaced with return $response !== nul...etStatusCode() >= 400);.
Loading history...
154
                        $response !== null && (
155
                            $response->getStatusCode() !== 404 &&
156
                            $response->getStatusCode() >= 400
157
                        )
158
                    ) {
159
                        return true;
160
                    }
161
                    return false;
162
                },
163
                static function (int $numberOfRetries) {
164
                    // retry delay
165
                    return 1000 * $numberOfRetries;
166
                }
167
            )
168
        );
169
170
        $config = array_replace_recursive($config, [
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $config. This often makes code more readable.
Loading history...
171
            'handler' => $handlerStack,
172
            RequestOptions::TIMEOUT => 10.0,
173
            RequestOptions::COOKIES => new CookieJar(),
174
            RequestOptions::HEADERS => [
175
                'Accept-Encoding' => 'gzip',
176
                'Accept-Language' => 'en',
177
                'User-Agent' => 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:67.0) Gecko/20100101 Firefox/67.0',
178
                'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
179
                'Connection' => 'keep-alive',
180
            ],
181
        ]);
182
        parent::__construct($config);
183
    }
184
185
    /**
186
     * @param CacheInterface|null $cache
187
     * @return self
188
     */
189
    public function setCache(?CacheInterface $cache): self
190
    {
191
        $this->cache = $cache;
192
        return $this;
193
    }
194
195
    /**
196
     * @param string|null $proxy
197
     * @return self
198
     */
199
    public function setProxy(?string $proxy): self
200
    {
201
        $config = $this->getConfig();
202
        $config[RequestOptions::PROXY] = $proxy;
203
        $this->setConfig($config);
204
        return $this;
205
    }
206
207
    /**
208
     * @param int $retryLimit
209
     * @return self
210
     */
211
    public function setRetryLimit(int $retryLimit): self
212
    {
213
        $this->retryLimit = max(0, $retryLimit);
214
        return $this;
215
    }
216
217
    /**
218
     * @param string $key
219
     * @param string $value
220
     * @return HttpClient
221
     */
222
    public function setHttpHeader(string $key, ?string $value): self
223
    {
224
        $config = $this->getConfig();
225
        if ($value === null) {
226
            if (isset($config[RequestOptions::HEADERS][$key])) {
227
                unset($config[RequestOptions::HEADERS][$key]);
228
                $this->setConfig($config);
229
            }
230
        } else {
231
            $config[RequestOptions::HEADERS][$key] = $value;
232
            $this->setConfig($config);
233
        }
234
        return $this;
235
    }
236
237
    /**
238
     * @param \DateInterval|int|null $ttl
239
     * @return HttpClient
240
     */
241
    public function setCacheTtl($ttl): self
242
    {
243
        if ($ttl !== null && !is_int($ttl) && !$ttl instanceof \DateInterval) {
244
            throw new \InvalidArgumentException('Invalid cache ttl value. Supported \DateInterval, int and null.');
245
        }
246
        $config = $this->getConfig();
247
        $config[self::OPTION_CACHE_TTL] = $ttl;
248
        $this->setConfig($config);
249
        return $this;
250
    }
251
252
    /**
253
     * @param float $connectTimeout
254
     * @return HttpClient
255
     */
256
    public function setConnectTimeout(float $connectTimeout): self
257
    {
258
        if ($connectTimeout < 0) {
259
            throw new \InvalidArgumentException('negative connect timeout');
260
        }
261
        $config = $this->getConfig();
262
        $config[RequestOptions::CONNECT_TIMEOUT] = $connectTimeout;
263
        $this->setConfig($config);
264
        return $this;
265
    }
266
267
    /**
268
     * @param float $timeout
269
     * @return HttpClient
270
     */
271
    public function setTimeout(float $timeout): self
272
    {
273
        if ($timeout < 0) {
274
            throw new \InvalidArgumentException('negative timeout');
275
        }
276
        $config = $this->getConfig();
277
        $config[RequestOptions::TIMEOUT] = $timeout;
278
        $this->setConfig($config);
279
        return $this;
280
    }
281
282
    /**
283
     * @param array $config
284
     */
285
    protected function mergeConfig(array $config): void
286
    {
287
        if (!empty($config)) {
288
            $this->setConfig(
289
                array_replace_recursive(
290
                    $this->getConfig(),
291
                    $config
292
                )
293
            );
294
        }
295
    }
296
297
    /**
298
     * @param array $config
299
     */
300
    protected function setConfig(array $config): void
301
    {
302
        static $property;
303
        try {
304
            if ($property === null) {
305
                $property = new \ReflectionProperty(parent::class, 'config');
306
                $property->setAccessible(true);
307
            }
308
            $property->setValue($this, $config);
309
        } catch (\ReflectionException $e) {
310
            throw new \RuntimeException($e->getMessage(), $e->getCode(), $e);
311
        }
312
    }
313
314
    /**
315
     * @param RequestInterface $request
316
     * @return string
317
     */
318
    private static function getRequestHash(RequestInterface $request): string
319
    {
320
        $data = [
321
            $request->getMethod(),
322
            (string)$request->getUri(),
323
            $request->getBody()->getContents(),
324
        ];
325
        foreach ($request->getHeaders() as $name => $header) {
326
            $data[] = $name . ': ' . implode(', ', $header);
327
        }
328
        $data[] = $request->getBody()->getContents();
329
        return hash('crc32b', implode("\n", $data));
330
    }
331
332
    /**
333
     * @param string $method
334
     * @param iterable $urls
335
     * @param array $options
336
     * @param int $concurrency
337
     * @return array
338
     * @throws GuzzleException
339
     */
340
    public function requestAsyncPool(string $method, iterable $urls, array $options = [], int $concurrency = 4): array
341
    {
342
        $results = [];
343
        if (!$urls instanceof \Generator) {
344
            $urls = $this->requestGenerator($method, $urls, $options);
0 ignored issues
show
Coding Style introduced by
Consider using a different name than the parameter $urls. This often makes code more readable.
Loading history...
345
        }
346
        each_limit_all($urls, $concurrency, static function ($response, $index) use (&$results) {
347
            $results[$index] = $response;
348
        })->wait();
349
        return $results;
350
    }
351
352
    /**
353
     * @param string $method
354
     * @param iterable $urls
355
     * @param array $options
356
     * @return \Generator
357
     */
358
    private function requestGenerator(string $method, iterable $urls, array $options): \Generator
359
    {
360
        foreach ($urls as $key => $url) {
361
            yield $key => $this->requestAsync($method, $url, $options);
362
        }
363
    }
364
}
365