Commit c0ab02812c7 for woocommerce

commit c0ab02812c7734f4f34da6f8e0f33e27b4842690
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date:   Tue Oct 6 15:28:18 2026 +0200

    Keep marketplace extension updates when a Helper update check fails (#69160)

    * Keep marketplace extension updates when an update check fails

    * Add changelog entry for the helper update-check fix

    * Drop forced auto-update flags from cached products served after a failed check

    * Keep cached updates through a manual refresh and drop them when the connection is rejected

    * Back off on every failure except a rejected connection and keep forced auto-updates through a refresh

    * Treat an update-check response that isn't JSON as a temporary failure

    * Renew the cached updates on a temporary failure so they outlast the backoff

    * Fall back to a longer-lived copy of the last successful update check instead of renewing the cache on failure

    * Drop forced auto-updates from the last successful check copy

    * Keep a single update cache for a week and treat it as fresh for 12 hours

    ---------

    Co-authored-by: Thilina Pituwala <thilina.hasantha@gmail.com>

diff --git a/plugins/woocommerce/changelog/wccom-2804-keep-extension-updates-on-failed-check b/plugins/woocommerce/changelog/wccom-2804-keep-extension-updates-on-failed-check
new file mode 100644
index 00000000000..c056085fcb8
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-2804-keep-extension-updates-on-failed-check
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep WooCommerce.com extension updates visible when an update check fails, instead of hiding them for 12 hours.
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
index 84e4b20ce7c..733324474f3 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-api-backoff.php
@@ -16,7 +16,8 @@ if ( ! defined( 'ABSPATH' ) ) {
  *
  * 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.
+ * refrains from calling that endpoint again until the limit resets. Callers can
+ * also record a fixed window after other failed requests with record().
  *
  * 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
@@ -148,7 +149,6 @@ class WC_Helper_API_Backoff {
 	 * @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 );
@@ -162,7 +162,24 @@ class WC_Helper_API_Backoff {
 			$retry_after = min( $retry_after, $bounds['max'] );
 		}

-		set_transient( self::get_transient_key( $request_type ), $now + $retry_after, $retry_after );
+		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 );
 	}

 	/**
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 1e6ca483bbc..f121a51637a 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-updater.php
@@ -1014,8 +1014,8 @@ class WC_Helper_Updater {
 	/**
 	 * Validates cached update data and checks if it matches the expected hash.
 	 *
-	 * Ensures the cached data is properly structured and corresponds to the current
-	 * payload to prevent fatal errors and avoid stale cache returns.
+	 * Ensures the cached data is properly structured, corresponds to the current
+	 * payload, and was not expired by a refresh, to avoid fatal errors and stale cache returns.
 	 *
 	 * @since 10.3.6
 	 *
@@ -1024,7 +1024,7 @@ class WC_Helper_Updater {
 	 * @return bool True if the data is valid and hash matches, false otherwise.
 	 */
 	private static function should_use_cached_update_data( $data, $hash ) {
-		if ( ! is_array( $data ) ) {
+		if ( ! is_array( $data ) || ! empty( $data['expired'] ) || ! self::is_fresh_update_data( $data ) ) {
 			return false;
 		}

@@ -1039,19 +1039,60 @@ class WC_Helper_Updater {
 		return hash_equals( $hash, $data['hash'] );
 	}

+	/**
+	 * Whether cached update data is recent enough to use without a new check. The cache is kept
+	 * for a week so a failed check has something to fall back on, but it's only fresh for 12 hours.
+	 *
+	 * @param array $data The cached update data.
+	 * @return bool
+	 */
+	private static function is_fresh_update_data( array $data ): bool {
+		return isset( $data['updated'] ) && is_numeric( $data['updated'] ) && (int) $data['updated'] > time() - 12 * HOUR_IN_SECONDS;
+	}
+
 	/**
 	 * 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.
+	 * Used while backing off and when the update check fails. The server-side `autoupdate`
+	 * decisions are kept only when the cache is fresh and was made for the current payload.
 	 *
-	 * @param mixed $data The data retrieved from the transient, of any shape.
+	 * @param mixed  $data The data retrieved from the transient, of any shape.
+	 * @param string $hash The hash of the current payload.
 	 * @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();
+	private static function get_cached_products( $data, string $hash ) {
+		if ( ! is_array( $data ) || ! isset( $data['products'] ) || ! is_array( $data['products'] ) ) {
+			return array();
+		}
+
+		$products = $data['products'];
+		if ( isset( $data['hash'] ) && is_string( $data['hash'] ) && hash_equals( $hash, $data['hash'] ) && self::is_fresh_update_data( $data ) ) {
+			return $products;
+		}
+
+		foreach ( $products as $product_id => $product ) {
+			if ( is_array( $product ) ) {
+				unset( $products[ $product_id ]['autoupdate'] );
+			}
+		}
+
+		return $products;
+	}
+
+	/**
+	 * Whether a failed update check may succeed if retried. Only a rejected connection is not:
+	 * a 401 or 403, or a WP_Error for missing local credentials.
+	 *
+	 * @param array|WP_Error $request       The update-check response.
+	 * @param int            $response_code The HTTP status code, 0 when there is none.
+	 * @return bool
+	 */
+	private static function is_temporary_failure( $request, int $response_code ): bool {
+		if ( is_wp_error( $request ) ) {
+			return 'authentication' !== $request->get_error_code();
+		}
+
+		return ! in_array( $response_code, array( 401, 403 ), true );
 	}

 	/**
@@ -1081,14 +1122,14 @@ 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.
+		/*
+		 * After a failed or rate-limited update-check, skip the remote call until the
+		 * backoff window passes. This check ignores the payload hash, so a changed
+		 * payload or flushed cache can't slip past it, but the Marketplace "Refresh"
+		 * button clears it. Return the last cached products, if any.
+		 */
 		if ( WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ) ) {
-			return self::get_cached_products( $data );
+			return self::get_cached_products( $data, $hash );
 		}

 		$cached_data = $data;
@@ -1126,24 +1167,25 @@ class WC_Helper_Updater {
 		}

 		$response_code = (int) wp_remote_retrieve_response_code( $request );
-		if ( 200 !== $response_code ) {
+		$products      = 200 === $response_code ? json_decode( wp_remote_retrieve_body( $request ), true ) : null;
+		if ( ! is_array( $products ) ) {
 			$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 );
+			// On an outage (including a 200 that isn't JSON), leave the cache untouched and back off, so a failed check doesn't hide extension updates for 12 hours.
+			if ( self::is_temporary_failure( $request, $response_code ) ) {
+				if ( 429 === $response_code && is_array( $request ) ) {
+					WC_Helper_API_Backoff::record_from_response( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, $request );
+				} else {
+					WC_Helper_API_Backoff::record( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, 15 * MINUTE_IN_SECONDS );
+				}

-				return self::get_cached_products( $cached_data );
+				return self::get_cached_products( $cached_data, $hash );
 			}
 		} else {
-			$data['products'] = json_decode( wp_remote_retrieve_body( $request ), true );
+			$data['products'] = $products;
 		}

-		set_transient( $cache_key, $data, 12 * HOUR_IN_SECONDS );
+		set_transient( $cache_key, $data, WEEK_IN_SECONDS );
 		return $data['products'];
 	}

