• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 25334076216

04 May 2026 05:46PM UTC coverage: 88.295% (+0.03%) from 88.263%
25334076216

Pull #10157

github

web-flow
Merge 61dade90f into 3cdb4c5e1
Pull Request #10157: feat: add CURLRequest retry option

82 of 86 new or added lines in 2 files covered. (95.35%)

1 existing line in 1 file now uncovered.

23566 of 26690 relevant lines covered (88.3%)

218.09 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.47
/system/HTTP/CURLRequest.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\HTTP;
15

16
use CodeIgniter\Exceptions\InvalidArgumentException;
17
use CodeIgniter\HTTP\Exceptions\HTTPException;
18
use Config\App;
19
use Config\CURLRequest as ConfigCURLRequest;
20
use CurlShareHandle;
21
use SensitiveParameter;
22

23
/**
24
 * A lightweight HTTP client for sending synchronous HTTP requests via cURL.
25
 *
26
 * @see \CodeIgniter\HTTP\CURLRequestTest
27
 */
28
class CURLRequest extends OutgoingRequest
29
{
30
    /**
31
     * The response object associated with this request
32
     *
33
     * @var ResponseInterface|null
34
     */
35
    protected $response;
36

37
    /**
38
     * The original response object associated with this request
39
     *
40
     * @var ResponseInterface|null
41
     */
42
    protected $responseOrig;
43

44
    /**
45
     * The URI associated with this request
46
     *
47
     * @var URI
48
     */
49
    protected $baseURI;
50

51
    /**
52
     * The setting values
53
     *
54
     * @var array
55
     */
56
    protected $config;
57

58
    /**
59
     * The default setting values
60
     *
61
     * @var array
62
     */
63
    protected $defaultConfig = [
64
        'timeout'         => 0.0,
65
        'connect_timeout' => 150,
66
        'debug'           => false,
67
        'verify'          => true,
68
    ];
69

70
    /**
71
     * Default values for when 'allow_redirects'
72
     * option is true.
73
     *
74
     * @var array
75
     */
76
    protected $redirectDefaults = [
77
        'max'       => 5,
78
        'strict'    => true,
79
        'protocols' => [
80
            'http',
81
            'https',
82
        ],
83
    ];
84

85
    /**
86
     * Default values for when 'retry' is enabled.
87
     *
88
     * @var array<string, bool|int|list<int>>
89
     */
90
    protected $retryDefaults = [
91
        'max_retries'         => 3,
92
        'delay'               => 1000,
93
        'max_delay'           => 30_000,
94
        'status_codes'        => [429, 503, 504],
95
        'curl_errors'         => false,
96
        'respect_retry_after' => true,
97
    ];
98

99
    /**
100
     * cURL error numbers that may succeed on another attempt.
101
     *
102
     * @var list<int>
103
     */
104
    protected $transientCurlErrors = [
105
        CURLE_COULDNT_RESOLVE_HOST,
106
        CURLE_COULDNT_CONNECT,
107
        CURLE_OPERATION_TIMEDOUT,
108
        CURLE_SEND_ERROR,
109
        CURLE_RECV_ERROR,
110
    ];
111

112
    /**
113
     * The number of milliseconds to delay before
114
     * sending the request.
115
     *
116
     * @var float
117
     */
118
    protected $delay = 0.0;
119

120
    /**
121
     * The last cURL error number.
122
     */
123
    protected int $lastCurlError = 0;
124

125
    /**
126
     * The default options from the constructor. Applied to all requests.
127
     */
128
    private readonly array $defaultOptions;
129

130
    /**
131
     * Whether share options between requests or not.
132
     *
133
     * If true, all the options won't be reset between requests.
134
     * It may cause an error request with unnecessary headers.
135
     */
136
    private readonly bool $shareOptions;
137

138
    /**
139
     * The share connection instance.
140
     */
141
    protected ?CurlShareHandle $shareConnection = null;
142

143
    /**
144
     * Takes an array of options to set the following possible class properties:
145
     *
146
     *  - baseURI
147
     *  - timeout
148
     *  - any other request options to use as defaults.
149
     *
150
     * @todo v4.8.0 Remove $config parameter since unused
151
     *
152
     * @param array<string, mixed> $options
153
     *
154
     * @phpstan-ignore-next-line constructor.unusedParameter
155
     */
156
    public function __construct(App $config, URI $uri, ?ResponseInterface $response = null, array $options = [])
157
    {
158
        if (! function_exists('curl_version')) {
203✔
159
            throw HTTPException::forMissingCurl(); // @codeCoverageIgnore
160
        }
161

162
        parent::__construct(Method::GET, $uri);
203✔
163

164
        $this->responseOrig = $response ?? new Response();
203✔
165
        // Remove the default Content-Type header.
166
        $this->responseOrig->removeHeader('Content-Type');
203✔
167

168
        $this->baseURI        = $uri->useRawQueryString();
203✔
169
        $this->defaultOptions = $options;
203✔
170

171
        $this->shareOptions = config(ConfigCURLRequest::class)->shareOptions;
203✔
172

173
        $this->config = $this->defaultConfig;
203✔
174
        $this->parseOptions($options);
203✔
175

176
        // Share Connection
177
        $optShareConnection = config(ConfigCURLRequest::class)->shareConnectionOptions ?? [ // @phpstan-ignore nullCoalesce.property
203✔
178
            CURL_LOCK_DATA_CONNECT,
203✔
179
            CURL_LOCK_DATA_DNS,
203✔
180
        ];
181

182
        if ($optShareConnection !== []) {
203✔
183
            $this->shareConnection = curl_share_init();
203✔
184

185
            foreach (array_unique($optShareConnection) as $opt) {
203✔
186
                curl_share_setopt($this->shareConnection, CURLSHOPT_SHARE, $opt);
203✔
187
            }
188
        }
189
    }
190

191
    /**
192
     * Sends an HTTP request to the specified $url. If this is a relative
193
     * URL, it will be merged with $this->baseURI to form a complete URL.
194
     *
195
     * @param string $method HTTP method
196
     */
197
    public function request($method, string $url, array $options = []): ResponseInterface
198
    {
199
        $this->response = clone $this->responseOrig;
188✔
200

201
        $this->parseOptions($options);
188✔
202

203
        $url = $this->prepareURL($url);
188✔
204

205
        $method = esc(strip_tags($method));
188✔
206

207
        $this->send($method, $url);
188✔
208

209
        if ($this->shareOptions === false) {
181✔
210
            $this->resetOptions();
97✔
211
        }
212

213
        return $this->response;
181✔
214
    }
215

216
    /**
217
     * Reset all options to default.
218
     *
219
     * @return void
220
     */
221
    protected function resetOptions()
222
    {
223
        // Reset headers
224
        $this->headers   = [];
97✔
225
        $this->headerMap = [];
97✔
226

227
        // Reset body
228
        $this->body = null;
97✔
229

230
        // Reset configs
231
        $this->config = $this->defaultConfig;
97✔
232

233
        // Set the default options for next request
234
        $this->parseOptions($this->defaultOptions);
97✔
235
    }
236

237
    /**
238
     * Convenience method for sending a GET request.
239
     */
240
    public function get(string $url, array $options = []): ResponseInterface
241
    {
242
        return $this->request(Method::GET, $url, $options);
56✔
243
    }
244

245
    /**
246
     * Convenience method for sending a DELETE request.
247
     */
248
    public function delete(string $url, array $options = []): ResponseInterface
249
    {
250
        return $this->request('DELETE', $url, $options);
2✔
251
    }
252

253
    /**
254
     * Convenience method for sending a HEAD request.
255
     */
256
    public function head(string $url, array $options = []): ResponseInterface
257
    {
258
        return $this->request('HEAD', $url, $options);
2✔
259
    }
260

261
    /**
262
     * Convenience method for sending an OPTIONS request.
263
     */
264
    public function options(string $url, array $options = []): ResponseInterface
265
    {
266
        return $this->request('OPTIONS', $url, $options);
2✔
267
    }
268

269
    /**
270
     * Convenience method for sending a PATCH request.
271
     */
272
    public function patch(string $url, array $options = []): ResponseInterface
273
    {
274
        return $this->request('PATCH', $url, $options);
2✔
275
    }
276

277
    /**
278
     * Convenience method for sending a POST request.
279
     */
280
    public function post(string $url, array $options = []): ResponseInterface
281
    {
282
        return $this->request(Method::POST, $url, $options);
16✔
283
    }
284

285
    /**
286
     * Convenience method for sending a PUT request.
287
     */
288
    public function put(string $url, array $options = []): ResponseInterface
289
    {
290
        return $this->request(Method::PUT, $url, $options);
2✔
291
    }
292

293
    /**
294
     * Set the HTTP Authentication.
295
     *
296
     * @param string $type basic or digest
297
     *
298
     * @return $this
299
     */
300
    public function setAuth(string $username, #[SensitiveParameter] string $password, string $type = 'basic')
301
    {
302
        $this->config['auth'] = [$username, $password, $type];
4✔
303

304
        return $this;
4✔
305
    }
306

307
    /**
308
     * Set form data to be sent.
309
     *
310
     * @param bool $multipart Set TRUE if you are sending CURLFiles
311
     *
312
     * @return $this
313
     */
314
    public function setForm(array $params, bool $multipart = false)
315
    {
316
        if ($multipart) {
2✔
317
            $this->config['multipart'] = $params;
2✔
318
        } else {
319
            $this->config['form_params'] = $params;
2✔
320
        }
321

322
        return $this;
2✔
323
    }
324

325
    /**
326
     * Set JSON data to be sent.
327
     *
328
     * @param array|bool|float|int|object|string|null $data
329
     *
330
     * @return $this
331
     */
332
    public function setJSON($data)
333
    {
334
        $this->config['json'] = $data;
2✔
335

336
        return $this;
2✔
337
    }
338

339
    /**
340
     * Sets the correct settings based on the options array
341
     * passed in.
342
     *
343
     * @return void
344
     */
345
    protected function parseOptions(array $options)
346
    {
347
        if (array_key_exists('baseURI', $options)) {
203✔
348
            $this->baseURI = new URI($options['baseURI'], true);
48✔
349
            unset($options['baseURI']);
48✔
350
        }
351

352
        if (array_key_exists('headers', $options) && is_array($options['headers'])) {
203✔
353
            foreach ($options['headers'] as $name => $value) {
6✔
354
                $this->setHeader($name, $value);
6✔
355
            }
356

357
            unset($options['headers']);
6✔
358
        }
359

360
        if (array_key_exists('delay', $options)) {
203✔
361
            // Convert from the milliseconds passed in
362
            // to the seconds that sleep requires.
363
            $this->delay = (float) $options['delay'] / 1000;
26✔
364
            unset($options['delay']);
26✔
365
        }
366

367
        if (array_key_exists('body', $options)) {
203✔
368
            $this->setBody($options['body']);
2✔
369
            unset($options['body']);
2✔
370
        }
371

372
        foreach ($options as $key => $value) {
203✔
373
            $this->config[$key] = $value;
114✔
374
        }
375
    }
376

377
    /**
378
     * If the $url is a relative URL, will attempt to create
379
     * a full URL by prepending $this->baseURI to it.
380
     */
381
    protected function prepareURL(string $url): string
382
    {
383
        // If it's a full URI, then we have nothing to do here...
384
        if (str_contains($url, '://')) {
190✔
385
            return $url;
100✔
386
        }
387

388
        $uri = $this->baseURI->resolveRelativeURI($url);
90✔
389

390
        // Create the string instead of casting to prevent baseURL muddling
391
        return URI::createURIString(
90✔
392
            $uri->getScheme(),
90✔
393
            $uri->getAuthority(),
90✔
394
            $uri->getPath(),
90✔
395
            $uri->getQuery(),
90✔
396
            $uri->getFragment(),
90✔
397
        );
90✔
398
    }
399

400
    /**
401
     * Fires the actual cURL request.
402
     *
403
     * @return ResponseInterface
404
     */
405
    public function send(string $method, string $url)
406
    {
407
        // Reset our curl options so we're on a fresh slate.
408
        $curlOptions = [];
190✔
409
        $config      = $this->config;
190✔
410
        $retry       = $this->normalizeRetryOption($config['retry'] ?? false);
190✔
411

412
        if (! empty($this->config['query']) && is_array($this->config['query'])) {
190✔
413
            // This is likely too naive a solution.
414
            // Should look into handling when $url already
415
            // has query vars on it.
416
            $url .= '?' . http_build_query($this->config['query']);
2✔
417
            unset($this->config['query']);
2✔
418
        }
419

420
        $curlOptions[CURLOPT_URL]            = $url;
190✔
421
        $curlOptions[CURLOPT_RETURNTRANSFER] = true;
190✔
422

423
        if ($this->shareConnection instanceof CurlShareHandle) {
190✔
424
            $curlOptions[CURLOPT_SHARE] = $this->shareConnection;
188✔
425
        }
426

427
        $curlOptions[CURLOPT_HEADER] = true;
190✔
428
        // Disable @file uploads in post data.
429
        $curlOptions[CURLOPT_SAFE_UPLOAD] = true;
190✔
430

431
        $curlOptions = $this->setCURLOptions($curlOptions, $config);
190✔
432
        $curlOptions = $this->applyMethod($method, $curlOptions);
186✔
433
        $curlOptions = $this->applyRequestHeaders($curlOptions);
186✔
434

435
        if ($retry !== null) {
186✔
436
            $curlOptions[CURLOPT_FAILONERROR] = false;
15✔
437
        }
438

439
        // Do we need to delay this request?
440
        if ($this->delay > 0) {
186✔
441
            $this->sleep($this->delay);
24✔
442
        }
443

444
        if ($retry === null) {
186✔
445
            return $this->sendAttempt($curlOptions);
171✔
446
        }
447

448
        $httpErrors = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
15✔
449

450
        return $this->sendWithRetries($curlOptions, $retry, $httpErrors);
15✔
451
    }
452

453
    /**
454
     * Sends the request until it succeeds or retry attempts are exhausted.
455
     *
456
     * @param array<int, mixed>                 $curlOptions
457
     * @param array<string, bool|int|list<int>> $retry
458
     */
459
    protected function sendWithRetries(array $curlOptions, array $retry, bool $httpErrors): ResponseInterface
460
    {
461
        $attempt = 0;
15✔
462

463
        while (true) {
15✔
464
            $this->response = clone $this->responseOrig;
15✔
465

466
            try {
467
                $response = $this->sendAttempt($curlOptions);
15✔
468
            } catch (HTTPException $e) {
3✔
469
                if (! $this->shouldRetryCurlError($retry, $attempt)) {
3✔
470
                    throw $e;
2✔
471
                }
472

473
                $this->sleep($this->getRetryDelay($retry, $attempt) / 1000);
1✔
474
                $attempt++;
1✔
475

476
                continue;
1✔
477
            }
478

479
            if (! $this->shouldRetryResponse($response, $retry, $attempt)) {
13✔
480
                if ($httpErrors && $response->getStatusCode() >= 400) {
13✔
481
                    throw HTTPException::forCurlError('22', 'The requested URL returned error: ' . $response->getStatusCode());
1✔
482
                }
483

484
                return $response;
12✔
485
            }
486

487
            $this->sleep($this->getRetryDelay($retry, $attempt, $response) / 1000);
11✔
488
            $attempt++;
11✔
489
        }
490
    }
491

492
    /**
493
     * Sends a single cURL request attempt and populates the response.
494
     *
495
     * @param array<int, mixed> $curlOptions
496
     */
497
    protected function sendAttempt(array $curlOptions): ResponseInterface
498
    {
499
        $this->lastCurlError = 0;
186✔
500

501
        $output = $this->sendRequest($curlOptions);
186✔
502

503
        // Set the string we want to break our response from
504
        $breakString = "\r\n\r\n";
184✔
505

506
        // Remove all intermediate responses
507
        $output = $this->removeIntermediateResponses($output, $breakString);
184✔
508

509
        // Split out our headers and body
510
        $break = strpos($output, $breakString);
184✔
511

512
        if ($break !== false) {
184✔
513
            // Our headers
514
            $headers = explode("\n", substr($output, 0, $break));
44✔
515

516
            $this->setResponseHeaders($headers);
44✔
517

518
            // Our body
519
            $body = substr($output, $break + 4);
44✔
520
            $this->response->setBody($body);
44✔
521
        } else {
522
            $this->response->setBody($output);
140✔
523
        }
524

525
        return $this->response;
184✔
526
    }
527

528
    /**
529
     * Normalizes the retry option into retry settings.
530
     *
531
     * @return array<string, bool|int|list<int>>|null
532
     */
533
    protected function normalizeRetryOption(mixed $retry): ?array
534
    {
535
        if (in_array($retry, [false, null, 0], true)) {
190✔
536
            return null;
174✔
537
        }
538

539
        $config = $this->retryDefaults;
16✔
540

541
        if (is_int($retry)) {
16✔
542
            $config['max_retries'] = $retry;
5✔
543
        } elseif (is_array($retry)) {
11✔
544
            $config = array_merge($config, $retry);
11✔
545
        } else {
NEW
546
            return null;
×
547
        }
548

549
        $config['max_retries'] = max(0, (int) $config['max_retries']);
16✔
550

551
        if ($config['max_retries'] === 0) {
16✔
552
            return null;
1✔
553
        }
554

555
        $config['delay']               = $this->normalizeRetryDelay($config['delay']);
15✔
556
        $config['max_delay']           = max(0, (int) $config['max_delay']);
15✔
557
        $config['status_codes']        = array_map(intval(...), (array) $config['status_codes']);
15✔
558
        $config['curl_errors']         = (bool) $config['curl_errors'];
15✔
559
        $config['respect_retry_after'] = (bool) $config['respect_retry_after'];
15✔
560

561
        return $config;
15✔
562
    }
563

564
    /**
565
     * Normalizes the retry delay setting.
566
     *
567
     * @return int|list<int>
568
     */
569
    protected function normalizeRetryDelay(mixed $delay): array|int
570
    {
571
        if (is_array($delay)) {
15✔
572
            return array_map(static fn ($value): int => max(0, (int) $value), $delay);
1✔
573
        }
574

575
        return max(0, (int) $delay);
14✔
576
    }
577

578
    /**
579
     * Determines whether a response should be retried.
580
     *
581
     * @param array<string, bool|int|list<int>> $retry
582
     */
583
    protected function shouldRetryResponse(ResponseInterface $response, array $retry, int $attempt): bool
584
    {
585
        if ($attempt >= $retry['max_retries']) {
13✔
586
            return false;
11✔
587
        }
588

589
        return in_array($response->getStatusCode(), $retry['status_codes'], true);
12✔
590
    }
591

592
    /**
593
     * Determines whether a cURL error should be retried.
594
     *
595
     * @param array<string, bool|int|list<int>> $retry
596
     */
597
    protected function shouldRetryCurlError(array $retry, int $attempt): bool
598
    {
599
        if ($attempt >= $retry['max_retries'] || $retry['curl_errors'] === false) {
3✔
600
            return false;
1✔
601
        }
602

603
        return in_array($this->lastCurlError, $this->transientCurlErrors, true);
2✔
604
    }
605

606
    /**
607
     * Returns the delay before the next retry attempt.
608
     *
609
     * @param array<string, bool|int|list<int>> $retry
610
     */
611
    protected function getRetryDelay(array $retry, int $attempt, ?ResponseInterface $response = null): int
612
    {
613
        if ($response instanceof ResponseInterface && $retry['respect_retry_after'] === true) {
12✔
614
            $retryAfter = $this->getRetryAfterDelay($response);
10✔
615

616
            if ($retryAfter !== null) {
10✔
617
                return $this->limitRetryDelay($retryAfter * 1000, $retry);
3✔
618
            }
619
        }
620

621
        $delay = $retry['delay'];
9✔
622

623
        if (is_array($delay)) {
9✔
624
            $lastDelay = end($delay);
1✔
625

626
            return $this->limitRetryDelay((int) ($delay[$attempt] ?? ($lastDelay !== false ? $lastDelay : 0)), $retry);
1✔
627
        }
628

629
        return $this->limitRetryDelay((int) $delay, $retry);
8✔
630
    }
631

632
    /**
633
     * Caps the retry delay when configured.
634
     *
635
     * @param array<string, bool|int|list<int>> $retry
636
     */
637
    protected function limitRetryDelay(int $delay, array $retry): int
638
    {
639
        $maxDelay = (int) $retry['max_delay'];
12✔
640

641
        if ($maxDelay === 0) {
12✔
NEW
642
            return $delay;
×
643
        }
644

645
        return min($delay, $maxDelay);
12✔
646
    }
647

648
    /**
649
     * Returns the delay from a Retry-After header in seconds.
650
     */
651
    protected function getRetryAfterDelay(ResponseInterface $response): ?int
652
    {
653
        $retryAfter = $response->getHeaderLine('Retry-After');
10✔
654

655
        if ($retryAfter === '') {
10✔
656
            return null;
7✔
657
        }
658

659
        if (ctype_digit($retryAfter)) {
3✔
660
            return (int) $retryAfter;
2✔
661
        }
662

663
        $timestamp = strtotime($retryAfter);
1✔
664

665
        if ($timestamp === false) {
1✔
NEW
666
            return null;
×
667
        }
668

669
        return max(0, $timestamp - time());
1✔
670
    }
671

672
    /**
673
     * Sleeps for the configured number of seconds.
674
     */
675
    protected function sleep(float $seconds): void
676
    {
NEW
677
        usleep((int) ($seconds * 1_000_000));
×
678
    }
679

680
    /**
681
     * Adds $this->headers to the cURL request.
682
     */
683
    protected function applyRequestHeaders(array $curlOptions = []): array
684
    {
685
        if (empty($this->headers)) {
186✔
686
            return $curlOptions;
95✔
687
        }
688

689
        $set = [];
94✔
690

691
        foreach (array_keys($this->headers) as $name) {
94✔
692
            $set[] = $name . ': ' . $this->getHeaderLine($name);
94✔
693
        }
694

695
        $curlOptions[CURLOPT_HTTPHEADER] = $set;
94✔
696

697
        return $curlOptions;
94✔
698
    }
699

700
    /**
701
     * Apply method
702
     */
703
    protected function applyMethod(string $method, array $curlOptions): array
704
    {
705
        $this->method                       = $method;
186✔
706
        $curlOptions[CURLOPT_CUSTOMREQUEST] = $method;
186✔
707

708
        $size = strlen($this->body ?? '');
186✔
709

710
        // Have content?
711
        if ($size > 0) {
186✔
712
            return $this->applyBody($curlOptions);
10✔
713
        }
714

715
        if ($method === Method::PUT || $method === Method::POST) {
177✔
716
            // See http://tools.ietf.org/html/rfc7230#section-3.3.2
717
            if ($this->header('content-length') === null && ! isset($this->config['multipart'])) {
55✔
718
                $this->setHeader('Content-Length', '0');
43✔
719
            }
720
        } elseif ($method === 'HEAD') {
126✔
721
            $curlOptions[CURLOPT_NOBODY] = 1;
2✔
722
        }
723

724
        return $curlOptions;
177✔
725
    }
726

727
    /**
728
     * Apply body
729
     */
730
    protected function applyBody(array $curlOptions = []): array
731
    {
732
        if (! empty($this->body)) {
10✔
733
            $curlOptions[CURLOPT_POSTFIELDS] = (string) $this->getBody();
10✔
734
        }
735

736
        return $curlOptions;
10✔
737
    }
738

739
    /**
740
     * Parses the header retrieved from the cURL response into
741
     * our Response object.
742
     *
743
     * @return void
744
     */
745
    protected function setResponseHeaders(array $headers = [])
746
    {
747
        foreach ($headers as $header) {
44✔
748
            if (($pos = strpos($header, ':')) !== false) {
44✔
749
                $title = trim(substr($header, 0, $pos));
30✔
750
                $value = trim(substr($header, $pos + 1));
30✔
751

752
                if ($this->response instanceof Response) {
30✔
753
                    $this->response->addHeader($title, $value);
30✔
754
                } else {
UNCOV
755
                    $this->response->setHeader($title, $value);
×
756
                }
757
            } elseif (str_starts_with($header, 'HTTP')) {
42✔
758
                preg_match('#^HTTP\/([12](?:\.[01])?) (\d+)(?: (.+))?#', $header, $matches);
42✔
759

760
                if (isset($matches[1])) {
42✔
761
                    $this->response->setProtocolVersion($matches[1]);
42✔
762
                }
763

764
                if (isset($matches[2])) {
42✔
765
                    $this->response->setStatusCode((int) $matches[2], $matches[3] ?? '');
42✔
766
                }
767
            }
768
        }
769
    }
770

771
    /**
772
     * Set CURL options
773
     *
774
     * @return array
775
     *
776
     * @throws InvalidArgumentException
777
     */
778
    protected function setCURLOptions(array $curlOptions = [], array $config = [])
779
    {
780
        // Auth Headers
781
        if (! empty($config['auth'])) {
190✔
782
            $curlOptions[CURLOPT_USERPWD] = $config['auth'][0] . ':' . $config['auth'][1];
10✔
783

784
            if (! empty($config['auth'][2]) && strtolower($config['auth'][2]) === 'digest') {
10✔
785
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_DIGEST;
4✔
786
            } else {
787
                $curlOptions[CURLOPT_HTTPAUTH] = CURLAUTH_BASIC;
6✔
788
            }
789
        }
790

791
        // Certificate
792
        if (! empty($config['cert'])) {
190✔
793
            $cert = $config['cert'];
6✔
794

795
            if (is_array($cert)) {
6✔
796
                $curlOptions[CURLOPT_SSLCERTPASSWD] = $cert[1];
2✔
797
                $cert                               = $cert[0];
2✔
798
            }
799

800
            if (! is_file($cert)) {
6✔
801
                throw HTTPException::forSSLCertNotFound($cert);
2✔
802
            }
803

804
            $curlOptions[CURLOPT_SSLCERT] = $cert;
4✔
805
        }
806

807
        // SSL Verification
808
        if (isset($config['verify'])) {
188✔
809
            if (is_string($config['verify'])) {
188✔
810
                $file = realpath($config['verify']) ?: $config['verify'];
4✔
811

812
                if (! is_file($file)) {
4✔
813
                    throw HTTPException::forInvalidSSLKey($config['verify']);
2✔
814
                }
815

816
                $curlOptions[CURLOPT_CAINFO]         = $file;
2✔
817
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = true;
2✔
818
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = 2;
2✔
819
            } elseif (is_bool($config['verify'])) {
184✔
820
                $curlOptions[CURLOPT_SSL_VERIFYPEER] = $config['verify'];
184✔
821
                $curlOptions[CURLOPT_SSL_VERIFYHOST] = $config['verify'] ? 2 : 0;
184✔
822
            }
823
        }
824

825
        // Proxy
826
        if (isset($config['proxy'])) {
186✔
827
            $curlOptions[CURLOPT_HTTPPROXYTUNNEL] = true;
2✔
828
            $curlOptions[CURLOPT_PROXY]           = $config['proxy'];
2✔
829
        }
830

831
        // Debug
832
        if ($config['debug']) {
186✔
833
            $curlOptions[CURLOPT_VERBOSE] = 1;
4✔
834
            $curlOptions[CURLOPT_STDERR]  = is_string($config['debug']) ? fopen($config['debug'], 'a+b') : fopen('php://stderr', 'wb');
4✔
835
        }
836

837
        // Decode Content
838
        if (! empty($config['decode_content'])) {
186✔
839
            $accept = $this->getHeaderLine('Accept-Encoding');
4✔
840

841
            if ($accept !== '') {
4✔
842
                $curlOptions[CURLOPT_ENCODING] = $accept;
2✔
843
            } else {
844
                $curlOptions[CURLOPT_ENCODING]   = '';
2✔
845
                $curlOptions[CURLOPT_HTTPHEADER] = 'Accept-Encoding';
2✔
846
            }
847
        }
848

849
        // Allow Redirects
850
        if (array_key_exists('allow_redirects', $config)) {
186✔
851
            $settings = $this->redirectDefaults;
14✔
852

853
            if (is_array($config['allow_redirects'])) {
14✔
854
                $settings = array_merge($settings, $config['allow_redirects']);
2✔
855
            }
856

857
            if ($config['allow_redirects'] === false) {
14✔
858
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 0;
4✔
859
            } else {
860
                $curlOptions[CURLOPT_FOLLOWLOCATION] = 1;
10✔
861
                $curlOptions[CURLOPT_MAXREDIRS]      = $settings['max'];
10✔
862

863
                if ($settings['strict'] === true) {
10✔
864
                    $curlOptions[CURLOPT_POSTREDIR] = 1 | 2 | 4;
10✔
865
                }
866

867
                $protocols = 0;
10✔
868

869
                foreach ($settings['protocols'] as $proto) {
10✔
870
                    $protocols += constant('CURLPROTO_' . strtoupper($proto));
10✔
871
                }
872

873
                $curlOptions[CURLOPT_REDIR_PROTOCOLS] = $protocols;
10✔
874
            }
875
        }
876

877
        // DNS Cache Timeout
878
        if (isset($config['dns_cache_timeout']) && is_numeric($config['dns_cache_timeout']) && $config['dns_cache_timeout'] >= -1) {
186✔
879
            $curlOptions[CURLOPT_DNS_CACHE_TIMEOUT] = (int) $config['dns_cache_timeout'];
12✔
880
        }
881

882
        // Fresh Connect (default true)
883
        $curlOptions[CURLOPT_FRESH_CONNECT] = isset($config['fresh_connect']) && is_bool($config['fresh_connect'])
186✔
884
            ? $config['fresh_connect']
2✔
885
            : true;
184✔
886

887
        // Timeout
888
        $curlOptions[CURLOPT_TIMEOUT_MS] = (float) $config['timeout'] * 1000;
186✔
889

890
        // Connection Timeout
891
        $curlOptions[CURLOPT_CONNECTTIMEOUT_MS] = (float) $config['connect_timeout'] * 1000;
186✔
892

893
        // Post Data - application/x-www-form-urlencoded
894
        if (! empty($config['form_params']) && is_array($config['form_params'])) {
186✔
895
            $postFields                      = http_build_query($config['form_params']);
10✔
896
            $curlOptions[CURLOPT_POSTFIELDS] = $postFields;
10✔
897

898
            // Ensure content-length is set, since CURL doesn't seem to
899
            // calculate it when HTTPHEADER is set.
900
            $this->setHeader('Content-Length', (string) strlen($postFields));
10✔
901
            $this->setHeader('Content-Type', 'application/x-www-form-urlencoded');
10✔
902
        }
903

904
        // Post Data - multipart/form-data
905
        if (! empty($config['multipart']) && is_array($config['multipart'])) {
186✔
906
            // setting the POSTFIELDS option automatically sets multipart
907
            $curlOptions[CURLOPT_POSTFIELDS] = $config['multipart'];
4✔
908
        }
909

910
        // HTTP Errors
911
        $curlOptions[CURLOPT_FAILONERROR] = array_key_exists('http_errors', $config) ? (bool) $config['http_errors'] : true;
186✔
912

913
        // JSON
914
        if (isset($config['json'])) {
186✔
915
            // Will be set as the body in `applyBody()`
916
            $json = json_encode($config['json']);
4✔
917
            $this->setBody($json);
4✔
918
            $this->setHeader('Content-Type', 'application/json');
4✔
919
            $this->setHeader('Content-Length', (string) strlen($json));
4✔
920
        }
921

922
        // Resolve IP
923
        if (array_key_exists('force_ip_resolve', $config)) {
186✔
924
            $curlOptions[CURLOPT_IPRESOLVE] = match ($config['force_ip_resolve']) {
6✔
925
                'v4'    => CURL_IPRESOLVE_V4,
2✔
926
                'v6'    => CURL_IPRESOLVE_V6,
2✔
927
                default => CURL_IPRESOLVE_WHATEVER,
2✔
928
            };
6✔
929
        }
930

931
        // version
932
        if (! empty($config['version'])) {
186✔
933
            $version = sprintf('%.1F', $config['version']);
10✔
934
            if ($version === '1.0') {
10✔
935
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_0;
4✔
936
            } elseif ($version === '1.1') {
8✔
937
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_1_1;
4✔
938
            } elseif ($version === '2.0') {
6✔
939
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_2_0;
4✔
940
            } elseif ($version === '3.0') {
2✔
941
                if (! defined('CURL_HTTP_VERSION_3')) {
2✔
942
                    define('CURL_HTTP_VERSION_3', 30);
1✔
943
                }
944

945
                $curlOptions[CURLOPT_HTTP_VERSION] = CURL_HTTP_VERSION_3;
2✔
946
            }
947
        }
948

949
        // Cookie
950
        if (isset($config['cookie'])) {
186✔
951
            $curlOptions[CURLOPT_COOKIEJAR]  = $config['cookie'];
2✔
952
            $curlOptions[CURLOPT_COOKIEFILE] = $config['cookie'];
2✔
953
        }
954

955
        // User Agent
956
        if (isset($config['user_agent'])) {
186✔
957
            $curlOptions[CURLOPT_USERAGENT] = $config['user_agent'];
4✔
958
        }
959

960
        return $curlOptions;
186✔
961
    }
962

963
    /**
964
     * Does the actual work of initializing cURL, setting the options,
965
     * and grabbing the output.
966
     *
967
     * @param array<int, mixed> $curlOptions
968
     *
969
     * @codeCoverageIgnore
970
     */
971
    protected function sendRequest(array $curlOptions = []): string
972
    {
973
        $ch = curl_init();
974

975
        curl_setopt_array($ch, $curlOptions);
976

977
        // Send the request and wait for a response.
978
        $output = curl_exec($ch);
979

980
        if ($output === false) {
981
            $this->lastCurlError = curl_errno($ch);
982

983
            throw HTTPException::forCurlError((string) $this->lastCurlError, curl_error($ch));
984
        }
985

986
        return $output;
987
    }
988

989
    private function removeIntermediateResponses(string $output, string $breakString): string
990
    {
991
        while (true) {
184✔
992
            // Check if we should remove the current response
993
            if ($this->shouldRemoveCurrentResponse($output, $breakString)) {
184✔
994
                $breakStringPos = strpos($output, $breakString);
18✔
995
                if ($breakStringPos !== false) {
18✔
996
                    $output = substr($output, $breakStringPos + 4);
18✔
997

998
                    continue;
18✔
999
                }
1000
            }
1001

1002
            // No more intermediate responses to remove
1003
            break;
184✔
1004
        }
1005

1006
        return $output;
184✔
1007
    }
1008

1009
    /**
1010
     * Check if the current response (at the beginning of output) should be removed.
1011
     */
1012
    private function shouldRemoveCurrentResponse(string $output, string $breakString): bool
1013
    {
1014
        // HTTP/x.x 1xx responses (Continue, Processing, etc.)
1015
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+1\d\d\s/', $output)) {
184✔
1016
            return true;
8✔
1017
        }
1018

1019
        // HTTP/x.x 200 Connection established (proxy responses)
1020
        if (preg_match('/^HTTP\/\d+(?:\.\d+)?\s+200\s+Connection\s+established/i', $output)) {
184✔
1021
            return true;
6✔
1022
        }
1023

1024
        // HTTP/x.x 3xx responses (redirects) - only if redirects are allowed
1025
        $allowRedirects = isset($this->config['allow_redirects']) && $this->config['allow_redirects'] !== false;
184✔
1026
        if ($allowRedirects && preg_match('/^HTTP\/\d+(?:\.\d+)?\s+3\d\d\s/', $output)) {
184✔
1027
            // Check if there's a Location header
1028
            $breakStringPos = strpos($output, $breakString);
4✔
1029
            if ($breakStringPos !== false) {
4✔
1030
                $headerSection = substr($output, 0, $breakStringPos);
4✔
1031
                $headers       = explode("\n", $headerSection);
4✔
1032

1033
                foreach ($headers as $header) {
4✔
1034
                    if (str_starts_with(strtolower($header), 'location:')) {
4✔
1035
                        return true; // Found location header, this is a redirect to remove
2✔
1036
                    }
1037
                }
1038
            }
1039
        }
1040

1041
        // Digest auth challenges - only remove if there's another response after
1042
        if (isset($this->config['auth'][2]) && $this->config['auth'][2] === 'digest') {
184✔
1043
            $breakStringPos = strpos($output, $breakString);
4✔
1044
            if ($breakStringPos !== false) {
4✔
1045
                $headerSection = substr($output, 0, $breakStringPos);
4✔
1046
                if (str_contains($headerSection, 'WWW-Authenticate: Digest')) {
4✔
1047
                    $nextBreakPos = strpos($output, $breakString, $breakStringPos + 4);
4✔
1048

1049
                    return $nextBreakPos !== false; // Only remove if there's another response
4✔
1050
                }
1051
            }
1052
        }
1053

1054
        return false;
184✔
1055
    }
1056
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc