Test Failed
Push — master ( 3dd85e...34f16b )
by Devin
04:34 queued 10s
created

CurlClient::request()   F

Complexity

Conditions 18
Paths 223

Size

Total Lines 95

Duplication

Lines 8
Ratio 8.42 %

Importance

Changes 0
Metric Value
cc 18
nc 223
nop 5
dl 8
loc 95
rs 3.1057
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace Stripe\HttpClient;
4
5
use Stripe\Stripe;
6
use Stripe\Error;
7
use Stripe\Util;
8
9
// cURL constants are not defined in PHP < 5.5
10
11
// @codingStandardsIgnoreStart
12
// PSR2 requires all constants be upper case. Sadly, the CURL_SSLVERSION
13
// constants do not abide by those rules.
14
15
// Note the values 1 and 6 come from their position in the enum that
16
// defines them in cURL's source code.
17
if (!defined('CURL_SSLVERSION_TLSv1')) {
18
    define('CURL_SSLVERSION_TLSv1', 1);
19
}
20
if (!defined('CURL_SSLVERSION_TLSv1_2')) {
21
    define('CURL_SSLVERSION_TLSv1_2', 6);
22
}
23
// @codingStandardsIgnoreEnd
24
25
if (!defined('CURL_HTTP_VERSION_2TLS')) {
26
    define('CURL_HTTP_VERSION_2TLS', 4);
27
}
28
29
class CurlClient implements ClientInterface
30
{
31
    private static $instance;
32
33
    public static function instance()
34
    {
35
        if (!self::$instance) {
36
            self::$instance = new self();
37
        }
38
        return self::$instance;
39
    }
40
41
    protected $defaultOptions;
42
43
    protected $userAgentInfo;
44
45
    protected $enablePersistentConnections = null;
46
47
    protected $enableHttp2 = null;
48
49
    protected $curlHandle = null;
50
51
    /**
52
     * CurlClient constructor.
53
     *
54
     * Pass in a callable to $defaultOptions that returns an array of CURLOPT_* values to start
55
     * off a request with, or an flat array with the same format used by curl_setopt_array() to
56
     * provide a static set of options. Note that many options are overridden later in the request
57
     * call, including timeouts, which can be set via setTimeout() and setConnectTimeout().
58
     *
59
     * Note that request() will silently ignore a non-callable, non-array $defaultOptions, and will
60
     * throw an exception if $defaultOptions returns a non-array value.
61
     *
62
     * @param array|callable|null $defaultOptions
63
     */
64
    public function __construct($defaultOptions = null, $randomGenerator = null)
65
    {
66
        $this->defaultOptions = $defaultOptions;
67
        $this->randomGenerator = $randomGenerator ?: new Util\RandomGenerator();
68
        $this->initUserAgentInfo();
69
70
        // TODO: curl_reset requires PHP >= 5.5.0. Once we drop support for PHP 5.4, we can simply
71
        // initialize this to true.
72
        $this->enablePersistentConnections = function_exists('curl_reset');
73
74
        $this->enableHttp2 = $this->canSafelyUseHttp2();
75
    }
76
77
    public function __destruct()
78
    {
79
        $this->closeCurlHandle();
80
    }
81
82
    public function initUserAgentInfo()
83
    {
84
        $curlVersion = curl_version();
85
        $this->userAgentInfo = [
86
            'httplib' =>  'curl ' . $curlVersion['version'],
87
            'ssllib' => $curlVersion['ssl_version'],
88
        ];
89
    }
90
91
    public function getDefaultOptions()
92
    {
93
        return $this->defaultOptions;
94
    }
95
96
    public function getUserAgentInfo()
97
    {
98
        return $this->userAgentInfo;
99
    }
100
101
    /**
102
     * @return boolean
103
     */
104
    public function getEnablePersistentConnections()
105
    {
106
        return $this->enablePersistentConnections;
107
    }
108
109
    /**
110
     * @param boolean $enable
111
     */
112
    public function setEnablePersistentConnections($enable)
113
    {
114
        $this->enablePersistentConnections = $enable;
115
    }
116
117
    /**
118
     * @return boolean
119
     */
120
    public function getEnableHttp2()
121
    {
122
        return $this->enableHttp2;
123
    }
124
125
    /**
126
     * @param boolean $enable
127
     */
128
    public function setEnableHttp2($enable)
129
    {
130
        $this->enableHttp2 = $enable;
131
    }
132
133
    // USER DEFINED TIMEOUTS
134
135
    const DEFAULT_TIMEOUT = 80;
136
    const DEFAULT_CONNECT_TIMEOUT = 30;
137
138
    private $timeout = self::DEFAULT_TIMEOUT;
139
    private $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT;
140
141
    public function setTimeout($seconds)
142
    {
143
        $this->timeout = (int) max($seconds, 0);
144
        return $this;
145
    }
146
147
    public function setConnectTimeout($seconds)
148
    {
149
        $this->connectTimeout = (int) max($seconds, 0);
150
        return $this;
151
    }
152
153
    public function getTimeout()
154
    {
155
        return $this->timeout;
156
    }
157
158
    public function getConnectTimeout()
159
    {
160
        return $this->connectTimeout;
161
    }
162
163
    // END OF USER DEFINED TIMEOUTS
164
165
    public function request($method, $absUrl, $headers, $params, $hasFile)
166
    {
167
        $method = strtolower($method);
168
169
        $opts = [];
170
        if (is_callable($this->defaultOptions)) { // call defaultOptions callback, set options to return value
171
            $opts = call_user_func_array($this->defaultOptions, func_get_args());
172
            if (!is_array($opts)) {
173
                throw new Error\Api("Non-array value returned by defaultOptions CurlClient callback");
174
            }
175
        } elseif (is_array($this->defaultOptions)) { // set default curlopts from array
176
            $opts = $this->defaultOptions;
177
        }
178
179
        $params = Util\Util::objectsToIds($params);
180
181
        if ($method == 'get') {
182
            if ($hasFile) {
183
                throw new Error\Api(
184
                    "Issuing a GET request with a file parameter"
185
                );
186
            }
187
            $opts[CURLOPT_HTTPGET] = 1;
188 View Code Duplication
            if (count($params) > 0) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
189
                $encoded = Util\Util::encodeParameters($params);
190
                $absUrl = "$absUrl?$encoded";
191
            }
192
        } elseif ($method == 'post') {
193
            $opts[CURLOPT_POST] = 1;
194
            $opts[CURLOPT_POSTFIELDS] = $hasFile ? $params : Util\Util::encodeParameters($params);
195
        } elseif ($method == 'delete') {
196
            $opts[CURLOPT_CUSTOMREQUEST] = 'DELETE';
197 View Code Duplication
            if (count($params) > 0) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
198
                $encoded = Util\Util::encodeParameters($params);
199
                $absUrl = "$absUrl?$encoded";
200
            }
201
        } else {
202
            throw new Error\Api("Unrecognized method $method");
203
        }
