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.
*/