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> (uses RFC 3986)
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 * array bodies are encoded as multipart/form-data and strings
15 * use application/x-www-form-urlencoded (headers sent automatically)
16 * - stream : resource to stream the HTTP response body to
18 * @author Aaron Schulz
21 class MultiHttpClient
{
23 protected $multiHandle = null; // curl_multi handle
24 /** @var string|null SSL certificates path */
25 protected $caBundlePath;
27 protected $connTimeout;
29 protected $reqTimeout;
32 * @param array $options
34 public function __construct( array $options ) {
35 if ( isset( $options['caBundlePath'] ) ) {
36 $this->caBundlePath
= $options['caBundlePath'];
37 if ( !file_exists( $this->caBundlePath
) ) {
38 throw new Exception( "Cannot find CA bundle: " . $this->caBundlePath
);
41 static $defaults = array( 'connTimeout' => 10, 'reqTimeout' => 300 );
42 foreach ( $defaults as $key => $default ) {
43 $this->$key = isset( $options[$key] ) ?
$options[$key] : $default;
48 * Execute an HTTP(S) request
50 * This method returns a response map of:
51 * - code : HTTP response code or 0 if there was a serious cURL error
52 * - reason : HTTP response reason (empty if there was a serious cURL error)
53 * - headers : <header name/value associative array>
54 * - body : HTTP response body or resource (if "stream" was set)
55 * - err : Any cURL error string
56 * The map also stores integer-indexed copies of these values. This lets callers do:
58 * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req;
60 * @param array $req HTTP request array
61 * @return array Response array for request
63 public function run( array $req ) {
64 $req = $this->runMulti( array( $req ) );
65 return $req[0]['response'];
69 * Execute a set of HTTP(S) request concurrently
71 * The maps are returned by this method with the 'response' field set to a map of:
72 * - code : HTTP response code or 0 if there was a serious cURL error
73 * - reason : HTTP response reason (empty if there was a serious cURL error)
74 * - headers : <header name/value associative array>
75 * - body : HTTP response body or resource (if "stream" was set)
76 * - err : Any cURL error string
77 * The map also stores integer-indexed copies of these values. This lets callers do:
79 * list( $rcode, $rdesc, $rhdrs, $rbody, $rerr ) = $req;
81 * All headers in the 'headers' field are normalized to use lower case names.
82 * This is true for the request headers and the response headers.
84 * @param array $req Map of HTTP request arrays
85 * @return array $reqs With response array populated for each
87 public function runMulti( array $reqs ) {
88 $multiHandle = $this->getCurlMulti();
90 // Normalize $reqs and add all of the required cURL handles...
92 foreach ( $reqs as $index => &$req ) {
93 $req['response'] = array(
100 if ( !isset( $req['method'] ) ) {
101 throw new Exception( "Request has no 'method' field set." );
102 } elseif ( !isset( $req['url'] ) ) {
103 throw new Exception( "Request has no 'url' field set." );
105 $req['query'] = isset( $req['query'] ) ?
$req['query'] : array();
106 $headers = array(); // normalized headers
107 if ( isset( $req['headers'] ) ) {
108 foreach ( $req['headers'] as $name => $value ) {
109 $headers[strtolower( $name )] = $value;
112 $req['headers'] = $headers;
113 if ( !isset( $req['body'] ) ) {
115 $req['headers']['content-length'] = 0;
117 $handles[$index] = $this->getCurlHandle( $req );
118 if ( count( $reqs ) > 1 ) {
119 // https://github.com/guzzle/guzzle/issues/349
120 curl_setopt( $handles[$index], CURLOPT_FORBID_REUSE
, true );
122 curl_multi_add_handle( $multiHandle, $handles[$index] );
125 // Execute the cURL handles concurrently...
126 $active = null; // handles still being processed
128 // Do any available work...
130 $mrc = curl_multi_exec( $multiHandle, $active );
131 } while ( $mrc == CURLM_CALL_MULTI_PERFORM
);
132 // Wait (if possible) for available work...
133 if ( $active > 0 && $mrc == CURLM_OK
) {
134 if ( curl_multi_select( $multiHandle, 10 ) == -1 ) {
135 // PHP bug 63411; http://curl.haxx.se/libcurl/c/curl_multi_fdset.html
136 usleep( 5000 ); // 5ms
139 } while ( $active > 0 && $mrc == CURLM_OK
);
141 // Remove all of the added cURL handles and check for errors...
142 foreach ( $reqs as $index => &$req ) {
143 $ch = $handles[$index];
144 curl_multi_remove_handle( $multiHandle, $ch );
145 if ( curl_errno( $ch ) !== 0 ) {
146 $req['error'] = "(curl error: " . curl_errno( $ch ) . ") " . curl_error( $ch );
148 // For convenience with the list() operator
149 $req['response'][0] = $req['response']['code'];
150 $req['response'][1] = $req['response']['reason'];
151 $req['response'][2] = $req['response']['headers'];
152 $req['response'][3] = $req['response']['body'];
153 $req['response'][4] = $req['response']['error'];
155 // Close any string wrapper file handles
156 if ( isset( $req['_closeHandle'] ) ) {
157 fclose( $req['_closeHandle'] );
158 unset( $req['_closeHandle'] );
166 * @param array $req HTTP request map
169 protected function getCurlHandle( array &$req ) {
172 curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT
, $this->connTimeout
);
173 curl_setopt( $ch, CURLOPT_TIMEOUT
, $this->reqTimeout
);
174 curl_setopt( $ch, CURLOPT_FOLLOWLOCATION
, 1 );
175 curl_setopt( $ch, CURLOPT_MAXREDIRS
, 4 );
176 curl_setopt( $ch, CURLOPT_HEADER
, 0 );
177 if ( !is_null( $this->caBundlePath
) ) {
178 curl_setopt( $ch, CURLOPT_SSL_VERIFYPEER
, true );
179 curl_setopt( $ch, CURLOPT_CAINFO
, $this->caBundlePath
);
181 curl_setopt( $ch, CURLOPT_RETURNTRANSFER
, 1 );
184 // PHP_QUERY_RFC3986 is PHP 5.4+ only
185 $query = str_replace(
188 http_build_query( $req['query'], '', '&' )
190 if ( $query != '' ) {
191 $url .= strpos( $req['url'], '?' ) === false ?
"?$query" : "&$query";
193 curl_setopt( $ch, CURLOPT_URL
, $url );
195 curl_setopt( $ch, CURLOPT_CUSTOMREQUEST
, $req['method'] );
196 if ( $req['method'] === 'HEAD' ) {
197 curl_setopt( $ch, CURLOPT_NOBODY
, 1 );
200 if ( $req['method'] === 'PUT' ) {
201 curl_setopt( $ch, CURLOPT_PUT
, 1 );
202 if ( is_resource( $req['body'] ) ) {
203 curl_setopt( $ch, CURLOPT_INFILE
, $req['body'] );
204 if ( isset( $req['headers']['content-length'] ) ) {
205 curl_setopt( $ch, CURLOPT_INFILESIZE
, $req['headers']['content-length'] );
206 } elseif ( isset( $req['headers']['transfer-encoding'] ) &&
207 $req['headers']['transfer-encoding'] === 'chunks'
209 curl_setopt( $ch, CURLOPT_UPLOAD
, true );
211 throw new Exception( "Missing 'Content-Length' or 'Transfer-Encoding' header." );
213 } elseif ( $req['body'] !== '' ) {
214 $fp = fopen( "php://temp", "wb+" );
215 fwrite( $fp, $req['body'], strlen( $req['body'] ) );
217 curl_setopt( $ch, CURLOPT_INFILE
, $fp );
218 curl_setopt( $ch, CURLOPT_INFILESIZE
, strlen( $req['body'] ) );
219 $req['_closeHandle'] = $fp; // remember to close this later
221 curl_setopt( $ch, CURLOPT_INFILESIZE
, 0 );
223 curl_setopt( $ch, CURLOPT_READFUNCTION
,
224 function ( $ch, $fd, $length ) {
225 $data = fread( $fd, $length );
226 $len = strlen( $data );
230 } elseif ( $req['method'] === 'POST' ) {
231 curl_setopt( $ch, CURLOPT_POST
, 1 );
232 curl_setopt( $ch, CURLOPT_POSTFIELDS
, $req['body'] );
234 if ( is_resource( $req['body'] ) ||
$req['body'] !== '' ) {
235 throw new Exception( "HTTP body specified for a non PUT/POST request." );
237 $req['headers']['content-length'] = 0;
241 foreach ( $req['headers'] as $name => $value ) {
242 if ( strpos( $name, ': ' ) ) {
243 throw new Exception( "Headers cannot have ':' in the name." );
245 $headers[] = $name . ': ' . trim( $value );
247 curl_setopt( $ch, CURLOPT_HTTPHEADER
, $headers );
249 curl_setopt( $ch, CURLOPT_HEADERFUNCTION
,
250 function ( $ch, $header ) use ( &$req ) {
251 $length = strlen( $header );
253 if ( preg_match( "/^(HTTP\/1\.[01]) (\d{3}) (.*)/", $header, $matches ) ) {
254 $req['response']['code'] = (int)$matches[2];
255 $req['response']['reason'] = trim( $matches[3] );
258 if ( strpos( $header, ":" ) === false ) {
261 list( $name, $value ) = explode( ":", $header, 2 );
262 $req['response']['headers'][strtolower( $name )] = trim( $value );
267 if ( isset( $req['stream'] ) ) {
268 // Don't just use CURLOPT_FILE as that might give:
269 // curl_setopt(): cannot represent a stream of type Output as a STDIO FILE*
270 // The callback here handles both normal files and php://temp handles.
271 curl_setopt( $ch, CURLOPT_WRITEFUNCTION
,
272 function ( $ch, $data ) use ( &$req ) {
273 return fwrite( $req['stream'], $data );
277 curl_setopt( $ch, CURLOPT_WRITEFUNCTION
,
278 function ( $ch, $data ) use ( &$req ) {
279 $req['response']['body'] .= $data;
280 return strlen( $data );
291 protected function getCurlMulti() {
292 if ( !$this->multiHandle
) {
293 $this->multiHandle
= curl_multi_init();
295 return $this->multiHandle
;
298 function __destruct() {
299 if ( $this->multiHandle
) {
300 curl_multi_close( $this->multiHandle
);