", * "cURL error 28: Operation timed out") says what the wire saw, not what to do. * This says what happened and what to try, and keeps the status or cURL number as * a small technical detail. The remote's body is never copied into the result; * callers log the raw text. Reporting only: nothing here changes how a request is * signed, retried or routed. */ final class RemoteErrorExplainer { public const CODE_BLOCKED = 'shiftora_remote_blocked'; public const CODE_NOT_FOUND = 'shiftora_remote_not_found'; public const CODE_REFUSED = 'shiftora_remote_refused'; public const CODE_TOO_LARGE = 'shiftora_remote_too_large'; public const CODE_RATE_LIMIT = 'shiftora_remote_rate_limited'; public const CODE_SERVER = 'shiftora_remote_server_error'; public const CODE_TIMEOUT = 'shiftora_remote_timeout'; public const CODE_UNREACHABLE = 'shiftora_remote_unreachable'; public const CODE_TLS = 'shiftora_remote_tls'; public const CODE_UNEXPECTED = 'shiftora_remote_unexpected'; public const CODE_REFUSAL = 'shiftora_remote_refusal'; /** Longest remote reason shown; the rest is cut. */ private const REASON_CAP = 300; /** cURL numbers that mean the certificate or handshake was not accepted. */ private const CURL_TLS = [ 35, 51, 58, 60, 77 ]; /** * Explains a failure, or returns null when it is not a remote request failure * (a local refusal, a blocked-external-requests policy) and keeps its own words. * * @return array{code: string, message: string, hint: string, detail: string}|null */ public static function explain( Throwable $e ): ?array { $http = self::find_http_exception( $e ); if ( null === $http ) { return null; } if ( HttpClient::ERR_BLOCKED_EXTERNAL === $http->getCode() ) { return null; } // Shiftora itself answered with a coded refusal at any status: its own reason is the explanation. if ( str_starts_with( $http->remote_code(), 'shiftora_' ) ) { $reason = self::remote_reason( $http->getMessage() ); if ( '' !== $reason ) { return self::build( $http->remote_code(), $reason, '', 'HTTP ' . $http->http_status() ); } if ( 403 === $http->http_status() ) { return null; } } return 0 === $http->http_status() ? self::from_transport( $http ) : self::from_status( $http->http_status(), $http->remote_code() ); } /** * Explains the stored text of a failure whose exception is gone, such as a * migration reason passed to the engine as a string. * * @return array{code: string, message: string, hint: string, detail: string}|null */ public static function explain_text( string $text ): ?array { if ( ! str_contains( $text, 'Shiftora HTTP error:' ) && ! str_contains( $text, 'HTTPS connection failed (' ) ) { return null; } $status = 1 === preg_match( '/remote returned status (\d{3})/', $text, $m ) ? (int) $m[1] : 0; // The other site gave its own reason: keep it instead of guessing from the status. $reason = self::remote_reason( $text ); if ( $status > 0 && '' !== $reason ) { return self::build( self::CODE_REFUSAL, $reason, '', 'HTTP ' . $status ); } if ( $status > 0 ) { return self::from_status( $status, '' ); } return self::from_curl( self::curl_number( $text, 0 ) ); } /** The one-line form for places that show a single string, such as a stored migration reason. */ public static function compose( array $explained ): string { $line = trim( $explained['message'] . ' ' . $explained['hint'] ); return '' === $explained['detail'] ? $line : $line . ' (' . $explained['detail'] . ')'; } /** * The REST error for a failed call to the other site, or null when the failure * is not one to rewrite. A rejected credential keeps its own code and wording. */ public static function to_wp_error( Throwable $e, int $connection_id, int $status = 502 ): ?WP_Error { $rejection = ConnectionRejection::from_exception( $e, $connection_id ); if ( null !== $rejection ) { return $rejection; } $explained = self::explain( $e ); if ( null === $explained ) { return null; } return new WP_Error( $explained['code'], $explained['message'], [ 'status' => $status, 'connection_id' => $connection_id, 'hint' => $explained['hint'], 'detail' => $explained['detail'], ] ); } /** The failure with the rejection and every other mapping applied, as one admin-facing line, or null. */ public static function line_for( Throwable $e ): ?string { $http = self::find_http_exception( $e ); if ( null !== $http && ConnectionRejection::is_rejection( $http->http_status(), $http->remote_code() ) ) { $error = ConnectionRejection::from_exception( $http, 0 ); return null === $error ? null : trim( $error->get_error_message() . ' ' . (string) ( $error->get_error_data()['hint'] ?? '' ) ); } $explained = self::explain( $e ); return null === $explained ? null : self::compose( $explained ); } /** * The same for a stored text. Words before the transport text, such as "PUSH finalize * failed: ", stay because they say which step failed. A rejection status keeps the * rejection wording. */ public static function line_for_text( string $text ): ?string { $start = null; foreach ( [ 'Shiftora HTTP error:', 'HTTPS connection failed (' ] as $marker ) { $at = strpos( $text, $marker ); if ( false !== $at && ( null === $start || $at < $start ) ) { $start = $at; } } if ( null === $start ) { return null; } if ( 1 === preg_match( '/remote returned status (\d{3})/', $text, $m ) && ConnectionRejection::is_rejection( (int) $m[1], '' ) ) { $line = self::line_for( new HttpRequestException( $text, 0, (int) $m[1] ) ); } else { $explained = self::explain_text( $text ); $line = null === $explained ? null : self::compose( $explained ); } return null === $line ? null : substr( $text, 0, $start ) . $line; } /** * The reason the other site gave after "Remote said: ", made safe to show: tags and * control characters removed, whitespace collapsed, cut to REASON_CAP. Empty when none. */ public static function remote_reason( string $text ): string { $at = strpos( $text, ' Remote said: ' ); if ( false === $at ) { return ''; } $reason = wp_strip_all_tags( substr( $text, $at + strlen( ' Remote said: ' ) ) ); $reason = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/', '', $reason ); $reason = trim( (string) preg_replace( '/\s+/u', ' ', $reason ) ); if ( mb_strlen( $reason ) > self::REASON_CAP ) { $reason = rtrim( mb_substr( $reason, 0, self::REASON_CAP ) ) . '...'; } return $reason; } private static function find_http_exception( Throwable $e ): ?HttpRequestException { for ( $depth = 0; null !== $e && $depth < 6; $depth++ ) { if ( $e instanceof HttpRequestException ) { return $e; } $e = $e->getPrevious(); } return null; } /** @return array{code: string, message: string, hint: string, detail: string} */ private static function from_transport( HttpRequestException $http ): array { $bucket = [ HttpClient::ERR_DNS_FAILURE => 6, HttpClient::ERR_CONNECTION_REFUSED => 7, HttpClient::ERR_TIMEOUT => 28, HttpClient::ERR_SSL => 35, ][ $http->getCode() ] ?? 0; return self::from_curl( self::curl_number( $http->getMessage(), $bucket ) ); } /** * The cURL number named in the text, else the one the message wording implies, else the fallback. */ private static function curl_number( string $text, int $fallback ): int { if ( 1 === preg_match( '/cURL error (\d+)/i', $text, $m ) ) { return (int) $m[1]; } $lower = strtolower( $text ); if ( str_contains( $lower, 'timed out' ) || str_contains( $lower, 'timeout' ) ) { return 28; } if ( str_contains( $lower, 'resolve host' ) ) { return 6; } if ( str_contains( $lower, 'connection refused' ) || str_contains( $lower, 'failed to connect' ) ) { return 7; } if ( str_contains( $lower, 'ssl' ) || str_contains( $lower, 'certificate' ) ) { return 35; } return $fallback; } /** @return array{code: string, message: string, hint: string, detail: string} */ private static function from_curl( int $curl ): array { $detail = $curl > 0 ? 'cURL ' . $curl : ''; if ( 28 === $curl ) { return self::build( self::CODE_TIMEOUT, __( 'The other site took too long to answer.', 'shiftora' ), __( 'Try again. On slow hosts, raise the request timeout in Settings.', 'shiftora' ), $detail ); } if ( 6 === $curl || 7 === $curl ) { return self::build( self::CODE_UNREACHABLE, __( 'Shiftora could not reach the other site.', 'shiftora' ), __( 'Check the address and that the site is online.', 'shiftora' ), $detail ); } if ( in_array( $curl, self::CURL_TLS, true ) ) { return self::build( self::CODE_TLS, __( 'The other site\'s certificate could not be verified.', 'shiftora' ), __( 'Fix or renew the certificate on the other site. If you did not expect this, someone may be intercepting the connection.', 'shiftora' ), $detail ); } return self::unexpected( $detail ); } /** @return array{code: string, message: string, hint: string, detail: string} */ private static function from_status( int $status, string $remote_code ): array { $detail = 'HTTP ' . $status; if ( 403 === $status ) { return self::build( self::CODE_BLOCKED, __( 'The other site blocked the request.', 'shiftora' ), __( 'A security or firewall plugin, or the host, is blocking Shiftora\'s REST requests. Allow /wp-json/shiftora/v1/ there.', 'shiftora' ), $detail ); } if ( 404 === $status ) { return self::build( self::CODE_NOT_FOUND, __( 'Shiftora did not answer on that site.', 'shiftora' ), __( 'Check that Shiftora is installed and active there, that the address is right, and that the REST API (/wp-json/) is not disabled.', 'shiftora' ), $detail ); } // A 400 that carries one of Shiftora's own codes is an application answer, not a proxy refusal. if ( 405 === $status || ( 400 === $status && '' === $remote_code ) ) { return self::build( self::CODE_REFUSED, __( 'The other site\'s server refused the request.', 'shiftora' ), __( 'A proxy or security layer in front of the site blocks it. Ask the host to allow POST requests to /wp-json/shiftora/v1/.', 'shiftora' ), $detail ); } if ( 413 === $status ) { return self::build( self::CODE_TOO_LARGE, __( 'The request was too large for the other site.', 'shiftora' ), __( 'Lower the transmitted chunk size in Settings, Transfer tuning.', 'shiftora' ), $detail ); } if ( 429 === $status ) { return self::build( self::CODE_RATE_LIMIT, __( 'The other site received too many requests.', 'shiftora' ), __( 'Wait a minute and try again.', 'shiftora' ), $detail ); } if ( $status >= 500 ) { return self::build( self::CODE_SERVER, __( 'The other site hit an error.', 'shiftora' ), __( 'Try again. If it repeats, check that site\'s PHP error log or Shiftora, Help there.', 'shiftora' ), $detail ); } return self::unexpected( $detail ); } /** @return array{code: string, message: string, hint: string, detail: string} */ private static function unexpected( string $detail ): array { return self::build( self::CODE_UNEXPECTED, __( 'The other site did not respond as expected.', 'shiftora' ), __( 'Try again. If it repeats, check Shiftora\'s log for the details.', 'shiftora' ), $detail ); } /** @return array{code: string, message: string, hint: string, detail: string} */ private static function build( string $code, string $message, string $hint, string $detail ): array { return [ 'code' => $code, 'message' => $message, 'hint' => $hint, 'detail' => $detail, ]; } }