@@ -1159,7 +1201,7 @@ class WC_Helper_Updater {
 			return $count;
 		}

-		// Don't fetch any new data since this function in high-frequency.
+		// This runs often, so it only counts from cached data; a cache that's stale or expired by a refresh triggers one update check here.
 		if ( ! get_transient( '_woocommerce_helper_subscriptions' ) ) {
 			return 0;
 		}
@@ -1285,6 +1327,21 @@ class WC_Helper_Updater {
 	 */
 	public static function flush_updates_cache() {
 		delete_transient( '_woocommerce_helper_updates' );
+		self::expire_updates_cache();
+	}
+
+	/**
+	 * Forces a fresh update check while keeping the cached products, so they are still served if that check fails.
+	 *
+	 * @since 11.3.0
+	 */
+	public static function expire_updates_cache(): void {
+		$data = get_transient( '_woocommerce_helper_updates' );
+		if ( is_array( $data ) && isset( $data['hash'] ) ) {
+			$data['expired'] = true;
+			set_transient( '_woocommerce_helper_updates', $data, WEEK_IN_SECONDS );
+		}
+
 		delete_transient( '_woocommerce_helper_updates_count' );
 		delete_site_transient( 'update_plugins' );
 		delete_site_transient( 'update_themes' );
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index c00ec613e76..20b37135173 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -1290,7 +1290,8 @@ class WC_Helper {
 		do_action( 'woocommerce_helper_subscriptions_refresh' );
 		self::_flush_authentication_cache();
 		self::_flush_subscriptions_cache();
-		self::_flush_updates_cache();
+		// Keep the cached updates so they're still listed if the forced check fails.
+		WC_Helper_Updater::expire_updates_cache();
 		self::flush_product_usage_notice_rules_cache();

 		// A manual refresh resets any rate-limit backoff so the subsequent
@@ -3289,6 +3290,8 @@ class WC_Helper {
 		}

 		self::_flush_subscriptions_cache();
+		// A backoff from the pre-connect (public) update-check shouldn't block the first authenticated one.
+		WC_Helper_API_Backoff::clear( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
 		self::_flush_updates_cache();
 		self::flush_product_usage_notice_rules_cache();
 	}
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 3beeb84f212..14079da2c3c 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
@@ -88,6 +88,7 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 		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 );
+		delete_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
 		delete_transient( '_woocommerce_helper_subscriptions_api_error' );
 	}

@@ -1245,4 +1246,35 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 			HOUR_IN_SECONDS
 		);
 	}
