Commit 9d6d4a5f071 for woocommerce

commit 9d6d4a5f071870eb036f594fbc2464cdbfb75356
Author: Thilina Pituwala <thilina.hasantha@gmail.com>
Date:   Fri Sep 4 17:54:32 2026 +1000

    Surface WooCommerce.com API errors on My Subscriptions (#67832)

    * Surface WooCommerce.com API errors on My Subscriptions

    * Add changelog entry for WooCommerce.com API error notices

    * Fix clock-drift flake test case in the 429 rate-limit test.

    * Report the page-load subscriptions error through the notice store

    It rendered as a separate non-dismissible notice with a component-local latch to stop it duplicating the Refresh notice. That made it impossible to dismiss when stale, and the latch reset on every tab switch, bringing a dismissed notice back.

    Dispatch it into the store under the refresh notice's id instead. The shared id replaces rather than stacks, the store supplies dismissal, and a module-scope flag stops it returning on remount.

    * Show connectivity guidance instead of raw transport errors

    A failed request with no HTTP status carries raw wp_remote_request text ("cURL error 28: Operation timed out after 10001 milliseconds...") — untranslated developer detail that tells a merchant nothing. Replace it in get_api_error() with copy pointing at the store's own outgoing connectivity, which is the likelier reason of a failed connection.

    * Stop the test harness clearing the error it measures.

    * Add @since tags to the new Helper API error methods.

    * Include the upstream WooCommerce.com status in refresh errors.

    * Validate the extracted refresh error message is a string.

diff --git a/plugins/woocommerce/changelog/fix-wccom-2840-surface-subscriptions-api-errors b/plugins/woocommerce/changelog/fix-wccom-2840-surface-subscriptions-api-errors
new file mode 100644
index 00000000000..9e2d88eb385
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wccom-2840-surface-subscriptions-api-errors
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Surface WooCommerce.com API failures on My Subscriptions instead of rendering an empty subscription list, including the rate-limit message for the whole backoff window after an HTTP 429.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx
index cd7bec97058..d189b50b741 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/my-subscriptions.tsx
@@ -3,7 +3,11 @@
  */
 import { Button } from '@wordpress/components';
 import { __ } from '@wordpress/i18n';
-import { createInterpolateElement, useContext } from '@wordpress/element';
+import {
+	createInterpolateElement,
+	useContext,
+	useEffect,
+} from '@wordpress/element';
 import { Icon, external } from '@wordpress/icons';
 import apiFetch from '@wordpress/api-fetch';

@@ -22,14 +26,46 @@ import { Subscription } from './types';
 import { RefreshButton } from './table/actions/refresh-button';
 import Notices from './notices';
 import InstallModal from './table/actions/install-modal';
-import { connectUrl } from '../../utils/functions';
+import { addNotice, connectUrl } from '../../utils/functions';
+import {
+	NoticeStatus,
+	REFRESH_SUBSCRIPTIONS_NOTICE_ID,
+} from '../../contexts/types';
 import Notice from '../notice/notice';
 import MySubscriptionsAccount from './my-subscriptions-account';

+/**
+ * Whether the failure captured at page load has already been reported.
+ *
+ * Module scope rather than component state because this component is unmounted
+ * whenever the merchant switches marketplace tabs. Held in the component, the
+ * flag would reset on the way back and re-add a notice that had already been
+ * dismissed.
+ */
+let pageLoadErrorReported = false;
+
 export default function MySubscriptions(): React.JSX.Element {
 	const { subscriptions, isLoading } = useContext( SubscriptionsContext );
 	const wccomSettings = getAdminSetting( 'wccomHelper', {} );

+	// Report the failure captured at page load as the notice a failed refresh
+	// would report, under the same id. The Refresh button reruns the very
+	// request this describes, so sharing the id means the later result replaces
+	// the earlier one and a single problem yields a single dismissible notice.
+	useEffect( () => {
+		if ( pageLoadErrorReported || ! wccomSettings?.api_error_notice ) {
+			return;
+		}
+
+		pageLoadErrorReported = true;
+
+		addNotice(
+			REFRESH_SUBSCRIPTIONS_NOTICE_ID,
+			wccomSettings.api_error_notice,
+			NoticeStatus.Error
+		);
+	}, [ wccomSettings?.api_error_notice ] );
+
 	const installedTableDescription = createInterpolateElement(
 		__(
 			'WooCommerce.com extensions and themes installed on this store. To see all your subscriptions go to <a>your account<custom_icon /></a> on WooCommerce.com.',
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/refresh-button.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/refresh-button.tsx
index b0c17edfb11..6be4f9c154e 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/refresh-button.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/my-subscriptions/table/actions/refresh-button.tsx
@@ -10,10 +10,15 @@ import { useContext, useState } from '@wordpress/element';
  */
 import RefreshIcon from '../../../../assets/images/refresh.svg';
 import { SubscriptionsContext } from '../../../../contexts/subscriptions-context';
-import { addNotice, removeNotice } from '../../../../utils/functions';
-import { NoticeStatus } from '../../../../contexts/types';
-
-const NOTICE_ID = 'woocommerce-marketplace-refresh-subscriptions';
+import {
+	addNotice,
+	getRefreshErrorMessage,
+	removeNotice,
+} from '../../../../utils/functions';
+import {
+	NoticeStatus,
+	REFRESH_SUBSCRIPTIONS_NOTICE_ID as NOTICE_ID,
+} from '../../../../contexts/types';

 export function RefreshButton() {
 	const { refreshSubscriptions } = useContext( SubscriptionsContext );
@@ -44,7 +49,7 @@ export function RefreshButton() {
 							'Error refreshing subscriptions: %s',
 							'woocommerce'
 						),
-						error.data.message
+						getRefreshErrorMessage( error )
 					),
 					NoticeStatus.Error
 				);
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/subscriptions-context.tsx b/plugins/woocommerce/client/admin/client/marketplace/contexts/subscriptions-context.tsx
index 8c4b4778032..949b886bafc 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/subscriptions-context.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/subscriptions-context.tsx
@@ -7,11 +7,16 @@ import { __, sprintf } from '@wordpress/i18n';
 /**
  * Internal dependencies
  */
-import { SubscriptionsContextType, NoticeStatus } from './types';
+import {
+	SubscriptionsContextType,
+	NoticeStatus,
+	REFRESH_SUBSCRIPTIONS_NOTICE_ID,
+} from './types';
 import { Subscription } from '../components/my-subscriptions/types';
 import {
 	addNotice,
 	fetchSubscriptions,
+	getRefreshErrorMessage,
 	refreshSubscriptions as fetchSubscriptionsFromWooCom,
 } from '../utils/functions';

@@ -81,14 +86,14 @@ export function SubscriptionsContextProvider( props: {
 		if ( installKey ) {
 			refreshSubscriptions( true ).catch( ( error ) => {
 				addNotice(
-					'woocommerce-marketplace-refresh-subscriptions',
+					REFRESH_SUBSCRIPTIONS_NOTICE_ID,
 					sprintf(
 						// translators: %s is the error message.
 						__(
 							'Error refreshing subscriptions: %s',
 							'woocommerce'
 						),
-						error.message
+						getRefreshErrorMessage( error )
 					),
 					NoticeStatus.Error
 				);
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
index de49756a10e..3e2560eb0f4 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
@@ -6,6 +6,15 @@ import { Subscription } from '../components/my-subscriptions/types';

 export type { NoticeAction, NoticeOptions } from '~/lib/notices/types';

+/**
+ * Notice key shared by everything that refreshes subscriptions, so a refresh
+ * result always replaces the previous one instead of stacking. Lives here
+ * rather than in refresh-button so SubscriptionsContext can use it without
+ * importing a component that imports the context back.
+ */
+export const REFRESH_SUBSCRIPTIONS_NOTICE_ID =
+	'woocommerce-marketplace-refresh-subscriptions';
+
 export interface SearchResultsCountType {
 	extensions: number;
 	themes: number;
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
index 685e048c3f0..622d03fa495 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
@@ -476,6 +476,33 @@ function addNotice(
 	}
 }

+/**
+ * Pull the human-readable message out of a rejected subscriptions request.
+ *
+ * `/wc/v3/marketplace/refresh` answers failures with `wp_send_json_error()`,
+ * which nests the message under `data`, while transport failures and generic
+ * REST errors put it at the top level. Reading only one shape renders
+ * "undefined" to the merchant.
+ *
+ * Both candidates are typed `unknown` and checked rather than asserted: they
+ * come off the wire, and a `message` that wasn't a string would be handed
+ * straight to sprintf and reach the merchant as "[object Object]".
+ *
+ * @param error The rejection value from apiFetch.
+ * @return The best available message.
+ */
+const getRefreshErrorMessage = ( error: unknown ): string => {
+	const candidate = error as
+		| { data?: { message?: unknown }; message?: unknown }
+		| undefined;
+
+	const message = [ candidate?.data?.message, candidate?.message ].find(
+		( value ): value is string => typeof value === 'string' && value !== ''
+	);
+
+	return message ?? __( 'Unexpected error.', 'woocommerce' );
+};
+
 const removeNotice = ( productKey: string ) => {
 	void dispatch( noticeStore ).removeNotice( productKey );
 };
@@ -589,6 +616,7 @@ export {
 	installProduct,
 	updateProduct,
 	addNotice,
+	getRefreshErrorMessage,
 	removeNotice,
 	renewUrl,
 	subscribeUrl,
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
index 30e07aa8c77..cd5a911b1c5 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php
@@ -107,6 +107,12 @@ class WC_Helper_Admin {
 				$settings['wccomHelper']['connection_url_notice']        = WC_Woo_Helper_Connection::get_connection_url_notice();
 				$settings['wccomHelper']['has_host_plan_orders']         = WC_Woo_Helper_Connection::has_host_plan_orders();
 				$settings['wccomHelper']['maybe_deleted_connection']     = WC_Woo_Helper_Connection::get_deleted_connection_notice();
+
+				// Read last: the notices above already trigger a subscriptions fetch,
+				// so by this point any failure from it has been recorded.
+				$api_error = WC_Helper::get_api_error();
+
+				$settings['wccomHelper']['api_error_notice'] = null !== $api_error ? $api_error['message'] : '';
 			} else {
 				$settings['wccomHelper']['disconnected_notice'] = PluginsHelper::get_wccom_disconnected_notice();
 			}
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 4bbb55a0585..84e4b20ce7c 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
@@ -181,6 +181,30 @@ class WC_Helper_API_Backoff {
 		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.
 	 *
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
index 731d1672f73..a73e4460848 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php
@@ -163,6 +163,28 @@ class WC_Helper_Subscriptions_API {
 			WC_Helper::get_subscriptions();
 			WC_Helper::get_product_usage_notice_rules();
 			WC_Helper::fetch_helper_connection_info();
+
+			// get_subscriptions() swallows Helper API failures and returns an empty
+			// array, so a refresh that could not reach WooCommerce.com would
+			// otherwise report success over an empty list. Surface the recorded
+			// failure instead. Checked before serving, since serving exits.
+			$api_error = WC_Helper::get_api_error();
+
+			if ( null !== $api_error ) {
+				wp_send_json_error(
+					array(
+						'message' => $api_error['message'],
+						// The upstream WooCommerce.com status, carried in the body
+						// rather than used as the response status. The failure is
+						// between this store and WooCommerce.com, so relaying it
+						// would have this endpoint claim the caller was rate
+						// limited or unauthorized when neither is true.
+						'code'    => $api_error['code'],
+					),
+					400
+				);
+			}
+
 			self::get_subscriptions();
 		} catch ( Exception $e ) {
 			wp_send_json_error(
diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
index 8d92e4f021a..c57f141e3fb 100644
--- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
+++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php
@@ -30,6 +30,21 @@ class WC_Helper {

 	private const CACHE_KEY_CONNECTION_DATA = '_woocommerce_helper_connection_data';

+	/**
+	 * Transient holding the last failed Helper API subscriptions response, so the
+	 * failure can be surfaced to the merchant instead of rendering as an empty
+	 * subscription list.
+	 */
+	private const CACHE_KEY_API_ERROR = '_woocommerce_helper_subscriptions_api_error';
+
+	/**
+	 * Status codes that get_message_for_response_code() has purpose-written copy
+	 * for. Only these are worth re-deriving on read; for anything else the
+	 * message recorded at failure time is more specific than the generic
+	 * "HTTP status code %d" fallback. Keep in sync with that method.
+	 */
+	private const RESPONSE_CODES_WITH_SPECIFIC_MESSAGES = array( 403, 429 );
+
 	/**
 	 * Get an absolute path to the requested helper view.
 	 *
@@ -81,6 +96,125 @@ class WC_Helper {
 		}
 	}

+	/**
+	 * Record the last failed Helper API subscriptions response.
+	 *
+	 * The message is stored alongside the code because it is often more specific
+	 * than anything we can rebuild from the status alone — a 401 carries
+	 * reconnect guidance, a 422 explains an unparseable body. Where we do have
+	 * purpose-written copy for a status, get_api_error() rebuilds it on read so
+	 * it lands in the viewer's locale instead of the recording request's.
+	 *
+	 * A 429 is held for the whole backoff window, otherwise the notice would
+	 * disappear while requests are still being suppressed and the screen would
+	 * silently revert to looking like an empty account.
+	 *
+	 * @param int    $code    HTTP status code, or 0 for a transport-level failure.
+	 * @param string $message Fallback message for failures with no HTTP status.
+	 * @return void
+	 */
+	private static function record_api_error( int $code, string $message ): void {
+		if ( $code < 100 ) {
+			// get_api_error() replaces this with merchant-facing guidance, and the
+			// catch in get_subscriptions() only logs failures from 404 up, so
+			// without this the transport detail would be lost entirely.
+			self::log( 'Could not reach the WooCommerce.com API: ' . $message, 'error' );
+		}
+
+		$ttl = 15 * MINUTE_IN_SECONDS;
+
+		if ( 429 === $code ) {
+			$retry_after = WC_Helper_API_Backoff::get_retry_after( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS );
+
+			if ( null !== $retry_after ) {
+				$ttl = $retry_after;
+			}
+		}
+
+		set_transient(
+			self::CACHE_KEY_API_ERROR,
+			array(
+				'code'    => $code,
+				'message' => $message,
+			),
+			$ttl
+		);
+	}
+
+	/**
+	 * The last failed Helper API subscriptions response, if one is still current.
+	 *
+	 * @since 11.2.0
+	 *
+	 * @return array{code:int, message:string, retry_after:int|null}|null Null when the last fetch succeeded.
+	 */
+	public static function get_api_error(): ?array {
+		$error = get_transient( self::CACHE_KEY_API_ERROR );
+
+		if ( ! is_array( $error ) || ! isset( $error['code'] ) ) {
+			return null;
+		}
+
+		$code           = (int) $error['code'];
+		$stored_message = (string) ( $error['message'] ?? '' );
+
+		// A rate limit is the one failure with a known end, and the generic copy
+		// ("a few minutes") understates a window that can run to hours. Read the
+		// backoff live so the figure counts down across page loads.
+		$retry_after = 429 === $code
+			? WC_Helper_API_Backoff::get_retry_after( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS )
+			: null;
+
+		if ( null !== $retry_after ) {
+			$now = time();
+
+			$message = sprintf(
+				/* translators: %s: localized duration until the request limit resets, e.g. "5 minutes" or "3 hours". */
+				__( 'You have exceeded the request limit. Please try again in %s.', 'woocommerce' ),
+				human_time_diff( $now, $now + $retry_after )
+			);
+		} elseif ( in_array( $code, self::RESPONSE_CODES_WITH_SPECIFIC_MESSAGES, true ) ) {
+			// We have copy written for this status, so rebuilding it costs nothing
+			// and gains the viewer's locale.
+			$message = self::get_message_for_response_code( $code );
+		} elseif ( $code < 100 ) {
+			// No HTTP status means the request never completed, so the recorded
+			// message is raw transport text ("cURL error 28: Operation timed
+			// out...") — untranslated developer detail that tells a merchant
+			// nothing. record_api_error() logs the specifics instead. The copy
+			// points at the store's own connectivity because that is the
+			// overwhelmingly likelier cause of a failed connection.
+			$message = __( 'Your store could not connect to WooCommerce.com. Please try again after a few minutes. If the issue persists, check whether your server can make outgoing requests.', 'woocommerce' );
+		} elseif ( '' !== $stored_message ) {
+			// Otherwise the recorded message wins. Rebuilding from the status
+			// would replace real guidance — the reconnect instructions on a 401,
+			// the invalid-response explanation on a 422 — with a bare
+			// "HTTP status code %d".
+			$message = $stored_message;
+		} else {
+			$message = self::get_message_for_response_code( $code );
+		}
+
+		if ( '' === $message ) {
+			return null;
+		}
+
+		return array(
+			'code'        => $code,
+			'message'     => $message,
+			'retry_after' => $retry_after,
+		);
+	}
+
+	/**
+	 * Clear the recorded Helper API subscriptions failure.
+	 *
+	 * @return void
+	 */
+	private static function clear_api_error(): void {
+		delete_transient( self::CACHE_KEY_API_ERROR );
+	}
+
 	/**
 	 * Adds at most one note signaling that there was an error with the WCCOM API.
 	 *
@@ -2022,8 +2156,14 @@ class WC_Helper {

 			// Remove notice after successful API call as it's no longer applicable.
 			self::remove_api_error_notice();
+			self::clear_api_error();
 			return $data;
 		} catch ( Exception $e ) {
+			// Record every failure, including those below 404, so the screen can
+			// explain itself instead of rendering an empty subscription list. This
+			// deliberately does not rethrow: callers rely on an empty array.
+			self::record_api_error( (int) $e->getCode(), $e->getMessage() );
+
 			if ( $e->getCode() < 404 ) {
 				self::remove_api_error_notice();
 			} else {
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 c0ea5314d34..a8edcff698d 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
@@ -34,6 +34,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( '_woocommerce_helper_subscriptions_api_error' );
 	}

 	/**
@@ -292,6 +293,239 @@ class WC_Helper_Test extends \WC_Unit_Test_Case {
 		);
 	}

+	/**
+	 * Run get_subscriptions() against a mocked Helper API response.
+	 *
+	 * @param array|WP_Error $response    The response pre_http_request should return.
+	 * @param bool           $reset_error Whether to clear any recorded API error first.
+	 *                                    Pass false to measure what the fetch itself
+	 *                                    does to an error recorded by an earlier call.
+	 * @return array The value get_subscriptions() returned.
+	 */
+	private function fetch_subscriptions_with_response( $response, bool $reset_error = true ): array {
+		$previous_auth = WC_Helper_Options::get( 'auth', array() );
+		$previous_log  = WC_Helper::$log;
+		$http_mock     = static function () use ( $response ) {
+			return $response;
+		};
+
+		WC_Helper::$log = $this->createMock( WC_Logger_Interface::class );
+
+		// Install the mock before touching `auth`. Updating that option fires
+		// hooks that call the Helper API themselves, and an unmocked call there
+		// caches an empty list, which the measured fetch would then return
+		// straight from cache without ever exercising the response under test.
+		add_filter( 'pre_http_request', $http_mock );
+
+		try {
+			WC_Helper_Options::update(
+				'auth',
+				array(
+					'access_token'        => 'test-token',
+					'access_token_secret' => 'test-secret',
+					// A real connection always carries this, and the subscription
+					// notes that run after a successful fetch dereference it.
+					'site_id'             => 1,
+				)
+			);
+
+			// Whatever those hooks recorded, start the measured call from a clean
+			// slate so the assertions describe this response and nothing else.
+			delete_transient( '_woocommerce_helper_subscriptions' );
+			if ( $reset_error ) {
+				delete_transient( '_woocommerce_helper_subscriptions_api_error' );
+			}
+			WC_Helper_API_Backoff::clear( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS );
+
+			return WC_Helper::get_subscriptions();
+		} finally {
+			// Restore `auth` while the mock is still installed, so the hooks it
+			// fires stay off the network.
+			WC_Helper_Options::update( 'auth', $previous_auth );
+			remove_filter( 'pre_http_request', $http_mock );
+			WC_Helper::$log = $previous_log;
+		}
+	}
+
+	/**
+	 * A rate-limited Helper API response.
+	 *
+	 * @return array
+	 */
+	private function get_rate_limited_response(): array {
+		return array(
+			// 300 rather than 60: human_time_diff() drops from minutes to seconds
+			// below MINUTE_IN_SECONDS, and get_api_error() recomputes the window
+			// live against the clock, so a boundary value reads back as
+			// "59 seconds" whenever a second elapses between recording the
+			// failure and reading it.
+			'headers'  => array( 'retry-after' => '300' ),
+			'response' => array(
+				'code'    => 429,
+				'message' => 'Too Many Requests',
+			),
+			'body'     => '{"code":"wccom_rest_limit_reached","data":{"status":429}}',
+		);
+	}
+
+	/**
+	 * @testdox get_api_error should return no error when nothing has failed.
+	 */
+	public function test_get_api_error_returns_null_without_a_recorded_failure(): void {
+		$this->assertNull(
+			WC_Helper::get_api_error(),
+			'No error should be reported before any Helper API call has failed'
+		);
+	}
+
+	/**
+	 * @testdox get_api_error should report the rate-limit message after a 429.
+	 */
+	public function test_get_api_error_reports_rate_limit_message_after_429(): void {
+		$result = $this->fetch_subscriptions_with_response( $this->get_rate_limited_response() );
+
+		$this->assertSame( array(), $result, 'A rate-limited response should yield no subscriptions' );
+
+		$error = WC_Helper::get_api_error();
+
+		$this->assertNotNull( $error, 'A 429 should record a surfaceable error' );
+		$this->assertSame( 429, $error['code'], 'The recorded error should carry the HTTP status' );
+		$this->assertSame(
+			'You have exceeded the request limit. Please try again in 5 minutes.',
+			$error['message'],
+			'A 429 should name the wait rather than say "a few minutes"'
+		);
+		$this->assertEqualsWithDelta(
+			300,
+			$error['retry_after'],
+			5,
+			'The Retry-After window should be reported'
+		);
+	}
+
+	/**
+	 * The generic "a few minutes" copy understates a window that the server can
+	 * set to hours, which is the case this replaces.
+	 *
+	 * @testdox get_api_error should report a multi-hour rate-limit window in hours.
+	 */
+	public function test_get_api_error_reports_a_long_rate_limit_window_in_hours(): void {
+		$response                           = $this->get_rate_limited_response();
+		$response['headers']['retry-after'] = (string) ( 3 * HOUR_IN_SECONDS );
+
+		$this->fetch_subscriptions_with_response( $response );
+
+		$error = WC_Helper::get_api_error();
+
+		$this->assertNotNull( $error, 'A 429 should record a surfaceable error' );
+		$this->assertSame(
+			'You have exceeded the request limit. Please try again in 3 hours.',
+			$error['message'],
+			'A multi-hour window should be stated in hours, not as "a few minutes"'
+		);
+	}
+
+	/**
+	 * get_message_for_response_code() only has copy for 429 and 403. Rebuilding
+	 * from the status for anything else replaces real guidance — the reconnect
+	 * instructions carried by a 401, for instance — with a bare status code.
+	 *
+	 * @testdox get_api_error should keep the recorded message for statuses with no specific copy.
+	 */
+	public function test_get_api_error_keeps_the_recorded_message_without_specific_copy(): void {
+		$actionable = 'Authentication failed. Please try again after a few minutes. If the issue persists, disconnect your store from WooCommerce.com and reconnect.';
+
+		$this->fetch_subscriptions_with_response( new WP_Error( 'authentication', $actionable, 401 ) );
+
+		$error = WC_Helper::get_api_error();
+
+		$this->assertNotNull( $error, 'A transport-level failure should record a surfaceable error' );
+		$this->assertSame( 401, $error['code'], 'The recorded error should carry the status' );
+		$this->assertSame(
+			$actionable,
+			$error['message'],
+			'The reconnect guidance should survive rather than becoming a bare status code'
+		);
+	}
+
+	/**
+	 * A failure with no HTTP status carries raw wp_remote_request text, which is
+	 * untranslated developer detail. The merchant gets guidance aimed at their own
+	 * server instead, since that is the likelier end of a failed connection.
+	 *
+	 * @testdox get_api_error should replace raw transport detail with merchant-facing guidance.
+	 */
+	public function test_get_api_error_replaces_raw_transport_detail(): void {
+		$raw = 'cURL error 28: Operation timed out after 10001 milliseconds with 0 bytes received';
+
+		$this->fetch_subscriptions_with_response( new WP_Error( 'http_request_failed', $raw ) );
+
+		$error = WC_Helper::get_api_error();
+
+		$this->assertNotNull( $error, 'A transport failure should record a surfaceable error' );
+		$this->assertSame( 0, $error['code'], 'A transport failure carries no HTTP status' );
+		$this->assertStringNotContainsString(
+			'cURL',
+			$error['message'],
+			'Raw transport detail should never reach the merchant'
+		);
+		$this->assertSame(
+			'Your store could not connect to WooCommerce.com. Please try again after a few minutes. If the issue persists, check whether your server can make outgoing requests.',
+			$error['message'],
+			'A transport failure should point at the store\'s own connectivity'
+		);
+	}
+
+	/**
+	 * A 429 suppresses further requests for the whole backoff window. The error has
+	 * to outlive the request that received it, or the screen silently reverts to
+	 * looking like an empty account while requests are still being held back.
+	 *
+	 * @testdox get_api_error should keep reporting a 429 for the whole backoff window.
+	 */
+	public function test_api_error_persists_across_the_backoff_window(): void {
+		$this->fetch_subscriptions_with_response( $this->get_rate_limited_response() );
+
+		// A second call short-circuits on the backoff and never reaches the API,
+		// so nothing new is recorded — the first record has to still be there.
+		$second_result = WC_Helper::get_subscriptions();
+
+		$this->assertSame( array(), $second_result, 'A backed-off call should yield no subscriptions' );
+		$this->assertTrue(
+			WC_Helper_API_Backoff::is_rate_limited( WC_Helper_API_Backoff::REQUEST_TYPE_SUBSCRIPTIONS ),
+			'The backoff window should still be open'
+		);
+
+		$error = WC_Helper::get_api_error();
+
+		$this->assertNotNull( $error, 'The error should survive for as long as requests are suppressed' );
+		$this->assertSame( 429, $error['code'], 'The persisted error should still be the rate limit' );
+	}
+
+	/**
+	 * @testdox get_api_error should stop reporting once a fetch succeeds.
+	 */
+	public function test_api_error_is_cleared_after_a_successful_fetch(): void {
+		$this->fetch_subscriptions_with_response( $this->get_rate_limited_response() );
+
+		$this->assertNotNull( WC_Helper::get_api_error(), 'Precondition: an error is recorded' );
+
+		// Keep the recorded error in place, so what clears it is the successful
+		// fetch rather than the harness resetting state ahead of the call.
+		$this->fetch_subscriptions_with_response(
+			array(
+				'response' => array( 'code' => 200 ),
+				'body'     => wp_json_encode( $this->get_valid_subscription_data() ),
+			),
+			false
+		);
+
+		$this->assertNull(
+			WC_Helper::get_api_error(),
+			'A successful fetch should clear the recorded error'
+		);
+	}
+
 	/**
 	 * @testdox get_cached_connection_data should return false for corrupted string transient.
 	 */