class-wc-helper-api-backoff.php
<?php
/**
* WooCommerce.com Helper API rate-limit backoff.
*
* @package WooCommerce\Admin\Helper
*/
declare(strict_types=1);
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC_Helper_API_Backoff Class
*
* Records and enforces a per-request-type backoff window when a WooCommerce.com
* Helper API endpoint responds with a rate-limit status (HTTP 429), so the site
* refrains from calling that endpoint again until the limit resets.
*
* The window is taken from the response's `Retry-After` header (delta seconds)
* and honored as-is, capped only at a per-type maximum. This covers both the
* short window of a global rate limit and the longer window of a per-endpoint
* limit. When the header is absent, a per-type default window is applied
* instead. `Retry-After` is used in preference to `X-RateLimit-Reset` because
* it is a relative delta and therefore immune to clock skew between the site
* and WooCommerce.com.
*
* A manual "Refresh" request (the Marketplace refresh button) always bypasses
* and clears the backoff so the user can force a fresh request at any time.
*/
class WC_Helper_API_Backoff {
/**
* Prefix for the transient that stores a request type's backoff expiry.
*
* @var string
*/
const TRANSIENT_PREFIX = '_woocommerce_helper_backoff_';
/**
* Request type: the WooCommerce.com update-check endpoint.
*
* @var string
*/
const REQUEST_TYPE_UPDATE_CHECK = 'update-check';
/**
* Request type: the WooCommerce.com subscriptions endpoint.
*
* @var string
*/
const REQUEST_TYPE_SUBSCRIPTIONS = 'subscriptions';
/**
* Backoff bounds per request type, in seconds.
*
* `default` is the window applied only when the response carries no usable
* rate-limit header. `max` is the ceiling that an explicit header value is
* capped at — a safety net against an unexpectedly distant reset locking the
* endpoint out. An explicit header shorter than `default` (e.g. a global
* rate limit's brief `Retry-After`) is honored as-is, not floored.
*
* @return array<string, array{default:int, max:int}>
*/
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 {
$now = time();
$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'] );
}
set_transient( self::get_transient_key( $request_type ), $now + $retry_after, $retry_after );
}
/**
* 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 );
}
}
}