Commit 0485051b1f6 for woocommerce

commit 0485051b1f601bbcfd74207be99f1f700cd211a4
Author: Thilina Pituwala <thilina.hasantha@gmail.com>
Date:   Tue Aug 18 18:21:35 2026 +1000

    Respect WooCommerce.com rate limits: back off on 429 for Helper API update-check and subscriptions (#67004)

    * Class that implement the backoff logic for 429 responses from WooCommerce.com for update-check and subscription API requests.

    * Include API backoff class and clear all API backoff transients when refresh button is clicked.

    * Implement backoff logic for update-check api call.

    * Clear api backoff before installations.

    * Wire the WooCommerce.com subscriptions endpoint (WC_Helper::get_subscriptions) into WC_Helper_API_Backoff: skip the call while rate limited, record a backoff on a 429, and clear it on a successful response.

    * Add unit tests for WC_Helper_API_Backoff Retry-After handling.

    * Adding changelog entry.

    * Centralize refresh-request detection in WC_Helper_API_Backoff::is_refresh_request().

    * Type record_from_response() to array and guard the 429 call sites accordingly

    * Add tests for refresh-request bypass and clear_all() in WC_Helper_API_Backoff.

    * Remove redundant backoff clear on successful Helper API responses

    The clear() call on the success path is unreachable with a live backoff: a rate-limited request returns early at the is_rate_limited() guard, a refresh request has already been cleared inside that guard, and an expired window is gone anyway since the transient's TTL and its stored value are set to the same moment. It was also wrong under concurrency — a request that succeeds while another records a 429 would wipe the freshly recorded window.

    * Fix error caching overriding the Helper API rate-limit backoff

      On a 429 the error result is no longer cached. The subscriptions cache
      stored an empty list for 15 minutes and the update-check cache stored an
      empty product set for 12 hours; both outlived a shorter Retry-After
      window and decided when the next call actually happened, which made the
      Retry-After handling largely cosmetic. The backoff transient is now the
      only gate, so the wait matches what WooCommerce.com asked for.

      Update-check had a second problem: the 429 write replaced the cached
      products with an empty set, discarding the data the backoff branch
      serves while waiting. It now returns the previous cache untouched.

diff --git a/plugins/woocommerce/changelog/add-helper-api-rate-limit-backoff b/plugins/woocommerce/changelog/add-helper-api-rate-limit-backoff
new file mode 100644
index 00000000000..e71697c6c60
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-helper-api-rate-limit-backoff
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add rate-limit (HTTP 429) backoff for WooCommerce.com update-check and subscriptions API requests, respecting the Retry-After header.
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php
new file mode 100644
index 00000000000..4bbb55a0585
--- /dev/null
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php
@@ -0,0 +1,208 @@
+<?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;
+	}
+
+	/**
+	 * 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 );
+		}
+	}
+}
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
index 0ea1afd85f9..18a209cb646 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -641,6 +641,21 @@ class WC_Helper_Updater {
 		return hash_equals( $hash, $data['hash'] );
 	}

+	/**
+	 * Extract the products from a cached update-check payload.
+	 *
+	 * Used on the paths that serve the previous cache rather than a fresh
+	 * response — while rate limited, and on the rate-limited response itself.
+	 *
+	 * @param mixed $data The data retrieved from the transient, of any shape.
+	 * @return array The cached products, or an empty array when there are none.
+	 */
+	private static function get_cached_products( $data ) {
+		return ( is_array( $data ) && isset( $data['products'] ) && is_array( $data['products'] ) )
+			? $data['products']
+			: array();
+	}
+
 	/**
 	 * Run an update check API call.
 	 *
@@ -664,6 +679,18 @@ class WC_Helper_Updater {
 			return $data['products'];
 		}

+		// If a previous update-check was rate limited (HTTP 429), honor the
+		// server's reset window and skip the remote call until it passes. This
+		// backoff is independent of the payload hash above, so a changed payload
+		// (or a flushed cache) can't slip past it — but clicking the Marketplace
+		// "Refresh" button bypasses and clears it. Return the last cached
+		// products, if any, rather than an empty set.
+		if ( WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ) ) {
+			return self::get_cached_products( $data );
+		}
+
+		$cached_data = $data;
+
 		$data = array(
 			'hash'     => $hash,
 			'updated'  => time(),
@@ -672,11 +699,7 @@ class WC_Helper_Updater {
 		);

 		// Detect if this is a manual refresh button click.
-		$request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
-		$source      = '';
-		if ( stripos( $request_uri, 'wc/v3/marketplace/refresh' ) !== false ) {
-			$source = 'refresh-button';
-		}
+		$source = WC_Helper_API_Backoff::is_refresh_request() ? 'refresh-button' : '';

 		$request_body = array( 'products' => $payload );
 		if ( ! empty( $source ) ) {
@@ -700,8 +723,20 @@ class WC_Helper_Updater {
 			);
 		}

-		if ( wp_remote_retrieve_response_code( $request ) !== 200 ) {
+		$response_code = (int) wp_remote_retrieve_response_code( $request );
+		if ( 200 !== $response_code ) {
 			$data['errors'][] = 'http-error';
+
+			// Respect server-side rate limiting: on a 429, record the reset window so
+			// we hold off on further update-check calls until then, and return the
+			// previously cached products without touching the cache. Caching this
+			// empty result for 12 hours would outlive the reset window, and it would
+			// discard the very products the backoff branch above serves while we wait.
+			if ( 429 === $response_code && is_array( $request ) ) {
+				WC_Helper_API_Backoff::record_from_response( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, $request );
+
+				return self::get_cached_products( $cached_data );
+			}
 		} else {
 			$data['products'] = json_decode( wp_remote_retrieve_body( $request ), true );
 		}
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index 572054474a8..8d92e4f021a 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -160,6 +160,7 @@ class WC_Helper {
 	protected static function includes() {
 		include_once __DIR__ . '/class-wc-helper-options.php';
 		include_once __DIR__ . '/class-wc-helper-api.php';
+		include_once __DIR__ . '/class-wc-helper-api-backoff.php';
 		include_once __DIR__ . '/class-wc-woo-update-manager-plugin.php';
 		include_once __DIR__ . '/class-wc-woo-helper-connection.php';
 		include_once __DIR__ . '/class-wc-helper-updater.php';
@@ -1142,6 +1143,10 @@ class WC_Helper {
 		self::_flush_subscriptions_cache();
 		self::_flush_updates_cache();
 		self::flush_product_usage_notice_rules_cache();
+
+		// A manual refresh resets any rate-limit backoff so the subsequent
+		// Helper API calls (e.g. update-check) are made fresh rather than skipped.
+		WC_Helper_API_Backoff::clear_all();
 	}

 	/**
@@ -1937,10 +1942,17 @@ class WC_Helper {
 			delete_transient( $cache_key );
 		}

+		// If a previous subscriptions call was rate limited (HTTP 429), honor the
+		// server's reset window and skip the remote call until it passes. A manual
+		// refresh bypasses and clears the backoff (see WC_Helper_API_Backoff).
+		if ( WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS ) ) {
+			return array();
+		}
+
 		try {
 			$request_uri = wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
 			$source      = '';
-			if ( false !== stripos( $request_uri, 'wc/v3/marketplace/refresh' ) ) :
+			if ( WC_Helper_API_Backoff::is_refresh_request() ) :
 				$source = 'refresh-button';
 			elseif ( false !== stripos( $request_uri, 'my-subscriptions' ) ) :
 				$source = 'my-subscriptions';
@@ -1973,7 +1985,16 @@ class WC_Helper {

 			$code = wp_remote_retrieve_response_code( $request );
 			if ( 200 !== $code ) {
-				set_transient( $cache_key, array(), 15 * MINUTE_IN_SECONDS );
+				// Respect server-side rate limiting: on a 429, record the reset window
+				// so we hold off on further subscriptions calls until then, and leave
+				// the cache alone. Caching an empty list here would outlive a shorter
+				// reset window and keep the site on an empty subscription list after
+				// WooCommerce.com already allows a retry. The backoff is the gate.
+				if ( 429 === (int) $code ) {
+					WC_Helper_API_Backoff::record_from_response( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS, $request );
+				} else {
+					set_transient( $cache_key, array(), 15 * MINUTE_IN_SECONDS );
+				}

 				throw new Exception( self::get_message_for_response_code( $code ), $code );
 			}
diff --git a/plugins/woocommerce/includes/wccom-site/installation/installation-steps/class-wc-wccom-site-installation-step-get-product-info.php b/plugins/woocommerce/includes/wccom-site/installation/installation-steps/class-wc-wccom-site-installation-step-get-product-info.php
index 11fb7ee8ccc..e50788d950c 100644
--- a/plugins/woocommerce/includes/wccom-site/installation/installation-steps/class-wc-wccom-site-installation-step-get-product-info.php
+++ b/plugins/woocommerce/includes/wccom-site/installation/installation-steps/class-wc-wccom-site-installation-step-get-product-info.php
@@ -103,6 +103,13 @@ class WC_WCCOM_Site_Installation_Step_Get_Product_Info implements WC_WCCOM_Site_
 	 * @throws Installer_Error Installer Error.
 	 */
 	protected function get_wccom_download_url( $product_id ) {
+		// An install/update is an explicit user action that force-refreshes the
+		// subscription and update data below. Clear any Helper API rate-limit
+		// backoff first so those calls are made fresh rather than skipped — a
+		// suppressed response would otherwise surface as a misleading
+		// "missing subscription" or "missing package" install failure.
+		WC_Helper_API_Backoff::clear_all();
+
 		WC_Helper::_flush_subscriptions_cache();

 		if ( ! WC_Helper::has_product_subscription( $product_id ) ) {
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-api-backoff-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-api-backoff-test.php
new file mode 100644
index 00000000000..6252538ebb2
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-api-backoff-test.php
@@ -0,0 +1,226 @@
+<?php
+/**
+ * Unit tests for WC_Helper_API_Backoff class
+ *
+ * @package WooCommerce\Tests\Admin\Helper
+ */
+
+declare(strict_types=1);
+
+/**
+ * Class WC_Helper_API_Backoff_Test
+ */
+class WC_Helper_API_Backoff_Test extends WC_Unit_Test_Case {
+
+	/**
+	 * The REQUEST_URI value present before the test ran, restored on tear down.
+	 *
+	 * @var string|null
+	 */
+	private $original_request_uri;
+
+	/**
+	 * Set up before each test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		// Saved raw to restore verbatim on tear down; not used for any logic.
+		$this->original_request_uri = $_SERVER['REQUEST_URI'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
+		// Default to a non-refresh request so is_rate_limited() is not bypassed.
+		$_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=wc-admin';
+
+		$this->cleanup_transients();
+	}
+
+	/**
+	 * Tear down after each test.
+	 */
+	public function tearDown(): void {
+		$this->cleanup_transients();
+
+		if ( null === $this->original_request_uri ) {
+			unset( $_SERVER['REQUEST_URI'] );
+		} else {
+			$_SERVER['REQUEST_URI'] = $this->original_request_uri;
+		}
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Clear the backoff transients for every known request type.
+	 */
+	private function cleanup_transients() {
+		delete_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
+		delete_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS );
+	}
+
+	/**
+	 * Build a mocked rate-limited (429) response with the given headers.
+	 *
+	 * @param array $headers Response headers (lowercase keys).
+	 * @return array A response array in the shape returned by wp_remote_post().
+	 */
+	private function make_rate_limited_response( array $headers ) {
+		return array(
+			'headers'  => $headers,
+			'response' => array(
+				'code'    => 429,
+				'message' => 'Too Many Requests',
+			),
+			'body'     => '{"code":"wccom_rest_limit_reached","message":"You reached your API request limit.","data":{"status":429}}',
+		);
+	}
+
+	/**
+	 * Read the stored backoff expiry timestamp for a request type.
+	 *
+	 * @param string $request_type The request type.
+	 * @return int The stored expiry timestamp, or 0 when not set.
+	 */
+	private function get_backoff_expiry( $request_type ) {
+		return (int) get_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . $request_type );
+	}
+
+	/**
+	 * @testdox Should derive the backoff window from the Retry-After header, capped at max, defaulting when absent.
+	 * @dataProvider retry_after_provider
+	 *
+	 * @param string $request_type     The request type to record against.
+	 * @param array  $headers          The response headers.
+	 * @param int    $expected_seconds The expected backoff window in seconds.
+	 */
+	public function test_record_from_response_honors_retry_after( string $request_type, array $headers, int $expected_seconds ): void {
+		$start = time();
+
+		WC_Helper_API_Backoff::record_from_response( $request_type, $this->make_rate_limited_response( $headers ) );
+
+		$end    = time();
+		$expiry = $this->get_backoff_expiry( $request_type );
+
+		// The stored expiry is now + window; now was captured between $start and $end.
+		$this->assertGreaterThanOrEqual(
+			$start + $expected_seconds,
+			$expiry,
+			"Backoff window should be at least {$expected_seconds}s for {$request_type}"
+		);
+		$this->assertLessThanOrEqual(
+			$end + $expected_seconds,
+			$expiry,
+			"Backoff window should be at most {$expected_seconds}s for {$request_type}"
+		);
+		$this->assertTrue(
+			WC_Helper_API_Backoff::is_rate_limited( $request_type ),
+			'Recording a 429 should put the request type into a backoff window'
+		);
+	}
+
+	/**
+	 * Data provider for the Retry-After window cases.
+	 *
+	 * update-check bounds: default 1h (3600), max 3h (10800).
+	 * subscriptions bounds: default 15m (900), max 3h (10800).
+	 *
+	 * @return array
+	 */
+	public function retry_after_provider() {
+		$update_check  = WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK;
+		$subscriptions = WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS;
+
+		return array(
+			'retry-after within bounds is honored'         => array( $update_check, array( 'retry-after' => '5000' ), 5000 ),
+			'global short retry-after is honored, not floored' => array( $update_check, array( 'retry-after' => '55' ), 55 ),
+			'retry-after above max is capped'              => array( $update_check, array( 'retry-after' => '20000' ), 10800 ),
+			'missing retry-after uses per-type default'    => array( $update_check, array(), 3600 ),
+			'subscriptions missing retry-after uses its default' => array( $subscriptions, array(), 900 ),
+			'zero retry-after is treated as absent'        => array( $update_check, array( 'retry-after' => '0' ), 3600 ),
+			'non-numeric retry-after is treated as absent' => array( $update_check, array( 'retry-after' => 'soon' ), 3600 ),
+			// X-RateLimit-Reset is intentionally ignored; only Retry-After drives the window.
+			'reset without retry-after falls back to default' => array( $update_check, array( 'x-ratelimit-reset' => '9999999999' ), 3600 ),
+			'retry-after wins when reset is also present'  => array(
+				$update_check,
+				array(
+					'retry-after'       => '55',
+					'x-ratelimit-reset' => '9999999999',
+				),
+				55,
+			),
+		);
+	}
+
+	/**
+	 * @testdox Should no longer be rate limited once the backoff is cleared.
+	 */
+	public function test_clear_removes_backoff(): void {
+		WC_Helper_API_Backoff::record_from_response(
+			WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK,
+			$this->make_rate_limited_response( array( 'retry-after' => '55' ) )
+		);
+		$this->assertTrue(
+			WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+			'Precondition: the request type should be rate limited after recording a 429'
+		);
+
+		WC_Helper_API_Backoff::clear( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
+
+		$this->assertFalse(
+			WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+			'Clearing the backoff should lift the rate limit'
+		);
+	}
+
+	/**
+	 * @testdox Should bypass and clear the backoff during a Marketplace refresh request.
+	 */
+	public function test_refresh_request_bypasses_and_clears_backoff(): void {
+		$type = WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK;
+		WC_Helper_API_Backoff::record_from_response( $type, $this->make_rate_limited_response( array( 'retry-after' => '55' ) ) );
+
+		$_SERVER['REQUEST_URI'] = '/wp-json/wc/v3/marketplace/refresh';
+
+		$this->assertTrue(
+			WC_Helper_API_Backoff::is_refresh_request(),
+			'The refresh REST route should be detected as a refresh request'
+		);
+		$this->assertFalse(
+			WC_Helper_API_Backoff::is_rate_limited( $type ),
+			'A refresh request should bypass the backoff'
+		);
+		$this->assertFalse(
+			get_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . $type ),
+			'A refresh request should also clear the stored backoff'
+		);
+	}
+
+	/**
+	 * @testdox Should not treat a non-refresh request as a refresh.
+	 */
+	public function test_non_refresh_request_is_not_a_refresh(): void {
+		$_SERVER['REQUEST_URI'] = '/wp-admin/admin.php?page=wc-admin&tab=my-subscriptions';
+
+		$this->assertFalse(
+			WC_Helper_API_Backoff::is_refresh_request(),
+			'A non-refresh admin request should not be detected as a refresh'
+		);
+	}
+
+	/**
+	 * @testdox Should clear the backoff for every known request type.
+	 */
+	public function test_clear_all_clears_every_request_type(): void {
+		WC_Helper_API_Backoff::record_from_response( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, $this->make_rate_limited_response( array( 'retry-after' => '55' ) ) );
+		WC_Helper_API_Backoff::record_from_response( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS, $this->make_rate_limited_response( array( 'retry-after' => '55' ) ) );
+
+		WC_Helper_API_Backoff::clear_all();
+
+		$this->assertFalse(
+			WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+			'clear_all() should clear the update-check backoff'
+		);
+		$this->assertFalse(
+			WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS ),
+			'clear_all() should clear the subscriptions backoff'
+		);
+	}
+}
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
index c539e851264..c0ea5314d34 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-test.php
@@ -33,6 +33,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 		delete_transient( '_woocommerce_helper_product_usage_notice_rules' );
 		delete_transient( '_woocommerce_helper_notices' );
 		delete_transient( '_woocommerce_helper_connection_data' );
