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