+
+	/**
+	 * @testdox Connecting the site should clear an update-check backoff so the first authenticated check runs.
+	 */
+	public function test_update_auth_option_clears_update_check_backoff(): void {
+		$previous_auth = WC_Helper_Options::get( 'auth', array() );
+		WC_Helper_API_Backoff::record( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK, 15 * MINUTE_IN_SECONDS );
+
+		$http_mock = static function () {
+			return array(
+				'response' => array(
+					'code'    => 200,
+					'message' => 'OK',
+				),
+				'body'     => '{"name":"Test","email":"test@example.com"}',
+			);
+		};
+		add_filter( 'pre_http_request', $http_mock );
+
+		try {
+			WC_Helper::update_auth_option( 'token', 'secret', 123, home_url() );
+		} finally {
+			WC_Helper_Options::update( 'auth', $previous_auth );
+			remove_filter( 'pre_http_request', $http_mock );
+		}
+
+		$this->assertFalse(
+			WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+			'Connecting should clear the update-check backoff'
+		);
+	}
 }
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 0efbb23f02d..4f7698b4291 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
@@ -325,6 +325,269 @@ class WC_Helper_Updater_Test extends WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * @testdox A failed update check should keep the cached products, without their forced auto-update, and back off for a short window.
+	 *
+	 * @testWith [500]
+	 *           [408]
+	 *           [200]
+	 *           [0]
+	 *
+	 * @param int $status HTTP status of the response (with a non-JSON body), or 0 for a transport error.
+	 */
+	public function test_update_check_preserves_cache_when_request_fails( int $status ): void {
+		$cached_data = array(
+			'hash'     => 'a-stale-hash',
+			'updated'  => time(),
+			'products' => array(
+				123 => array(
+					'version'    => '1.2.3',
+					'slug'       => 'test-plugin',
+					'autoupdate' => true,
+				),
+			),
+			'errors'   => array(),
+		);
+		set_transient( '_woocommerce_helper_updates', $cached_data, HOUR_IN_SECONDS );
+
+		$requests  = 0;
+		$http_mock = static function () use ( $status, &$requests ) {
+			++$requests;
+			return 0 === $status
+				? new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' )
+				: array(
+					'response' => array(
+						'code'    => $status,
+						'message' => get_status_header_desc( $status ),
+					),
+					'body'     => '<html><body>Bad gateway</body></html>',
+				);
+		};
+		add_filter( 'pre_http_request', $http_mock );
+
+		try {
+			$result       = $this->call_update_check(
+				array(
+					123 => array(
+						'product_id' => 123,
+						'file_id'    => 'abc123',
+						'version'    => '1.0.0',
+					),
+				)
+			);
+			$second_check = $this->call_update_check(
+				array(
+					123 => array(
+						'product_id' => 123,
+						'file_id'    => 'abc123',
+						'version'    => '1.1.0',
+					),
+				)
+			);
+		} finally {
+			remove_filter( 'pre_http_request', $http_mock );
+		}
+
+		$expected_products = array(
+			123 => array(
+				'version' => '1.2.3',
+				'slug'    => 'test-plugin',
+			),
+		);
+		$backoff_until     = (int) get_transient( WC_Helper_API_Backoff::TRANSIENT_PREFIX . WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK );
+		$this->assertSame( $expected_products, $result, 'A failed check should serve the cached products, without a forced auto-update decided for another version' );
+		$this->assertSame( $cached_data, get_transient( '_woocommerce_helper_updates' ), 'A failed check should leave the cached update data untouched' );
+		$this->assertSame( $expected_products, $second_check, 'A check inside the backoff window should serve the cached products' );
+		$this->assertSame( 1, $requests, 'A check inside the backoff window should not call the API, even after the installed version changed' );
+		$this->assertGreaterThan( time(), $backoff_until, 'A failed check should record a backoff window' );
+		$this->assertLessThanOrEqual( time() + 15 * MINUTE_IN_SECONDS, $backoff_until, 'The backoff after a failed check should be short' );
+	}
+
+	/**
+	 * @testdox A failed update check with nothing cached should return no products and not cache the empty result.
+	 */
+	public function test_update_check_without_cache_returns_nothing_when_request_fails(): void {
+		$http_mock = static function () {
+			return new WP_Error( 'http_request_failed', 'cURL error 6: Could not resolve host' );
+		};
+		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( array(), $result, 'With nothing cached there are no products to serve' );
+		$this->assertFalse( get_transient( '_woocommerce_helper_updates' ), 'The empty result should not be cached' );
+	}
+
+	/**
+	 * @testdox A failed update check after the cache went stale should keep serving it for a week, without its forced auto-updates.
+	 */
+	public function test_update_check_serves_stale_cache_when_request_fails(): void {
+		$payload  = array(
+			123 => array(
+				'product_id' => 123,
+				'file_id'    => 'abc123',
+			),
+		);
+		$products = array(
+			123 => array(
+				'version'    => '1.2.3',
+				'autoupdate' => true,
+			),
+		);
+		$fail     = false;
+
+		$http_mock = static function () use ( &$fail, $products ) {
+			return $fail
+				? new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' )
+				: array(
+					'response' => array(
+						'code'    => 200,
+						'message' => 'OK',
+					),
+					'body'     => wp_json_encode( $products ),
+				);
+		};
+		add_filter( 'pre_http_request', $http_mock );
+
+		try {
+			$fresh             = $this->call_update_check( $payload );
+			$cache_ttl         = (int) get_option( '_transient_timeout__woocommerce_helper_updates' ) - time();
+			$stale             = get_transient( '_woocommerce_helper_updates' );
+			$stale['updated'] -= 13 * HOUR_IN_SECONDS;
+			set_transient( '_woocommerce_helper_updates', $stale, WEEK_IN_SECONDS );
+			$fail           = true;
+			$after_failure  = $this->call_update_check( $payload );
+			$inside_backoff = $this->call_update_check( $payload );
+		} finally {
+			remove_filter( 'pre_http_request', $http_mock );
+		}
+
+		$expected = array( 123 => array( 'version' => '1.2.3' ) );
+		$this->assertSame( $products, $fresh, 'A fresh check should keep the forced auto-update' );
+		$this->assertGreaterThan( DAY_IN_SECONDS, $cache_ttl, 'A successful check should be kept longer than it is fresh' );
+		$this->assertSame( $expected, $after_failure, 'A failed check should serve the stale cache, without forcing auto-updates' );
+		$this->assertSame( $expected, $inside_backoff, 'A check inside the backoff should serve the stale cache too' );
+		$this->assertSame( $stale, get_transient( '_woocommerce_helper_updates' ), 'A failed check should not write the cache' );
+	}
+
+	/**
+	 * @testdox A rejected update check should cache the empty result as before, without a backoff.
+	 *
+	 * @testWith [401]
+	 *           [403]
+	 *           [0]
+	 *
+	 * @param int $status HTTP status of the response, or 0 for a WP_Error about missing local credentials.
+	 */
+	public function test_update_check_caches_empty_result_when_connection_is_rejected( int $status ): void {
+		set_transient(
+			'_woocommerce_helper_updates',
+			array(
+				'hash'     => 'a-stale-hash',
+				'updated'  => time(),
+				'products' => array( 123 => array( 'version' => '1.2.3' ) ),
+				'errors'   => array(),
+			),
+			HOUR_IN_SECONDS
+		);
+
+		$http_mock = static function () use ( $status ) {
+			return 0 === $status
+				? new WP_Error( 'authentication', 'Authentication failed.', 401 )
+				: array(
+					'response' => array(
+						'code'    => $status,
+						'message' => get_status_header_desc( $status ),
+					),
+					'body'     => '',
+				);
+		};
+		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 );
+		}
+
+		$stored = get_transient( '_woocommerce_helper_updates' );
+		$this->assertSame( array(), $result, 'A rejected check should not serve updates the server refused' );
+		$this->assertSame( array(), $stored['products'], 'A rejected check should cache the empty result' );
+		$this->assertSame( array( 'http-error' ), $stored['errors'], 'A rejected check should be recorded as an error' );
+		$this->assertFalse(
+			WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_UPDATE_CHECK ),
+			'A rejected check should not start the outage backoff'
+		);
+	}
+
+	/**
+	 * @testdox A manual refresh should force a fresh update check but keep serving the cached products if it fails.
+	 */
+	public function test_refresh_keeps_cached_products_when_the_forced_check_fails(): void {
+		$payload = array(
+			123 => array(
+				'product_id' => 123,
+				'file_id'    => 'abc123',
+			),
+		);
+		ksort( $payload );
+		$hash     = md5( wp_json_encode( $payload ) );
+		$products = array(
+			123 => array(
+				'version'    => '1.2.3',
+				'autoupdate' => true,
+			),
+		);
+		set_transient(
+			'_woocommerce_helper_updates',
+			array(
+				'hash'     => $hash,
+				'updated'  => time(),
+				'products' => $products,
+				'errors'   => array(),
+			),
+			HOUR_IN_SECONDS
+		);
+
+		$update_checks = 0;
+		$http_mock     = static function ( $pre, $args, $url ) use ( &$update_checks ) {
+			if ( false !== strpos( $url, 'update-check' ) ) {
+				++$update_checks;
+			}
+			return new WP_Error( 'http_request_failed', 'cURL error 28: Operation timed out' );
+		};
+		add_filter( 'pre_http_request', $http_mock, 10, 3 );
+
+		try {
+			WC_Helper::refresh_helper_subscriptions();
+			$result = $this->call_update_check( $payload );
+		} finally {
+			remove_filter( 'pre_http_request', $http_mock, 10 );
+		}
+
+		$this->assertSame( 1, $update_checks, 'A refresh should force a fresh update check' );
+		$this->assertSame( $products, $result, 'A failed check after a refresh should serve the cached products, keeping the forced auto-update made for this payload' );
+		$this->assertSame( $hash, get_transient( '_woocommerce_helper_updates' )['hash'], 'A refresh should keep the hash of the cached payload' );
+	}
+
 	/**
 	 * Test that _update_check refreshes cache when hash doesn't match.
 	 */