*/ private static function get_all_bounds(): array { return array( self::REQUEST_TYPE_UPDATE_CHECK => array( 'default' => HOUR_IN_SECONDS, 'max' => HOUR_IN_SECONDS * 3, ), self::REQUEST_TYPE_SUBSCRIPTIONS => array( 'default' => 15 * MINUTE_IN_SECONDS, 'max' => HOUR_IN_SECONDS * 3, ), ); } /** * Backoff bounds for a single request type, in seconds. * * Unknown request types fall back to a conservative default. * * @param string $request_type The Helper API request type (e.g. 'update-check'). * @return array{default:int, max:int} */ private static function get_bounds( string $request_type ): array { $fallback = array( 'default' => HOUR_IN_SECONDS, 'max' => WEEK_IN_SECONDS, ); return self::get_all_bounds()[ $request_type ] ?? $fallback; } /** * Transient key for a request type's backoff window. * * @param string $request_type The Helper API request type (e.g. 'update-check'). * @return string */ private static function get_transient_key( string $request_type ): string { return self::TRANSIENT_PREFIX . $request_type; } /** * Whether the current request is a manual Marketplace refresh. * * @return bool */ public static function is_refresh_request(): bool { $request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized return false !== stripos( (string) $request_uri, 'wc/v3/marketplace/refresh' ); } /** * Whether a request type is currently within a backoff window. * * A manual refresh always returns false and clears any recorded backoff, so * clicking "Refresh" lets the user force a fresh request at any time. * * @param string $request_type The Helper API request type (e.g. 'update-check'). * @return bool True while the site should refrain from calling the endpoint. */ public static function is_rate_limited( string $request_type ): bool { if ( self::is_refresh_request() ) { self::clear( $request_type ); return false; } $retry_after = get_transient( self::get_transient_key( $request_type ) ); return is_numeric( $retry_after ) && (int) $retry_after > time(); } /** * Record a backoff window for a request type from a rate-limited response. * * The wait is taken from the response's rate-limit headers and honored as-is, * capped only at the request type's `max`. This respects both a global rate * limit's short `Retry-After` and a per-endpoint limit's longer window. When * no usable header is present (a malformed 429), the per-type `default` is * used so there is always a sensible backoff. * * @param string $request_type The Helper API request type (e.g. 'update-check'). * @param array $response The rate-limited (HTTP 429) response from the Helper API call. * @return void */ public static function record_from_response( string $request_type, array $response ): void { $bounds = self::get_bounds( $request_type ); $retry_after = self::get_retry_after_from_headers( $response ); if ( null === $retry_after ) { // No Retry-After header — apply the per-type default window. $retry_after = $bounds['default']; } else { // Honor the server's directive, but never longer than the per-type // maximum (a safety net against an erroneous far value). $retry_after = min( $retry_after, $bounds['max'] ); } self::record( $request_type, $retry_after ); } /** * Record a backoff window of a fixed length for a request type, e.g. after a failed request. * * @since 11.3.0 * * @param string $request_type The Helper API request type (e.g. 'update-check'). * @param int $seconds Length of the window, in seconds. * @return void */ public static function record( string $request_type, int $seconds ): void { if ( $seconds <= 0 ) { return; } set_transient( self::get_transient_key( $request_type ), time() + $seconds, $seconds ); } /** * Extract the wait, in seconds, from a rate-limited response's `Retry-After` * header. Non-positive or missing values are treated as absent. * * @param array $response The rate-limited (HTTP 429) response from the Helper API call. * @return int|null Seconds to wait, or null when the header is absent/invalid. */ private static function get_retry_after_from_headers( array $response ): ?int { $retry_after = wp_remote_retrieve_header( $response, 'retry-after' ); if ( is_numeric( $retry_after ) && (int) $retry_after > 0 ) { return (int) $retry_after; } return null; } /** * Seconds remaining in a request type's backoff window. * * Lets callers align a user-facing message with the window the site is * actually observing, so a rate-limit notice stays visible for exactly as * long as requests are being suppressed. * * @since 11.2.0 * * @param string $request_type The Helper API request type (e.g. 'update-check'). * @return int|null Seconds until the window expires, or null when not rate limited. */ public static function get_retry_after( string $request_type ): ?int { $expires_at = get_transient( self::get_transient_key( $request_type ) ); if ( ! is_numeric( $expires_at ) ) { return null; } $remaining = (int) $expires_at - time(); return $remaining > 0 ? $remaining : null; } /** * Clear any recorded backoff for a request type. * * @param string $request_type The Helper API request type (e.g. 'update-check'). * @return void */ public static function clear( string $request_type ): void { delete_transient( self::get_transient_key( $request_type ) ); } /** * Clear the recorded backoff for every known request type. * * Called when the user clicks the Marketplace "Refresh" button so a manual * refresh always resets rate-limit backoffs and forces fresh Helper API * calls. * * @return void */ public static function clear_all(): void { foreach ( array_keys( self::get_all_bounds() ) as $request_type ) { self::clear( $request_type ); } } }