204
205
        // It is only safe to retry network failures on POST requests if we
206
        // add an Idempotency-Key header
207
        if (($method == 'post') && (Stripe::$maxNetworkRetries > 0)) {
208
            if (!$this->hasHeader($headers, "Idempotency-Key")) {
209
                array_push($headers, 'Idempotency-Key: ' . $this->randomGenerator->uuid());
210
            }
211
        }
212
213
        // Create a callback to capture HTTP headers for the response
214
        $rheaders = new Util\CaseInsensitiveArray();
215
        $headerCallback = function ($curl, $header_line) use (&$rheaders) {
216
            // Ignore the HTTP request line (HTTP/1.1 200 OK)
217
            if (strpos($header_line, ":") === false) {
218
                return strlen($header_line);
219
            }
220
            list($key, $value) = explode(":", trim($header_line), 2);
221
            $rheaders[trim($key)] = trim($value);
222
            return strlen($header_line);
223
        };
224
225
        // By default for large request body sizes (> 1024 bytes), cURL will
226
        // send a request without a body and with a `Expect: 100-continue`
227
        // header, which gives the server a chance to respond with an error
228
        // status code in cases where one can be determined right away (say
229
        // on an authentication problem for example), and saves the "large"
230
        // request body from being ever sent.
231
        //
232
        // Unfortunately, the bindings don't currently correctly handle the
