Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.64% |
115 / 119 |
|
83.33% |
10 / 12 |
CRAP | |
0.00% |
0 / 1 |
| Registration_Failure | |
96.64% |
115 / 119 |
|
83.33% |
10 / 12 |
48 | |
0.00% |
0 / 1 |
| backoff_schedule | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
1 | |||
| get | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
5 | |||
| get_suppressed_error | |
100.00% |
16 / 16 |
|
100.00% |
1 / 1 |
4 | |||
| record | |
100.00% |
39 / 39 |
|
100.00% |
1 / 1 |
13 | |||
| record_suppressed_attempt | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
| clear | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| is_terminal | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| backoff_delay | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
4 | |||
| retry_after_seconds | |
75.00% |
9 / 12 |
|
0.00% |
0 / 1 |
7.77 | |||
| extract_http_status | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| fingerprint | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| track | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Registration failure state for the Jetpack connection. |
| 4 | * |
| 5 | * @package automattic/jetpack-connection |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Connection; |
| 9 | |
| 10 | use Automattic\Jetpack\Tracking; |
| 11 | use WP_Error; |
| 12 | |
| 13 | /** |
| 14 | * Remembers why the last registration attempt failed, and decides whether another |
| 15 | * automatic attempt is worth making yet. |
| 16 | * |
| 17 | * Unattended callers would otherwise retry a site that can never register forever. |
| 18 | * |
| 19 | * @internal Use Manager::get_registration_failure() and Manager::clear_registration_failure(). |
| 20 | * |
| 21 | * @since 9.11.0 |
| 22 | */ |
| 23 | class Registration_Failure { |
| 24 | |
| 25 | /** |
| 26 | * Name of the option holding the failure state. |
| 27 | */ |
| 28 | const OPTION = 'jetpack_register_failure'; |
| 29 | |
| 30 | /** |
| 31 | * Name of the transient used to rate-limit the "retry suppressed" Tracks event. |
| 32 | */ |
| 33 | const SUPPRESSION_TRACKED_TRANSIENT = 'jetpack_register_suppression_tracked'; |
| 34 | |
| 35 | /** |
| 36 | * Longest delay between two automatic attempts. |
| 37 | */ |
| 38 | const MAX_BACKOFF = DAY_IN_SECONDS; |
| 39 | |
| 40 | /** |
| 41 | * Error codes that describe the site's environment rather than a passing condition. |
| 42 | * |
| 43 | * Not `site_inaccessible_403` or `site_requires_authorization`: WordPress.com returns those |
| 44 | * from its XML-RPC fallback after any failed REST verify, so a passing glitch can cause them. |
| 45 | */ |
| 46 | const TERMINAL_ERROR_CODES = array( |
| 47 | 'siteurl_private_ip', |
| 48 | 'home_private_ip', |
| 49 | // Request parameters the server validates; the `verify_*` codes are a separate, transient family. |
| 50 | 'siteurl_missing', |
| 51 | 'siteurl_malformed', |
| 52 | 'secret_1_missing', |
| 53 | 'secret_1_malformed', |
| 54 | 'secret_2_missing', |
| 55 | 'secret_2_malformed', |
| 56 | 'site_name_malformed', |
| 57 | 'timezone_string_malformed', |
| 58 | 'gmt_offset_malformed', |
| 59 | ); |
| 60 | |
| 61 | /** |
| 62 | * Error code WordPress.com returns when the site hit the hourly registration rate limit. |
| 63 | * |
| 64 | * Waits a flat hour and is never terminal: the limit resets hourly, and its counter is |
| 65 | * shared by every site registering under the same URL. |
| 66 | */ |
| 67 | const RATE_LIMITED_ERROR_CODE = 'request_cancelled'; |
| 68 | |
| 69 | /** |
| 70 | * Delay before each successive automatic attempt, in seconds. |
| 71 | * |
| 72 | * Attempts past the end of the list reuse the last value. |
| 73 | * |
| 74 | * @return int[] |
| 75 | */ |
| 76 | private static function backoff_schedule() { |
| 77 | return array( |
| 78 | MINUTE_IN_SECONDS, |
| 79 | 5 * MINUTE_IN_SECONDS, |
| 80 | 30 * MINUTE_IN_SECONDS, |
| 81 | 2 * HOUR_IN_SECONDS, |
| 82 | self::MAX_BACKOFF, |
| 83 | ); |
| 84 | } |
| 85 | |
| 86 | /** |
| 87 | * Get the stored failure state. |
| 88 | * |
| 89 | * @return array|null The state, or null if there is none or it no longer applies. |
| 90 | */ |
| 91 | public static function get() { |
| 92 | $state = get_option( self::OPTION ); |
| 93 | |
| 94 | if ( ! is_array( $state ) || empty( $state['error_code'] ) ) { |
| 95 | return null; |
| 96 | } |
| 97 | |
| 98 | // A failure recorded under a different site address no longer applies. |
| 99 | if ( ! isset( $state['fingerprint'] ) || self::fingerprint() !== $state['fingerprint'] ) { |
| 100 | return null; |
| 101 | } |
| 102 | |
| 103 | return $state; |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * Get the error to return instead of attempting registration again, if the |
| 108 | * previous failure still stands. |
| 109 | * |
| 110 | * It carries the original code and message so callers handling specific codes |
| 111 | * keep working; the `suppressed` data flag tells it apart from a fresh failure. |
| 112 | * |
| 113 | * @return WP_Error|null Null when an attempt should be made. |
| 114 | */ |
| 115 | public static function get_suppressed_error() { |
| 116 | $state = self::get(); |
| 117 | |
| 118 | if ( null === $state ) { |
| 119 | return null; |
| 120 | } |
| 121 | |
| 122 | $next_retry_after = (int) ( $state['next_retry_after'] ?? 0 ); |
| 123 | |
| 124 | if ( time() >= $next_retry_after ) { |
| 125 | return null; |
| 126 | } |
| 127 | |
| 128 | // A failure can arrive on a 200 response, which REST would send as a success. |
| 129 | $http_status = (int) ( $state['http_status'] ?? 0 ); |
| 130 | |
| 131 | return new WP_Error( |
| 132 | $state['error_code'], |
| 133 | $state['error_message'] ?? '', |
| 134 | array( |
| 135 | 'suppressed' => true, |
| 136 | 'status' => $http_status >= 400 ? $http_status : 400, |
| 137 | 'next_retry_after' => $next_retry_after, |
| 138 | ) |
| 139 | ); |
| 140 | } |
| 141 | |
| 142 | /** |
| 143 | * Record a failed registration attempt. |
| 144 | * |
| 145 | * @param WP_Error $error The error the attempt failed with. |
| 146 | * @param array|WP_Error|null $response The raw HTTP response, when there was one. Used to read the `Retry-After` header. |
| 147 | * @param string|null $plugin_slug Slug of the plugin that triggered the attempt, for tracking. |
| 148 | * |
| 149 | * @return void |
| 150 | */ |
| 151 | public static function record( WP_Error $error, $response = null, $plugin_slug = null ) { |
| 152 | $previous = self::get(); |
| 153 | |
| 154 | $error_code = (string) $error->get_error_code(); |
| 155 | $error_message = Utils::get_registration_error_description( $error_code, (string) $error->get_error_message() ); |
| 156 | $http_status = self::extract_http_status( $error, $response ); |
| 157 | $is_terminal = self::is_terminal( $error_code ); |
| 158 | $is_rate_limited = self::RATE_LIMITED_ERROR_CODE === $error_code; |
| 159 | |
| 160 | $attempts = 1; |
| 161 | |
| 162 | if ( null !== $previous ) { |
| 163 | // A failure inside an open window overlapped, or was forced past, the attempt |
| 164 | // that opened it, so it is not another step up the schedule. Nor is the rate limit. |
| 165 | $window_open = time() < (int) ( $previous['next_retry_after'] ?? 0 ); |
| 166 | $attempts = (int) $previous['attempts'] + ( $window_open || $is_rate_limited ? 0 : 1 ); |
| 167 | |
| 168 | // Leaving a terminal error means the owner changed something: start the schedule over. |
| 169 | if ( ! empty( $previous['terminal'] ) && ! $is_terminal && ! $is_rate_limited ) { |
| 170 | $attempts = 1; |
| 171 | } |
| 172 | } |
| 173 | |
| 174 | // A rate-limited response usually masks a reason WordPress.com has already |
| 175 | // returned; keep showing a terminal one, but still push the next attempt out. |
| 176 | // A transient one is left to be replaced: by then it is stale, and "too many |
| 177 | // attempts" is the accurate message for the user who caused the limit. |
| 178 | if ( $is_rate_limited && null !== $previous && ! empty( $previous['terminal'] ) ) { |
| 179 | $error_code = $previous['error_code']; |
| 180 | $error_message = $previous['error_message'] ?? ''; |
| 181 | $http_status = $previous['http_status'] ?? null; |
| 182 | $is_terminal = true; |
| 183 | } |
| 184 | |
| 185 | $state = array( |
| 186 | 'error_code' => $error_code, |
| 187 | 'error_message' => $error_message, |
| 188 | 'http_status' => $http_status, |
| 189 | 'attempts' => $attempts, |
| 190 | 'last_attempt_at' => time(), |
| 191 | 'next_retry_after' => time() + self::backoff_delay( $attempts, $is_terminal, $response, $http_status, $error_code ), |
| 192 | 'terminal' => $is_terminal, |
| 193 | 'fingerprint' => self::fingerprint(), |
| 194 | ); |
| 195 | |
| 196 | update_option( self::OPTION, $state, false ); |
| 197 | |
| 198 | $is_new_terminal_failure = $is_terminal && ( null === $previous || empty( $previous['terminal'] ) ); |
| 199 | |
| 200 | if ( $is_new_terminal_failure ) { |
| 201 | self::track( |
| 202 | 'jpc_register_terminal_failure', |
| 203 | array( |
| 204 | 'error_code' => $error_code, |
| 205 | 'http_status' => $http_status, |
| 206 | 'attempts' => $attempts, |
| 207 | ), |
| 208 | $plugin_slug |
| 209 | ); |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | /** |
| 214 | * Record that an attempt was blocked by the stored state. |
| 215 | * |
| 216 | * Rate-limited to once per backoff window so that a site retrying on every admin |
| 217 | * request does not trade a request storm for an event storm. |
| 218 | * |
| 219 | * @param WP_Error $error The error returned in place of the attempt. |
| 220 | * @param string|null $plugin_slug Slug of the plugin that triggered the attempt. |
| 221 | * |
| 222 | * @return void |
| 223 | */ |
| 224 | public static function record_suppressed_attempt( WP_Error $error, $plugin_slug = null ) { |
| 225 | if ( get_transient( self::SUPPRESSION_TRACKED_TRANSIENT ) ) { |
| 226 | return; |
| 227 | } |
| 228 | |
| 229 | $data = $error->get_error_data(); |
| 230 | $next_retry_after = is_array( $data ) && isset( $data['next_retry_after'] ) ? (int) $data['next_retry_after'] : 0; |
| 231 | $window = $next_retry_after > time() ? $next_retry_after - time() : self::MAX_BACKOFF; |
| 232 | |
| 233 | set_transient( self::SUPPRESSION_TRACKED_TRANSIENT, 1, min( $window, self::MAX_BACKOFF ) ); |
| 234 | |
| 235 | self::track( |
| 236 | 'jpc_register_retry_suppressed', |
| 237 | array( |
| 238 | 'error_code' => (string) $error->get_error_code(), |
| 239 | ), |
| 240 | $plugin_slug |
| 241 | ); |
| 242 | } |
| 243 | |
| 244 | /** |
| 245 | * Forget the stored failure. |
| 246 | * |
| 247 | * @return void |
| 248 | */ |
| 249 | public static function clear() { |
| 250 | delete_option( self::OPTION ); |
| 251 | delete_transient( self::SUPPRESSION_TRACKED_TRANSIENT ); |
| 252 | } |
| 253 | |
| 254 | /** |
| 255 | * Whether an error code can ever be resolved by trying again. |
| 256 | * |
| 257 | * Unrecognized codes are treated as transient, so a new server-side error does |
| 258 | * not silently become permanent. |
| 259 | * |
| 260 | * @param string $error_code The error code. |
| 261 | * |
| 262 | * @return bool |
| 263 | */ |
| 264 | public static function is_terminal( $error_code ) { |
| 265 | return in_array( $error_code, self::TERMINAL_ERROR_CODES, true ); |
| 266 | } |
| 267 | |
| 268 | /** |
| 269 | * Work out how long to wait before the next automatic attempt. |
| 270 | * |
| 271 | * @param int $attempts Number of steps up the backoff schedule, including this one. |
| 272 | * @param bool $is_terminal Whether the error describes the site's environment. |
| 273 | * @param array|WP_Error|null $response The raw HTTP response, when there was one. |
| 274 | * @param int|null $http_status The HTTP status code of the response. |
| 275 | * @param string $error_code The error code of the failure. |
| 276 | * |
| 277 | * @return int Delay in seconds. |
| 278 | */ |
| 279 | private static function backoff_delay( $attempts, $is_terminal, $response, $http_status, $error_code = '' ) { |
| 280 | if ( $is_terminal ) { |
| 281 | $delay = self::MAX_BACKOFF; |
| 282 | } elseif ( self::RATE_LIMITED_ERROR_CODE === $error_code ) { |
| 283 | // The response carries no Retry-After header, so assume the hourly window. |
| 284 | $delay = HOUR_IN_SECONDS; |
| 285 | } else { |
| 286 | $schedule = self::backoff_schedule(); |
| 287 | $delay = $schedule[ min( max( $attempts, 1 ), count( $schedule ) ) - 1 ]; |
| 288 | } |
| 289 | |
| 290 | // Retry-After is a floor: the server knows the window actually in effect. |
| 291 | if ( 429 === $http_status ) { |
| 292 | $delay = max( $delay, self::retry_after_seconds( $response ) ); |
| 293 | } |
| 294 | |
| 295 | $delay = min( $delay, self::MAX_BACKOFF ); |
| 296 | |
| 297 | // Spread out sites that failed at the same moment. |
| 298 | return $delay + wp_rand( 0, (int) ceil( $delay * 0.1 ) ); |
| 299 | } |
| 300 | |
| 301 | /** |
| 302 | * Read the `Retry-After` response header. |
| 303 | * |
| 304 | * @param array|WP_Error|null $response The raw HTTP response. |
| 305 | * |
| 306 | * @return int Seconds to wait, or 0 if the header is absent or unusable. |
| 307 | */ |
| 308 | private static function retry_after_seconds( $response ) { |
| 309 | if ( ! is_array( $response ) ) { |
| 310 | return 0; |
| 311 | } |
| 312 | |
| 313 | $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); |
| 314 | |
| 315 | if ( is_array( $retry_after ) ) { |
| 316 | $retry_after = reset( $retry_after ); |
| 317 | } |
| 318 | |
| 319 | if ( ! is_string( $retry_after ) && ! is_numeric( $retry_after ) ) { |
| 320 | return 0; |
| 321 | } |
| 322 | |
| 323 | $retry_after = trim( (string) $retry_after ); |
| 324 | |
| 325 | if ( is_numeric( $retry_after ) ) { |
| 326 | return max( 0, (int) $retry_after ); |
| 327 | } |
| 328 | |
| 329 | // The header may also be an HTTP date. |
| 330 | $timestamp = strtotime( $retry_after ); |
| 331 | |
| 332 | return $timestamp ? max( 0, $timestamp - time() ) : 0; |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Determine the HTTP status the attempt failed with. |
| 337 | * |
| 338 | * @param WP_Error $error The error the attempt failed with. |
| 339 | * @param array|WP_Error|null $response The raw HTTP response, when there was one. |
| 340 | * |
| 341 | * @return int|null |
| 342 | */ |
| 343 | private static function extract_http_status( WP_Error $error, $response ) { |
| 344 | if ( is_array( $response ) ) { |
| 345 | $code = wp_remote_retrieve_response_code( $response ); |
| 346 | if ( $code ) { |
| 347 | return (int) $code; |
| 348 | } |
| 349 | } |
| 350 | |
| 351 | // Most registration errors carry the status code as their error data. |
| 352 | $data = $error->get_error_data(); |
| 353 | |
| 354 | return is_numeric( $data ) ? (int) $data : null; |
| 355 | } |
| 356 | |
| 357 | /** |
| 358 | * Fingerprint of the site addresses the stored failure refers to. |
| 359 | * |
| 360 | * Not Urls::site_url(): it follows the current request's scheme and writes its scheme |
| 361 | * history on every call, so HTTP and HTTPS requests would disagree. |
| 362 | * |
| 363 | * @return string |
| 364 | */ |
| 365 | private static function fingerprint() { |
| 366 | $without_scheme = static function ( $url ) { |
| 367 | return preg_replace( '#^[a-z][a-z0-9+.\-]*://#i', '', untrailingslashit( (string) $url ) ); |
| 368 | }; |
| 369 | |
| 370 | return md5( $without_scheme( Urls::get_raw_url( 'siteurl' ) ) . '|' . $without_scheme( Urls::get_raw_url( 'home' ) ) ); |
| 371 | } |
| 372 | |
| 373 | /** |
| 374 | * Send a Tracks event. |
| 375 | * |
| 376 | * @param string $event_type Event name, without the `jetpack_` prefix. |
| 377 | * @param array $data Event properties. |
| 378 | * @param string|null $plugin_slug Slug of the plugin that triggered the attempt. |
| 379 | * |
| 380 | * @return void |
| 381 | */ |
| 382 | private static function track( $event_type, array $data, $plugin_slug ) { |
| 383 | if ( null !== $plugin_slug ) { |
| 384 | $data['plugin_slug'] = $plugin_slug; |
| 385 | } |
| 386 | |
| 387 | ( new Tracking() )->record_user_event( $event_type, $data ); |
| 388 | } |
| 389 | } |