Skip to content
This repository was archived by the owner on May 30, 2025. It is now read-only.

Commit 966caf7

Browse files
authored
[HttpClient] stability improvements, larger unit test suite (#13)
1 parent 6c15872 commit 966caf7

File tree

8 files changed

+977
-293
lines changed

8 files changed

+977
-293
lines changed

components/Git/GitRemote.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ public function __construct( GitRepository $repository, $remote_name, $options =
3838
$this->repository = $repository;
3939
$this->http_client = $options['http_client'] ?? new Client(
4040
array(
41-
'timeout' => 300,
41+
'timeout_ms' => 300000,
4242
)
4343
);
4444
}

components/HttpClient/Client.php

Lines changed: 87 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use WordPress\ByteStream\ByteTransformer\InflateTransformer;
66
use WordPress\ByteStream\ReadStream\FileReadStream;
77
use WordPress\ByteStream\ReadStream\TransformedReadStream;
8+
use WordPress\DataLiberation\URL\WPURL;
89
use WordPress\HttpClient\ByteStream\ChunkedDecoderReadStream;
910
use WordPress\HttpClient\ByteStream\ChunkedEncoderByteTransformer;
1011
use WordPress\HttpClient\ByteStream\RequestReadStream;
@@ -107,13 +108,13 @@ class Client {
107108
protected $event;
108109
protected $request;
109110
protected $response_body_chunk;
110-
protected $timeout;
111+
protected $request_timeout_ms;
111112
protected $requests_started_at = array();
112113

113114
public function __construct( $options = array() ) {
114115
$this->concurrency = $options['concurrency'] ?? 10;
115116
$this->max_redirects = $options['max_redirects'] ?? 3;
116-
$this->timeout = $options['timeout'] ?? 30;
117+
$this->request_timeout_ms = $options['timeout_ms'] ?? 30000;
117118
$this->requests = array();
118119
}
119120

@@ -162,10 +163,28 @@ public function enqueue( $requests ) {
162163
}
163164

164165
foreach ( $requests as $request ) {
166+
if ( array_key_exists( $request->id, $this->connections ) ) {
167+
throw new HttpClientException("Request {$request->id} is already enqueued.");
168+
}
169+
170+
if($request->state !== Request::STATE_CREATED) {
171+
throw new HttpClientException("Request {$request->id} is not in the created state.");
172+
}
173+
174+
$request->state = Request::STATE_ENQUEUED;
165175
$this->requests[] = apply_filters( 'wp_http_client_request', $request );
166176
$this->events[ $request->id ] = array();
167177
$this->connections[ $request->id ] = new Connection( $request );
168-
$this->requests_started_at[ $request->id ] = microtime( true );
178+
179+
$parsed = WPURL::parse($request->url);
180+
if(false === $parsed) {
181+
$this->set_error( $request, new HttpError( sprintf( 'Invalid URL: %s', $request->url ) ) );
182+
continue;
183+
}
184+
if($parsed->protocol !== 'http:' && $parsed->protocol !== 'https:') {
185+
$this->set_error( $request, new HttpError( sprintf( 'Invalid URL – only HTTP and HTTPS URLs are supported: %s', $parsed->toString() ) ) );
186+
continue;
187+
}
169188
}
170189
}
171190

@@ -237,13 +256,13 @@ public function await_next_event( $query = array() ) {
237256
$this->response_body_chunk = null;
238257

239258
$start_time = microtime( true );
240-
$timeout = $query['timeout'] ?? $this->timeout * 1000;
259+
$timeout_ms = isset( $query['timeout_ms'] )
260+
? $query['timeout_ms']
261+
// Give the requests an opportunity to time out
262+
: $this->request_timeout_ms * 1.1
263+
;
241264

242265
do {
243-
if ( false !== $timeout && ( microtime( true ) - $start_time ) * 1000 >= $timeout ) {
244-
return false;
245-
}
246-
247266
if ( empty( $query['requests'] ) ) {
248267
$events = array_keys( $this->events );
249268
} else {
@@ -275,6 +294,15 @@ public function await_next_event( $query = array() ) {
275294
return true;
276295
}
277296
}
297+
298+
// After we've checked for any available events, see if we've run out of time.
299+
// This way, we always return any events that were ready before worrying about the timeout.
300+
// If we checked the timeout first, we might miss events that were already waiting for us
301+
// when the timeout is set to zero.
302+
$time_elapsed_ms = (microtime( true ) - $start_time) * 1000;
303+
if ( $timeout_ms && $time_elapsed_ms >= $timeout_ms ) {
304+
return false;
305+
}
278306
} while ( $this->event_loop_tick() );
279307

280308
return false;
@@ -336,9 +364,18 @@ protected function event_loop_tick() {
336364
return false;
337365
}
338366

339-
foreach ( $this->get_active_requests() as $request ) {
340-
if ( microtime( true ) - $this->requests_started_at[ $request->id ] > $this->timeout ) {
341-
$this->set_error( $request, new HttpError( sprintf( 'Request timed out after %s seconds.', $this->timeout ) ) );
367+
foreach ( $this->get_active_requests([
368+
Request::STATE_WILL_ENABLE_CRYPTO,
369+
Request::STATE_WILL_SEND_HEADERS,
370+
Request::STATE_WILL_SEND_BODY,
371+
Request::STATE_SENT,
372+
Request::STATE_RECEIVING_HEADERS,
373+
Request::STATE_RECEIVING_BODY,
374+
Request::STATE_RECEIVED,
375+
]) as $request ) {
376+
$time_elapsed_ms = (microtime( true ) - $this->requests_started_at[ $request->id ]) * 1000;
377+
if ( $time_elapsed_ms > $this->request_timeout_ms ) {
378+
$this->set_error( $request, new HttpError( sprintf( 'Request timed out after %s seconds.', $time_elapsed_ms ) ) );
342379
}
343380
}
344381

@@ -390,6 +427,7 @@ protected function event_loop_tick() {
390427
$this->get_active_requests( Request::STATE_RECEIVING_BODY )
391428
);
392429

430+
393431
return true;
394432
}
395433

@@ -444,7 +482,7 @@ protected function close_connection( Request $request ) {
444482
if ( $this->connections[ $request->id ]->decoded_response_stream ) {
445483
$stream = $this->connections[ $request->id ]->decoded_response_stream;
446484
$stream->close_reading();
447-
unset( $this->connections[ $request->id ]->decoded_response_stream );
485+
$this->connections[ $request->id ]->decoded_response_stream = null;
448486
} else {
449487
@fclose( $socket );
450488
}
@@ -533,6 +571,9 @@ protected function decode_and_monitor_response_body_stream( Request $request ) {
533571
$transfer_encoding === 'gzip' ? ZLIB_ENCODING_GZIP : ZLIB_ENCODING_RAW
534572
);
535573
break;
574+
case 'deflate':
575+
$transformers[] = new InflateTransformer( ZLIB_ENCODING_DEFLATE );
576+
break;
536577
case 'identity':
537578
// No-op
538579
break;
@@ -560,16 +601,17 @@ protected function decode_and_monitor_response_body_stream( Request $request ) {
560601
*/
561602
protected function enable_crypto( array $requests ) {
562603
foreach ( $this->stream_select( $requests, static::STREAM_SELECT_WRITE ) as $request ) {
563-
stream_set_timeout( $this->connections[ $request->id ]->http_socket, 1 );
604+
@stream_set_timeout( $this->connections[ $request->id ]->http_socket, 1 );
564605
$enabled_crypto = stream_socket_enable_crypto(
565606
$this->connections[ $request->id ]->http_socket,
566607
true,
567-
STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT
608+
STREAM_CRYPTO_METHOD_TLS_CLIENT
568609
);
569610
if ( false === $enabled_crypto ) {
570611
$last_error = error_get_last();
571612
$this->set_error( $request,
572-
new HttpError( 'Failed to enable crypto: ' . ( is_array( $last_error ) ? $last_error['message'] : 'unknown' ) ) );
613+
new HttpError( 'Failed to enable crypto: ' . ( is_array( $last_error ) ? $last_error['message'] : 'unknown' ) )
614+
);
573615
continue;
574616
} elseif ( 0 === $enabled_crypto ) {
575617
// The SSL handshake isn't finished yet, let's skip it
@@ -589,11 +631,12 @@ protected function enable_crypto( array $requests ) {
589631
protected function send_request_headers( array $requests ) {
590632
foreach ( $this->stream_select( $requests, static::STREAM_SELECT_WRITE ) as $request ) {
591633
$header_bytes = static::prepare_request_headers( $request );
592-
593634
if ( false === @fwrite( $this->connections[ $request->id ]->http_socket, $header_bytes ) ) {
594635
$last_error = error_get_last();
636+
$last_error_message = is_array( $last_error ) ? $last_error['message'] : 'unknown';
595637
$this->set_error( $request,
596-
new HttpError( 'Failed to write request bytes - ' . ( is_array( $last_error ) ? $last_error['message'] : 'unknown' ) ) );
638+
new HttpError( 'Failed to write request bytes - ' . $last_error_message )
639+
);
597640
continue;
598641
}
599642

@@ -635,8 +678,10 @@ protected function send_request_body( array $requests ) {
635678
}
636679

637680
$chunk = $request->upload_body_stream->consume( $available_bytes );
638-
if ( ! fwrite( $this->connections[ $request->id ]->http_socket, $chunk ) ) {
639-
$this->set_error( $request, new HttpError( 'Failed to write request bytes.' ) );
681+
if ( ! @fwrite( $this->connections[ $request->id ]->http_socket, $chunk ) ) {
682+
$last_error = error_get_last();
683+
$last_error_message = is_array( $last_error ) ? $last_error['message'] : 'unknown';
684+
$this->set_error( $request, new HttpError( 'Failed to write request bytes: ' . $last_error_message ) );
640685
continue;
641686
}
642687
}
@@ -660,11 +705,22 @@ protected function receive_response_headers( $requests ) {
660705
while ( true ) {
661706
// @TODO: Use a larger chunk size here and then scan for \r\n\r\n.
662707
// 1 seems slow and overly conservative.
708+
if (
709+
!$this->connections[ $request->id ]->http_socket ||
710+
!is_resource($this->connections[ $request->id ]->http_socket) ||
711+
@feof($this->connections[ $request->id ]->http_socket)
712+
) {
713+
$this->set_error($request, new HttpError('Connection closed while reading response headers.'));
714+
break;
715+
}
716+
663717
$header_byte = fread( $this->connections[ $request->id ]->http_socket, 1 );
718+
664719
if ( false === $header_byte || '' === $header_byte ) {
665720
if (
666-
!is_resource($this->connections[ $request->id ]->http_socket) ||
667-
feof($this->connections[ $request->id ]->http_socket)
721+
!$this->connections[ $request->id ]->http_socket ||
722+
!is_resource($this->connections[ $request->id ]->http_socket) ||
723+
@feof($this->connections[ $request->id ]->http_socket)
668724
) {
669725
$this->set_error($request, new HttpError('Connection closed while reading response headers.'));
670726
break;
@@ -709,13 +765,8 @@ protected function receive_response_headers( $requests ) {
709765
break;
710766
}
711767

712-
// If we're being redirected, we don't need to wait for the body.
713-
if ( $response->status_code >= 300 && $response->status_code < 400 ) {
714-
$request->state = Request::STATE_RECEIVED;
715-
break;
716-
}
717-
718768
$request->state = Request::STATE_RECEIVING_BODY;
769+
$this->connections[ $request->id ]->decoded_response_stream = $this->decode_and_monitor_response_body_stream( $request );
719770
break;
720771
}
721772
}
@@ -734,10 +785,6 @@ protected function receive_response_body( $requests ) {
734785
// * The last chunk in Transfer-Encoding: chunked is received
735786
// * The connection is closed
736787
foreach ( $this->stream_select( $requests, static::STREAM_SELECT_READ ) as $request ) {
737-
if ( ! $this->connections[ $request->id ]->decoded_response_stream ) {
738-
$this->connections[ $request->id ]->decoded_response_stream = $this->decode_and_monitor_response_body_stream( $request );
739-
}
740-
741788
$stream = $this->connections[ $request->id ]->decoded_response_stream;
742789

743790
while ( true ) {
@@ -789,30 +836,20 @@ protected function handle_redirects( $requests ) {
789836
}
790837

791838
$redirect_url = $location;
792-
if ( strpos( $redirect_url, 'http://' ) !== 0 && strpos( $redirect_url, 'https://' ) !== 0 ) {
793-
$current_url_parts = parse_url( $request->url );
794-
$redirect_url = $current_url_parts['scheme'] . '://' . $current_url_parts['host'];
795-
if ( $current_url_parts['port'] ) {
796-
$redirect_url .= ':' . $current_url_parts['port'];
797-
}
798-
if ( strlen( $location ) === 0 || $location[0] !== '/' ) {
799-
$redirect_url .= '/';
800-
}
801-
$redirect_url .= $location;
802-
}
803-
804-
// @TODO: Use a WHATWG-compliant URL parser
805-
if ( ! filter_var( $redirect_url, FILTER_VALIDATE_URL ) ) {
806-
$this->set_error( $request, new HttpError( 'Invalid redirect URL' ) );
839+
$parsed = WPURL::parse($redirect_url, $request->url);
840+
if(false === $parsed) {
841+
$this->set_error( $request, new HttpError( sprintf( 'Invalid redirect URL: %s', $redirect_url ) ) );
807842
continue;
808843
}
844+
$redirect_url = $parsed->toString();
809845

810846
$this->events[ $request->id ][ self::EVENT_REDIRECT ] = true;
811847
$this->enqueue(
812848
new Request(
813849
$redirect_url,
814850
array(
815-
'method' => $request->method,
851+
// Redirects are always GET requests
852+
'method' => 'GET',
816853
'redirected_from' => $request,
817854
)
818855
)
@@ -904,11 +941,12 @@ protected function open_nonblocking_http_sockets( $requests ) {
904941
)
905942
);
906943

944+
$this->requests_started_at[ $request->id ] = microtime( true );
907945
$stream = @stream_socket_client(
908946
'tcp://' . $host . ':' . $port,
909947
$errno,
910948
$errstr,
911-
$this->timeout,
949+
$this->request_timeout_ms,
912950
STREAM_CLIENT_CONNECT | STREAM_CLIENT_ASYNC_CONNECT,
913951
$context
914952
);
@@ -921,13 +959,7 @@ protected function open_nonblocking_http_sockets( $requests ) {
921959
continue;
922960
}
923961

924-
if ( PHP_VERSION_ID >= 72000 ) {
925-
// In PHP <= 7.1 and later, making the socket non-blocking before the
926-
// SSL handshake makes the stream_socket_enable_crypto() call always return
927-
// false. Therefore, we only make the socket non-blocking after the
928-
// SSL handshake.
929-
stream_set_blocking( $stream, 0 );
930-
}
962+
stream_set_blocking( $stream, false );
931963

932964
$this->connections[ $request->id ]->http_socket = $stream;
933965
if ( $is_ssl ) {

components/HttpClient/Connection.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class Connection {
77
public $request;
88
public $http_socket;
99
public $response_buffer;
10-
public $decoded_response_stream;
10+
public $decoded_response_stream = null;
1111

1212
public function __construct( Request $request ) {
1313
$this->request = $request;

components/HttpClient/Request.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
class Request {
66

7+
const STATE_CREATED = 'STATE_CREATED';
78
const STATE_ENQUEUED = 'STATE_ENQUEUED';
89
const STATE_WILL_ENABLE_CRYPTO = 'STATE_WILL_ENABLE_CRYPTO';
910
const STATE_WILL_SEND_HEADERS = 'STATE_WILL_SEND_HEADERS';
@@ -19,7 +20,7 @@ class Request {
1920

2021
public $id;
2122

22-
public $state = self::STATE_ENQUEUED;
23+
public $state = self::STATE_CREATED;
2324

2425
public $url;
2526
public $is_ssl;

0 commit comments

Comments
 (0)