233
        // success case (in which the server sends back a 100 CONTINUE), so
234
        // we'll error under that condition. To compensate for that problem
235
        // for the time being, override cURL's behavior by simply always
236
        // sending an empty `Expect:` header.
237
        array_push($headers, 'Expect: ');
238
239
        $absUrl = Util\Util::utf8($absUrl);
240
        $opts[CURLOPT_URL] = $absUrl;
241
        $opts[CURLOPT_RETURNTRANSFER] = true;
242
        $opts[CURLOPT_CONNECTTIMEOUT] = $this->connectTimeout;
243
        $opts[CURLOPT_TIMEOUT] = $this->timeout;
244
        $opts[CURLOPT_HEADERFUNCTION] = $headerCallback;
245
        $opts[CURLOPT_HTTPHEADER] = $headers;
246
        $opts[CURLOPT_CAINFO] = Stripe::getCABundlePath();
247
        if (!Stripe::getVerifySslCerts()) {
248
            $opts[CURLOPT_SSL_VERIFYPEER] = false;
249
        }
250
251
        if (!isset($opts[CURLOPT_HTTP_VERSION]) && $this->getEnableHttp2()) {
252
            // For HTTPS requests, enable HTTP/2, if supported
253
            $opts[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2TLS;
254
        }
255
256
        list($rbody, $rcode) = $this->executeRequestWithRetries($opts, $absUrl);
257
258
        return [$rbody, $rcode, $rheaders];
259
    }
260
261
    /**
262
     * @param array $opts cURL options
263
     */
264
    private function executeRequestWithRetries($opts, $absUrl)
265
    {
266
        $numRetries = 0;
267
268
        while (true) {
269
            $rcode = 0;
270
            $errno = 0;
271
272
            $this->resetCurlHandle();
273
            curl_setopt_array($this->curlHandle, $opts);
274
            $rbody = curl_exec($this->curlHandle);
275
276
            if ($rbody === false) {
277
                $errno = curl_errno($this->curlHandle);
278
                $message = curl_error($this->curlHandle);
279
            } else {
280
                $rcode = curl_getinfo($this->curlHandle, CURLINFO_HTTP_CODE);
281
            }
282
            if (!$this->getEnablePersistentConnections()) {
283
                $this->closeCurlHandle();
284
            }
285
286
            if ($this->shouldRetry($errno, $rcode, $numRetries)) {
287
                $numRetries += 1;
288
                $sleepSeconds = $this->sleepTime($numRetries);
289
                usleep(intval($sleepSeconds * 1000000));
290
            } else {
291
                break;
292
            }
293
        }
294
295
        if ($rbody === false) {
296
            $this->handleCurlError($absUrl, $errno, $message, $numRetries);
297
        }
298
299
        return [$rbody, $rcode];
300
    }
301
302
    /**
303
     * @param string $url
304
     * @param int $errno
305
     * @param string $message
306
     * @param int $numRetries
307
     * @throws Error\ApiConnection
308
     */
309
    private function handleCurlError($url, $errno, $message, $numRetries)
310
    {
311
        switch ($errno) {
312
            case CURLE_COULDNT_CONNECT:
313
            case CURLE_COULDNT_RESOLVE_HOST:
314
            case CURLE_OPERATION_TIMEOUTED:
315
                $msg = "Could not connect to Stripe ($url).  Please check your "
316
                 . "internet connection and try again.  If this problem persists, "
317
                 . "you should check Stripe's service status at "
318
                 . "https://twitter.com/stripestatus, or";
319
                break;
320
            case CURLE_SSL_CACERT:
321
            case CURLE_SSL_PEER_CERTIFICATE:
322
                $msg = "Could not verify Stripe's SSL certificate.  Please make sure "
323
                 . "that your network is not intercepting certificates.  "
324
                 . "(Try going to $url in your browser.)  "
325
                 . "If this problem persists,";
326
                break;
327
            default:
328
                $msg = "Unexpected error communicating with Stripe.  "
329
                 . "If this problem persists,";
330
        }
331
        $msg .= " let us know at [email protected].";
332
333
        $msg .= "\n\n(Network error [errno $errno]: $message)";
334
335
        if ($numRetries > 0) {
336
            $msg .= "\n\nRequest was retried $numRetries times.";
337
        }
338
339
        throw new Error\ApiConnection($msg);
340
    }
