4 * Class to handle concurrent HTTP requests
6 * HTTP request maps use the following format:
7 * - method : GET/HEAD/PUT/POST/DELETE
8 * - url : HTTP/HTTPS URL
9 * - query : <query parameter field/value associative array>
10 * - headers : <header name/value associative array>
11 * - body : source to get the HTTP request body from;
12 * this can simply be a string (always), a resource for
13 * PUT requests, and a field/value array for POST request
14 * - stream : resource to stream the HTTP response body to
16 * @author Aaron Schulz
19 class MultiHttpClient
{
21 protected $multiHandle = null; // curl_multi handle
22 /** @var string|null SSL certificates path */
23 protected $caBundlePath;
25 protected $connTimeout;
27 protected $reqTimeout;
30 * @param array $options
32 public function __construct( array $options ) {
33 if ( isset( $options['caBundlePath'] ) ) {
34 $this->caBundlePath
= $options['caBundlePath'];
35 if ( !file_exists( $this->caBundlePath
) ) {
36 throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath
);
39 static $defaults = array( 'connTimeout' => 10, 'reqTimeout' => 300 );
40 foreach ( $defaults as $key => $default ) {
41 $this->$key = isset( $options[$key] ) ?
$options[$key] : $default;
46 * Execute an HTTP(S) request
48 * This method returns a response map of:
49 * - code : HTTP response code or 0 if there was a serious cURL error
50 * - reason : HTTP response reason (empty if there was a serious cURL error)
51 * - headers : <header name/value associative array>
52 * - body : HTTP response body or resource (if "stream" was set)
53 * - err : Any cURL error string
54 * The map also stores integer-indexed copies of these values. This lets callers do:
56 * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req;
58 * @param array $req HTTP request array
59 * @return array Response array for request
61 public function run( array $req ) {
62 $req = $this->runMulti( array( $req ) );
63 return $req[0]['response'];
67 * Execute a set of HTTP(S) request concurrently
69 * The maps are returned by this method with the 'response' field set to a map of:
70 * - code : HTTP response code or 0 if there was a serious cURL error
71 * - reason : HTTP response reason (empty if there was a serious cURL error)
72 * - headers : <header name/value associative array>
73 * - body : HTTP response body or resource (if "stream" was set)
74 * - err : Any cURL error string
75 * The map also stores integer-indexed copies of these values. This lets callers do:
77 * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req;
79 * All headers in the 'headers' field are normalized to use lower case names.
80 * This is true for the request headers and the response headers.
82 * @param array $req Map of HTTP request arrays
83 * @return array $reqs With response array populated for each
85 public function runMulti( array $reqs ) {
86 $multiHandle = $this->getCurlMulti();
88 // Normalize $reqs and add all of the required cURL handles...
90 foreach ( $reqs as $index => &$req ) {
91 $req['response'] = array(
98 if ( !isset( $req['method'] ) ) {
99 throw new Exception( "Request has no 'method' field set." );
100 } elseif ( !isset( $req['url'] ) ) {
101 throw new Exception( "Request has no 'url' field set." );
103 $req['query'] = isset( $req['query'] ) ?
$req['query'] : array();
104 $headers = array(); // normalized headers
105 if ( isset( $req['headers'] ) ) {
106 foreach ( $req['headers'] as $name => $value ) {
107 $headers[strtolower( $name )] = $value;
110 $req['headers'] = $headers;
111 if ( !isset( $req['body'] ) ) {
113 $req['headers']['content-length'] = 0;
115 $handles[$index] = $this->getCurlHandle( $req );
116 if ( count( $reqs ) > 1 ) {
117 // https://github.com/guzzle/guzzle/issues/349
118 curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE
, true );
120 curl_multi_add_handle( $multiHandle, $handles[$index] );
123 // Execute the cURL handles concurrently...
124 $active = null; // handles still being processed
126 // Do any available work...
128 $mrc = curl_multi_exec( $multiHandle, $active );
129 } while ( $mrc == CURLM_CALL_MULTI_PERFORM
);
130 // Wait (if possible) for available work...
131 if ( $active > 0 && $mrc == CURLM_OK
) {
132 if ( curl_multi_select( $multiHandle, 10 ) == -1 ) {
133 // PHP bug 63411; http://curl.haxx.se/libcurl/c/curl_multi_fdset.html
134 usleep( 5000 ); // 5ms
137 } while ( $active > 0 && $mrc == CURLM_OK
);
139 // Remove all of the added cURL handles and check for errors...
140 foreach ( $reqs as $index => &$req ) {
141 $ch = $handles[$index];
142 curl_multi_remove_handle( $multiHandle, $ch );
143 if ( curl_errno( $ch ) !== 0 ) {
144 $req['error'] = "(curl error: " . curl_errno( $ch ) . ") " . curl_error( $ch );
146 // For convenience with the list() operator
147 $req['response'][0] = $req['response']['code'];
148 $req['response'][1] = $req['response']['reason'];
149 $req['response'][2] = $req['response']['headers'];
150 $req['response'][3] = $req['response']['body'];
151 $req['response'][4] = $req['response']['error'];
153 // Close any string wrapper file handles
154 if ( isset( $req['_closeHandle'] ) ) {
155 fclose( $req['_closeHandle'] );
156 unset( $req['_closeHandle'] );
164 * @param array $req HTTP request map
167 protected function getCurlHandle( array &$req ) {
170 curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT
, $this->connTimeout
);
171 curl_setopt( $ch, CURLOPT_TIMEOUT
, $this->reqTimeout
);
172 curl_setopt( $ch, CURLOPT_FOLLOWLOCATION
, 1 );
173 curl_setopt( $ch, CURLOPT_MAXREDIRS
, 4 );
174 curl_setopt( $ch, CURLOPT_HEADER
, 0 );
175 if ( !is_null( $this->caBundlePath
) ) {
176 curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER
, true );
177 curl_setopt( $ch, CURLOPT_CAINFO
, $this->caBundlePath
);
179 curl_setopt( $ch, CURLOPT_RETURNTRANSFER
, 1 );
182 $query = http_build_query( $req['query'], '', '&', PHP_QUERY_RFC3986
);
183 if ( $query != '' ) {
184 $url .= strpos( $req['url'], '?' ) === false ?
"?$query" : "&$query";
186 curl_setopt( $ch, CURLOPT_URL
, $url );
188 curl_setopt( $ch, CURLOPT_CUSTOMREQUEST
, $req['method'] );
189 if ( $req['method'] === 'HEAD' ) {
190 curl_setopt( $ch, CURLOPT_NOBODY
, 1 );
193 if ( $req['method'] === 'PUT' ) {
194 curl_setopt( $ch, CURLOPT_PUT
, 1 );
195 if ( is_resource( $req['body'] ) ) {
196 curl_setopt( $ch, CURLOPT_INFILE
, $req['body'] );
197 if ( isset( $req['headers']['content-length'] ) ) {
198 curl_setopt( $ch, CURLOPT_INFILESIZE
, $req['headers']['content-length'] );
199 } elseif ( isset( $req['headers']['transfer-encoding'] ) &&
200 $req['headers']['transfer-encoding'] === 'chunks'
202 curl_setopt( $ch, CURLOPT_UPLOAD
, true );
204 throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." );
206 } elseif ( $req['body'] !== '' ) {
207 $fp = fopen( "php://temp", "wb+" );
208 fwrite( $fp, $req['body'], strlen( $req['body'] ) );
210 curl_setopt( $ch, CURLOPT_INFILE
, $fp );
211 curl_setopt( $ch, CURLOPT_INFILESIZE
, strlen( $req['body'] ) );
212 $req['_closeHandle'] = $fp; // remember to close this later
214 curl_setopt( $ch, CURLOPT_INFILESIZE
, 0 );
216 curl_setopt( $ch, CURLOPT_READFUNCTION
,
217 function ( $ch, $fd, $length ) {
218 $data = fread( $fd, $length );
219 $len = strlen( $data );
223 } elseif ( $req['method'] === 'POST' ) {
224 curl_setopt( $ch, CURLOPT_POST
, 1 );
225 curl_setopt( $ch, CURLOPT_POSTFIELDS
, $req['body'] );
227 if ( is_resource( $req['body'] ) ||
$req['body'] !== '' ) {
228 throw new Exception( "HTTP body specified for a non PUT/POST request." );
230 $req['headers']['content-length'] = 0;
234 foreach ( $req['headers'] as $name => $value ) {
235 if ( strpos( $name, ': ' ) ) {
236 throw new Exception( "Headers cannot have ':' in the name." );
238 $headers[] = $name . ': ' . trim( $value );
240 curl_setopt( $ch, CURLOPT_HTTPHEADER
, $headers );
242 curl_setopt( $ch, CURLOPT_HEADERFUNCTION
,
243 function ( $ch, $header ) use ( &$req ) {
244 $length = strlen( $header );
246 if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) {
247 $req['response']['code'] = (int)$matches[2];
248 $req['response']['reason'] = trim( $matches[3] );
251 if ( strpos( $header, ":" ) === false ) {
254 list( $name, $value ) = explode( ":", $header, 2 );
255 $req['response']['headers'][strtolower( $name )] = trim( $value );
260 if ( isset( $req['stream'] ) ) {
261 // Don't just use CURLOPT_FILE as that might give:
262 // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE*
263 // The callback here handles both normal files and php://temp handles.
264 curl_setopt( $ch, CURLOPT_WRITEFUNCTION
,
265 function ( $ch, $data ) use ( &$req ) {
266 return fwrite( $req['stream'], $data );
270 curl_setopt( $ch, CURLOPT_WRITEFUNCTION
,
271 function ( $ch, $data ) use ( &$req ) {
272 $req['response']['body'] .= $data;
273 return strlen( $data );
284 protected function getCurlMulti() {
285 if ( !$this->multiHandle
) {
286 $this->multiHandle
= curl_multi_init();
288 return $this->multiHandle
;
291 function __destruct() {
292 if ( $this->multiHandle
) {
293 curl_multi_close( $this->multiHandle
);