+		delete_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS );
 	}

 	/**
@@ -199,6 +200,98 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox get_subscriptions should record a backoff on a 429 without caching an empty subscription list.
+	 */
+	public function test_get_subscriptions_does_not_cache_empty_list_when_rate_limited(): void {
+		$previous_auth = WC_Helper_Options::get( 'auth', array() );
+		$previous_log  = WC_Helper::$log;
+		$http_mock     = static function () {
+			return array(
+				'headers'  => array( 'retry-after' => '60' ),
+				'response' => array(
+					'code'    => 429,
+					'message' => 'Too Many Requests',
+				),
+				'body'     => '{"code":"wccom_rest_limit_reached","data":{"status":429}}',
+			);
+		};
+
+		WC_Helper::$log = $this->createMock( WC_Logger_Interface::class );
+		WC_Helper_Options::update(
+			'auth',
+			array(
+				'access_token'        => 'test-token',
+				'access_token_secret' => 'test-secret',
+			)
+		);
+		add_filter( 'pre_http_request', $http_mock );
+
+		try {
+			$result = WC_Helper::get_subscriptions();
+		} finally {
+			remove_filter( 'pre_http_request', $http_mock );
+			WC_Helper_Options::update( 'auth', $previous_auth );
+			WC_Helper::$log = $previous_log;
+		}
+
+		$this->assertSame( array(), $result, 'A rate-limited response should yield no subscriptions' );
+		$this->assertFalse(
+			get_transient( '_woocommerce_helper_subscriptions' ),
+			'A 429 should not cache an empty subscription list, which would outlive the backoff window'
+		);
+		$this->assertNotFalse(
+			get_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS ),
+			'A 429 should record a backoff window for the subscriptions endpoint'
+		);
+	}
+
+	/**
+	 * @testdox get_subscriptions should cache an empty list for non-rate-limit errors.
+	 */
+	public function test_get_subscriptions_caches_empty_list_for_other_errors(): void {
+		$previous_auth = WC_Helper_Options::get( 'auth', array() );
+		$previous_log  = WC_Helper::$log;
+		$http_mock     = static function () {
+			return array(
+				'response' => array(
+					'code'    => 500,
+					'message' => 'Internal Server Error',
+				),
+				'body'     => '',
+			);
+		};
+
+		WC_Helper::$log = $this->createMock( WC_Logger_Interface::class );
+		WC_Helper_Options::update(
+			'auth',
+			array(
+				'access_token'        => 'test-token',
+				'access_token_secret' => 'test-secret',
+			)
+		);
+		add_filter( 'pre_http_request', $http_mock );
+
+		try {
+			$result = WC_Helper::get_subscriptions();
+		} finally {
+			remove_filter( 'pre_http_request', $http_mock );
+			WC_Helper_Options::update( 'auth', $previous_auth );
+			WC_Helper::$log = $previous_log;
+		}
+
+		$this->assertSame( array(), $result, 'A failed response should yield no subscriptions' );
+		$this->assertSame(
+			array(),
+			get_transient( '_woocommerce_helper_subscriptions' ),
+			'A non-429 error should still cache an empty subscription list'
+		);
+		$this->assertFalse(
+			get_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS ),
+			'Only a 429 should record a backoff window'
+		);
+	}
+
 	/**
 	 * @testdox get_cached_connection_data should return false for corrupted string transient.
 	 */