341
342
    /**
343
     * Checks if an error is a problem that we should retry on. This includes both
344
     * socket errors that may represent an intermittent problem and some special
345
     * HTTP statuses.
346
     * @param int $errno
347
     * @param int $rcode
348
     * @param int $numRetries
349
     * @return bool
350
     */
351
    private function shouldRetry($errno, $rcode, $numRetries)
352
    {
353
        if ($numRetries >= Stripe::getMaxNetworkRetries()) {
354
            return false;
355
        }
356
357
        // Retry on timeout-related problems (either on open or read).
358
        if ($errno === CURLE_OPERATION_TIMEOUTED) {
359
            return true;
360
        }
361
362
        // Destination refused the connection, the connection was reset, or a
363
        // variety of other connection failures. This could occur from a single
364
        // saturated server, so retry in case it's intermittent.
365
        if ($errno === CURLE_COULDNT_CONNECT) {
366
            return true;
367
        }
368
369
        // 409 conflict
370
        if ($rcode === 409) {
371
            return true;
372
        }
373
374
        return false;
375
    }
376
377
    private function sleepTime($numRetries)
378
    {
379
        // Apply exponential backoff with $initialNetworkRetryDelay on the
380
        // number of $numRetries so far as inputs. Do not allow the number to exceed
381
        // $maxNetworkRetryDelay.
382
        $sleepSeconds = min(
383
            Stripe::getInitialNetworkRetryDelay() * 1.0 * pow(2, $numRetries - 1),
384
            Stripe::getMaxNetworkRetryDelay()
385
        );
386
387
        // Apply some jitter by randomizing the value in the range of
388
        // ($sleepSeconds / 2) to ($sleepSeconds).
389
        $sleepSeconds *= 0.5 * (1 + $this->randomGenerator->randFloat());
390
391
        // But never sleep less than the base sleep seconds.
392
        $sleepSeconds = max(Stripe::getInitialNetworkRetryDelay(), $sleepSeconds);
393
394
        return $sleepSeconds;
395
    }
396
397
    /**
398
     * Initializes the curl handle. If already initialized, the handle is closed first.
399
     */
400
    private function initCurlHandle()
401
    {
402
        $this->closeCurlHandle();
403
        $this->curlHandle = curl_init();
404
    }
405
406
    /**
407
     * Closes the curl handle if initialized. Do nothing if already closed.
408
     */
409
    private function closeCurlHandle()
410
    {
411
        if (!is_null($this->curlHandle)) {
412
            curl_close($this->curlHandle);
413
            $this->curlHandle = null;
414
        }
415
    }
416
417
    /**
418
     * Resets the curl handle. If the handle is not already initialized, or if persistent
419
     * connections are disabled, the handle is reinitialized instead.
420
     */
421
    private function resetCurlHandle()
422
    {
423
        if (!is_null($this->curlHandle) && $this->getEnablePersistentConnections()) {
424
            curl_reset($this->curlHandle);
425
        } else {
426
            $this->initCurlHandle();
427
        }
428
    }
429
430
    /**
431
     * Indicates whether it is safe to use HTTP/2 or not.
432
     *
433
     * @return boolean
434
     */
435
    private function canSafelyUseHttp2()
436
    {
437
        // Versions of curl older than 7.60.0 don't respect GOAWAY frames
438
        // (cf. https://github.com/curl/curl/issues/2416), which Stripe use.
439
        $curlVersion = curl_version()['version'];
440
        return (version_compare($curlVersion, '7.60.0') >= 0);
441
    }
442
443
    /**
444
     * Checks if a list of headers contains a specific header name.
445
     *
446
     * @param string[] $headers
447
     * @param string $name
448
     * @return boolean
449
     */
450
    private function hasHeader($headers, $name)
451
    {
452
        foreach ($headers as $header) {
453
            if (strncasecmp($header, "{$name}: ", strlen($name) + 2) === 0) {
454
                return true;
455
            }
456
        }
457
458
        return false;
459
    }
460
}
461