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

dg / ai-access / 31344455491

08 Aug 2026 11:19AM UTC coverage: 92.031% (-4.6%) from 96.643%
31344455491

push

github

dg
curl: connection kept alive between requests, headers matched case-insensitively

A tool loop is a burst of requests to one host, and a fresh TCP+TLS handshake
for each was the cheapest latency there was to win: the handle now survives in
the client and is curl_reset for the next request. Taking it out of the property
before use means two transfers can never share it - even when a fiber suspends
a stream mid-flight and other code fires a request meanwhile, that request
simply builds a fresh handle.

Header names are case-insensitive on the wire, so the defaults no longer
duplicate a header the caller spelled differently ('Content-Type' next to
content-type: application/json used to reach the server as two headers).

8 of 12 new or added lines in 1 file covered. (66.67%)

92 existing lines in 16 files now uncovered.

1998 of 2171 relevant lines covered (92.03%)

0.92 hits per line

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

95.33
/src/Http/CurlClient.php
1
<?php declare(strict_types=1);
2

3
/**
4
 * This file is part of the AI Access library.
5
 * Copyright (c) 2024 David Grudl (https://davidgrudl.com)
6
 */
7

8
namespace AIAccess\Http;
9

10
use AIAccess\CommunicationException;
11
use AIAccess\Helpers;
12
use function defined, is_array, is_string, strlen;
13

14

15
/**
16
 * cURL-based implementation of the HTTP Client interface.
17
 */
18
final class CurlClient implements Client
19
{
20
        private string $userAgent = 'ai-access-php';
21

22
        /** Default timeouts in seconds */
23
        private int $connectTimeout = 10;
24
        private int $requestTimeout = 180;
25
        private ?string $proxy = null;
26

27
        /** kept between requests so the TLS connection survives a tool loop */
28
        private ?\CurlHandle $handle = null;
29

30

31
        public function setOptions(
1✔
32
                ?int $connectTimeout = null,
33
                ?int $requestTimeout = null,
34
                ?string $proxy = null,
35
        ): static
36
        {
37
                if ($connectTimeout !== null) {
1✔
38
                        $this->connectTimeout = $connectTimeout;
1✔
39
                }
40
                if ($requestTimeout !== null) {
1✔
41
                        $this->requestTimeout = $requestTimeout;
1✔
42
                }
43
                if ($proxy !== null) {
1✔
44
                        $this->proxy = $proxy;
1✔
45
                }
46
                return $this;
1✔
47
        }
48

49

50
        public function fetch(
1✔
51
                string $url,
52
                string|array|FormData|null $payload = null,
53
                array $headers = [],
54
                ?string $method = null,
55
                ?\Closure $onChunk = null,
56
        ): Response
57
        {
58
                $ch = $this->create($payload, $headers, $url, $method);
1✔
59
                try {
60
                        return $onChunk === null
1✔
61
                                ? $this->execute($ch)
1✔
62
                                : $this->executeStream($ch, $onChunk);
1✔
NEW
63
                } finally {
×
64
                        $this->handle = $ch;
1✔
65
                }
66
        }
67

68

69
        /** @param \Closure(string): (bool|null) $onChunk */
70
        private function executeStream(\CurlHandle $ch, \Closure $onChunk): Response
1✔
71
        {
72
                $rawHeaders = $errorBody = '';
1✔
73
                $stopped = false;
1✔
74

75
                // a long answer legitimately streams for minutes, so the question is not how long
76
                // the whole thing takes but whether it has stalled
77
                curl_setopt($ch, CURLOPT_TIMEOUT, 0);
1✔
78
                curl_setopt($ch, CURLOPT_LOW_SPEED_LIMIT, 1);
1✔
79
                curl_setopt($ch, CURLOPT_LOW_SPEED_TIME, max($this->connectTimeout, $this->requestTimeout));
1✔
80

81
                curl_setopt($ch, CURLOPT_HEADER, false);
1✔
82
                curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, string $line) use (&$rawHeaders): int {
1✔
83
                        $rawHeaders .= $line;
1✔
84
                        return strlen($line);
1✔
85
                });
1✔
86
                curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use ($onChunk, &$errorBody, &$stopped): int {
1✔
87
                        // anything but success is not a stream: an error is a JSON body the caller needs
88
                        // whole, and a redirect body (redirects are not followed) is no answer at all
89
                        if (curl_getinfo($ch, CURLINFO_RESPONSE_CODE) >= 300) {
1✔
90
                                $errorBody .= $chunk;
1✔
91
                        } elseif ($onChunk($chunk) === false) {
1✔
92
                                $stopped = true;
1✔
93
                                return 0; // aborts the transfer
1✔
94
                        }
95
                        return strlen($chunk);
1✔
96
                });
1✔
97

98
                if (curl_exec($ch) === false && !$stopped) {
1✔
99
                        $errorNo = curl_errno($ch);
1✔
100
                        throw new CommunicationException('cURL request failed: [' . $errorNo . '] ' . curl_error($ch), $errorNo);
1✔
101
                }
102

103
                $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1✔
104
                if ($errorBody !== '') {
1✔
105
                        // a streaming endpoint keeps announcing text/event-stream even when it answers with
106
                        // a JSON error, so the content type says nothing here and the body is tried as JSON
107
                        $decoded = json_decode($errorBody, true);
1✔
108
                        $errorBody = is_array($decoded) ? $decoded : $errorBody;
1✔
109
                }
110

111
                return new Response($httpCode, $this->parseHeaders($rawHeaders), $errorBody === '' ? null : $errorBody);