diff --git a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
index c60751186d3..f83ce2947f1 100644
--- a/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/helper/class-wc-helper-updater-test.php
@@ -58,6 +58,7 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
 		delete_transient( '_woocommerce_helper_updates' );
 		delete_transient( '_woocommerce_helper_updates_count' );
 		delete_transient( '_woocommerce_helper_subscriptions' );
+		delete_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
 	}

 	/**
@@ -227,6 +228,61 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
 		$this->assertEquals( $cached_data['products'], $result, 'Result should match cached version' );
 	}

+	/**
+	 * @testdox A rate-limited update check should keep the cached products instead of caching the empty result.
+	 */
+	public function test_update_check_preserves_cache_when_rate_limited(): void {
+		$cached_data = array(
+			'hash'     => 'a-stale-hash',
+			'updated'  => time(),
+			'products' => array(
+				123 => array(
+					'version' => '1.2.3',
+					'slug'    => 'test-plugin',
+				),
+			),
+			'errors'   => array(),
+		);
+
+		set_transient( '_woocommerce_helper_updates', $cached_data, HOUR_IN_SECONDS );
+
+		$http_mock = static function () {
+			return array(
+				'headers'  => array( 'retry-after' => '60' ),
+				'response' => array(
+					'code'    => 429,
+					'message' => 'Too Many Requests',
+				),
+				'body'     => '{"code":"wccom_rest_limit_reached","data":{"status":429}}',
+			);
+		};
+		add_filter( 'pre_http_request', $http_mock );
+
+		try {
+			$result = $this->call_update_check(
+				array(
+					123 => array(
+						'product_id' => 123,
+						'file_id'    => 'abc123',
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'pre_http_request', $http_mock );
+		}
+
+		$this->assertSame( $cached_data['products'], $result, 'A rate-limited check should serve the previously cached products' );
+		$this->assertSame(
+			$cached_data,
+			get_transient( '_woocommerce_helper_updates' ),
+			'A 429 should leave the cached update data untouched rather than replacing it with an empty result'
+		);
+		$this->assertNotFalse(
+			get_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+			'A 429 should record a backoff window for the update-check endpoint'
+		);
+	}
+
 	/**
 	 * Test that _update_check refreshes cache when hash doesn't match.
 	 */