Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.64% covered (success)
96.64%
115 / 119
83.33% covered (warning)
83.33%
10 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Registration_Failure
96.64% covered (success)
96.64%
115 / 119
83.33% covered (warning)
83.33%
10 / 12
48
0.00% covered (danger)
0.00%
0 / 1
 backoff_schedule
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 get_suppressed_error
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 record
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
1 / 1
13
 record_suppressed_attempt
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 clear
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 is_terminal
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 backoff_delay
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 retry_after_seconds
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 extract_http_status
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 fingerprint
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 track
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
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
8namespace Automattic\Jetpack\Connection;
9
10use Automattic\Jetpack\Tracking;
11use 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 */
23class 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}