Skip to content
132 changes: 81 additions & 51 deletions php/utils.php
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,7 @@ static function ( $matches ) use ( $file, $dir ) {
* or string absolute path to CA cert to use.
* Defaults to detected CA cert bundled with the Requests library.
* @type bool $insecure Whether to retry automatically without certificate validation.
* @type int $max_retries Maximum number of retries of failed requests. Default 3.
* }
* @return \Requests_Response|Response
* @throws RuntimeException If the request failed.
Expand All @@ -878,6 +879,7 @@ static function ( $matches ) use ( $file, $dir ) {
function http_request( $method, $url, $data = null, $headers = [], $options = [] ) {
$insecure = isset( $options['insecure'] ) && (bool) $options['insecure'];
$halt_on_error = ! isset( $options['halt_on_error'] ) || (bool) $options['halt_on_error'];
$max_retries = isset( $options['max_retries'] ) ? (int) $options['max_retries'] : 3;
unset( $options['halt_on_error'] );

if ( ! isset( $options['verify'] ) ) {
Expand All @@ -897,9 +899,38 @@ function http_request( $method, $url, $data = null, $headers = [], $options = []
*/
$request_method = [ RequestsLibrary::get_class_name(), 'request' ];

try {
$attempt = 0;
$last_exception = null;
$retry_after_delay = 1; // Start with 1 second delay.

while ( $attempt <= $max_retries ) {
++$attempt;
try {
return $request_method( $url, $headers, $data, $method, $options );
try {
return $request_method( $url, $headers, $data, $method, $options );
} catch ( \Requests_Exception | \WpOrg\Requests\Exception $exception ) {
$curl_handle = $exception->getData();
// Get curl error code safely - only if curl is available and handle is valid.
$curl_errno = null;
if ( function_exists( 'curl_errno' ) && ( is_resource( $curl_handle ) || ( is_object( $curl_handle ) && $curl_handle instanceof \CurlHandle ) ) ) {
// @phpstan-ignore argument.type
$curl_errno = curl_errno( $curl_handle );
}
// CURLE_SSL_CACERT = 60
$is_ssl_cacert_error = null !== $curl_errno && 60 === $curl_errno;

if (
true !== $options['verify']
|| 'curlerror' !== $exception->getType()
|| ! $is_ssl_cacert_error
) {
throw $exception;
}

$options['verify'] = get_default_cacert( $halt_on_error );

return $request_method( $url, $headers, $data, $method, $options );
}
} catch ( \Requests_Exception | \WpOrg\Requests\Exception $exception ) {
$curl_handle = $exception->getData();
// Get curl error code safely - only if curl is available and handle is valid.
Expand All @@ -908,66 +939,65 @@ function http_request( $method, $url, $data = null, $headers = [], $options = []
// @phpstan-ignore argument.type
$curl_errno = curl_errno( $curl_handle );
}
// CURLE_SSL_CACERT = 60
$is_ssl_cacert_error = null !== $curl_errno && 60 === $curl_errno;
// CURLE_SSL_CONNECT_ERROR = 35, CURLE_SSL_CERTPROBLEM = 58, CURLE_SSL_CACERT_BADFILE = 77
$is_ssl_error = null !== $curl_errno && in_array( $curl_errno, [ 35, 58, 77 ], true );

// CURLE_COULDNT_RESOLVE_HOST = 6, CURLE_COULDNT_CONNECT = 7, CURLE_PARTIAL_FILE = 18
// CURLE_OPERATION_TIMEDOUT = 28, CURLE_GOT_NOTHING = 52, CURLE_SEND_ERROR = 55, CURLE_RECV_ERROR = 56
$is_transient_error = null !== $curl_errno && in_array( $curl_errno, [ 6, 7, 18, 28, 52, 55, 56 ], true );

if (
true !== $options['verify']
|| 'curlerror' !== $exception->getType()
|| ! $is_ssl_cacert_error
! $insecure
||
'curlerror' !== $exception->getType()
||
! $is_ssl_error
) {
throw $exception;
}

$options['verify'] = get_default_cacert( $halt_on_error );
// Check if this is a transient error that should be retried.
if ( ! $is_transient_error || $attempt > $max_retries ) {
$error_msg = sprintf( "Failed to get url '%s': %s.", $url, $exception->getMessage() );
if ( $halt_on_error ) {
WP_CLI::error( $error_msg );
}
throw new RuntimeException( $error_msg, 0, $exception );
}

return $request_method( $url, $headers, $data, $method, $options );
}
} catch ( \Requests_Exception | \WpOrg\Requests\Exception $exception ) {
$curl_handle = $exception->getData();
// Get curl error code safely - only if curl is available and handle is valid.
$curl_errno = null;
if ( function_exists( 'curl_errno' ) && ( is_resource( $curl_handle ) || ( is_object( $curl_handle ) && $curl_handle instanceof \CurlHandle ) ) ) {
// @phpstan-ignore argument.type
$curl_errno = curl_errno( $curl_handle );
}
// CURLE_SSL_CONNECT_ERROR = 35, CURLE_SSL_CERTPROBLEM = 58, CURLE_SSL_CACERT_BADFILE = 77
$is_ssl_error = null !== $curl_errno && in_array( $curl_errno, [ 35, 58, 77 ], true );

if (
! $insecure
||
'curlerror' !== $exception->getType()
||
! $is_ssl_error
) {
$error_msg = sprintf( "Failed to get url '%s': %s.", $url, $exception->getMessage() );
if ( $halt_on_error ) {
WP_CLI::error( $error_msg );
// Store exception and retry.
$last_exception = $exception;
WP_CLI::debug( sprintf( 'Retrying HTTP request to %s (retry %d/%d) after transient error: %s', $url, $attempt - 1, $max_retries, $exception->getMessage() ), 'http' );
sleep( $retry_after_delay );
$retry_after_delay = min( $retry_after_delay * 2, 10 ); // Exponential backoff, max 10 seconds.
continue;
}
throw new RuntimeException( $error_msg, 0, $exception );
}

$warning = sprintf(
"Re-trying without verify after failing to get verified url '%s' %s.",
$url,
$exception->getMessage()
);
WP_CLI::warning( $warning );
$warning = sprintf(
"Re-trying without verify after failing to get verified url '%s' %s.",
$url,
$exception->getMessage()
);
WP_CLI::warning( $warning );

// Disable certificate validation for the next try.
$options['verify'] = false;
// Disable certificate validation for the next try.
$options['verify'] = false;

try {
return $request_method( $url, $headers, $data, $method, $options );
} catch ( \Requests_Exception | \WpOrg\Requests\Exception $exception ) {
$error_msg = sprintf( "Failed to get non-verified url '%s' %s.", $url, $exception->getMessage() );
if ( $halt_on_error ) {
WP_CLI::error( $error_msg );
try {
return $request_method( $url, $headers, $data, $method, $options );
} catch ( \Requests_Exception | \WpOrg\Requests\Exception $exception ) {
$error_msg = sprintf( "Failed to get non-verified url '%s' %s.", $url, $exception->getMessage() );
if ( $halt_on_error ) {
WP_CLI::error( $error_msg );
}
throw new RuntimeException( $error_msg, 0, $exception );
}
throw new RuntimeException( $error_msg, 0, $exception );
}
}

// Should never reach here, but just in case.
$error_msg = sprintf( "Failed to get url '%s' after %d attempts.", $url, $max_retries + 1 );
if ( $halt_on_error ) {
WP_CLI::error( $error_msg );
}
throw new RuntimeException( $error_msg, 0, $last_exception );
}

/**
Expand Down