1✔
112
        }
113

114

115
        /**
116
         * @param string|mixed[]|FormData|null $payload
117
         * @param string[] $headers
118
         * @param non-empty-string|null $method
119
         */
120
        private function create(
1✔
121
                array|string|FormData|null $payload,
122
                array $headers,
123
                string $url,
124
                ?string $method,
125
        ): \CurlHandle
126
        {
127
                // the handle is taken and the property cleared, so two transfers can never share
128
                // one handle - even when a fiber suspends a stream mid-flight and other code
129
                // fires a request meanwhile, that request simply builds a fresh handle
130
                if ($this->handle !== null) {
1✔
NEW
131
                        $ch = $this->handle;
×
NEW
132
                        $this->handle = null;
×
NEW
133
                        curl_reset($ch);
×
134
                } else {
135
                        $ch = curl_init();
1✔
136
                }
137
                assert($url !== '');
138
                curl_setopt($ch, CURLOPT_URL, $url);
1✔
139
                curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method ?? ($payload === null ? 'GET' : 'POST'));
1✔
140
                curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
1✔
141
                curl_setopt($ch, CURLOPT_HEADER, true);
1✔
142
                curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->connectTimeout);
1✔
143
                curl_setopt($ch, CURLOPT_TIMEOUT, max($this->connectTimeout, $this->requestTimeout));
1✔
144
                curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
1✔
145
                curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
1✔
146
                curl_setopt($ch, CURLOPT_ENCODING, '');
1✔
147
                if (defined('CURLOPT_PROTOCOLS_STR')) {
1✔
148
                        curl_setopt($ch, CURLOPT_PROTOCOLS_STR, 'http,https');
1✔
149
                }
150

151
                // header names are case-insensitive on the wire, so the defaults below must not
152
                // duplicate a header the caller spelled differently
153
                $headers = array_change_key_case($headers);
1✔
154

155
                if ($payload instanceof FormData) {
1✔
156
                        $tmp = [];
1✔
157
                        foreach ($payload->getItems() as $field => $info) {
1✔
158
                                if (isset($info['value'])) {
1✔
159
                                        $tmp[$field] = $info['value'];
1✔
160
                                } elseif (isset($info['content'])) {
1✔
161
                                        $tmp[$field] = new \CURLStringFile($info['content'], $info['name'], $info['mime'] ?? 'application/octet-stream');
1✔
162
                                } else {
163
                                        $tmp[$field] = new \CURLFile($info['path'], $info['mime'] ?? 'application/octet-stream', $info['name']);
1✔
164
                                }
165
                        }
166
                        curl_setopt($ch, CURLOPT_POSTFIELDS, $tmp);
1✔
167

168
                } elseif (is_array($payload)) {
1✔
169
                        $headers['content-type'] = 'application/json';
1✔
170
                        $headers['accept'] = 'application/json';
1✔
171
                        curl_setopt($ch, CURLOPT_POSTFIELDS, Helpers::encodeJson($payload));
1✔
172

173
                } elseif (is_string($payload)) {
1✔
174
                        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
1✔
175
                }
176

177
                $headers += ['user-agent' => $this->userAgent];
1✔
178
                $tmp = array_map(fn($k, $v) => "$k: $v", array_keys($headers), array_values($headers));
1✔
179
                curl_setopt($ch, CURLOPT_HTTPHEADER, $tmp);
1✔
180

181
                $proxy = $this->proxy;
1✔
182
                if ($proxy !== null && $proxy !== '') {
1✔
183
                        curl_setopt($ch, CURLOPT_PROXY, $proxy);
1✔
184
                }
185

186
                return $ch;
1✔
187
        }
188

189

190
        private function execute(\CurlHandle $ch): Response
1✔
191
        {
192
                $response = curl_exec($ch);
1✔
193
                if ($response === false) {
1✔
194
                        $errorNo = curl_errno($ch);
1✔
195
                        throw new CommunicationException('cURL request failed: [' . $errorNo . '] ' . curl_error($ch), $errorNo);
1✔
196
                }
197

198
                assert(is_string($response));
199
                $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
1✔
200
                $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
1✔
201
                $headers = substr($response, 0, $headerSize);
1✔
202
                $data = substr($response, $headerSize);
1✔
203
                $contentType = curl_getinfo($ch, CURLINFO_CONTENT_TYPE) ?: '';
1✔
204
                if ($data !== '' && preg_match('~^application/json|\+json~', $contentType)) {
1✔
205
                        $data = Helpers::decodeJson($data);
1✔
206
                }
207

208
                return new Response($httpCode, $this->parseHeaders($headers), $data);
1✔
209
        }
210

211

212
        /**
213
         * Parses raw HTTP headers into an associative array.
214
         * Normalizes header names to lowercase.
215
         * @return array<string, string[]>
216
         */
217
        private function parseHeaders(string $rawHeaders): array
1✔
218
        {
219
                $headers = [];
1✔
220
                $lines = explode("\r\n", trim($rawHeaders));
1✔
221
                array_shift($lines); // Skip the first line (HTTP status line)
1✔
222

223
                foreach ($lines as $line) {
1✔
224
                        if (!str_contains($line, ':')) {
1✔
UNCOV
225
                                continue;
×
226
                        }
227

228
                        [$name, $value] = explode(':', $line, 2);
1✔
229
                        $name = strtolower(trim($name));
1✔
230
                        $headers[$name][] = trim($value);
1✔
231
                }
232

233
                return $headers;
1✔
234
        }
235
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc