Commit 6a0632c240f for woocommerce
commit 6a0632c240fbf32b85ac7c331605e6d1a2581827
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Oct 8 12:43:49 2026 +0300
Surface Store API engine errors in debug mode (#68577)
* fix(store-api): return safe, debuggable responses for engine errors
Engine errors such as TypeError and Error thrown while a Store API
route runs escaped the route's try/catch, which only caught Exception,
and reached WordPress's generic fatal handler. Clients saw a bare
"internal server error" with no way to see the real failure even with
debug mode on, and nothing was logged with a trace.
Add StoreApi\Utilities\UnexpectedErrorResponse and a trailing Throwable
catch after the existing RouteException and Exception arms in the four
route dispatchers (AbstractRoute, AbstractCartRoute, Checkout, Batch).
The builder logs the failure at critical, the level WooCommerce uses
for fatals, with up to ten trace frames under the backtrace context
key, the same shape the shutdown fatal handler writes. It returns a
generic status-500 response to every client. The failure message and
exception class are included only when the
woocommerce_store_api_expose_error_details filter, which defaults to
WP_DEBUG, is true and the current user can manage_woocommerce. The
capability check is not filterable, so store staff can debug a
production store without enabling debug mode site-wide and details
never reach a user who cannot manage WooCommerce.
The cart-session failure path from #67769 uses the same builder while
keeping its public message and headers. In AbstractCartRoute an engine
failure returns immediately, so cart_updated() never runs after a
failed dispatch. Ordinary Exception responses are byte-for-byte
unchanged in all four dispatchers.
The filter lives under src/StoreApi, so the published Store API hook
reference (client/blocks/docs/.../hooks/filters.md) is regenerated.
Tests cover the route boundary and the ordinary Exception arm of all
four dispatchers through a shared fixture trait, the disclosure matrix,
the filter, a failing logger, and the cart update lifecycle.
Refs #47095
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* chore(store-api): add changelog entry for engine error responses
Refs #47095
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(store-api): Expect a 500 response for a pre-payment checkout Error
#68234 added a test asserting that an Error raised during checkout,
before any payment was taken, escapes the route uncaught. It guards
against the post-payment recovery path claiming an order that never
paid and reporting it as a successful checkout.
This branch now catches Throwable in the Store API routes and turns
unexpected engine failures into a 500 woocommerce_rest_unknown_server_error
response, so the Error no longer propagates and the test fails on every
unit:php job.
The invariant the test protects still holds: the checkout is reported
as failed and the order stays pending. Assert that through the response
instead of a thrown Error.
Refs #47095
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* docs(store-api): Set UnexpectedErrorResponse @since to 11.3.0
trunk is on 11.3.0-dev and release/11.2 is already cut, so the new
method and the woocommerce_store_api_expose_error_details filter first
ship in 11.3.0.
Refs #47095
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* refactor(store-api): Let cart route engine failures use the shared error path
The trailing catch in AbstractCartRoute::get_response() returned the
engine-failure response immediately, while the AbstractRoute, Batch and
Checkout dispatchers assign $response and fall through. The early
return was described as what keeps cart_updated() from running, but the
fall-through path already guards that call with is_wp_error(), and the
response, nonce and Cache-Control headers come out the same either way.
Assign the response like the sibling dispatchers so all four handle an
engine failure in one shape, and so later changes to the shared error
path apply to the cart route too.
Refs #47095
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(store-api): Fall back to the PHP error log when logging an engine failure fails
UnexpectedErrorResponse::create() swallows any failure raised by the log
handlers so the safe 500 response is always returned. That left a
double failure, an engine error whose logging also broke, with no trace
anywhere: non-managers see a generic 500 and nothing records either
error.
Write the original failure and the logging error's message to the PHP
error log in that case. The log message is built once so both paths
report the same class, message, file and line. The test now pins the
fallback by pointing error_log at a temporary file.
Refs #47095
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(store-api): Cover stock release for a draft order engine error
The draft-order stock release test only threw an ordinary Exception.
The new Throwable arm in Checkout::get_response() reaches the same
release branch because it assigns the response rather than returning,
but nothing pinned that: an arm that returned early would still pass
the existing 500-status test while leaving held stock in place.
Run the release test with a TypeError as well, through a data provider.
Mutating the arm to an early return makes the new case fail.
Refs #47095
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(store-api): Replace the route fixture switch with a dispatcher provider
test_ordinary_exception_response_is_unchanged picked its route with a
switch on a string label, a null sentinel for the batch route and a
ternary, and a mistyped label silently fell back to the abstract route.
Have the data provider yield a dispatcher closure per route kind so the
test body has no conditionals and an unknown kind cannot pass by
accident.
Refs #47095
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-47095-store-api-debug-errors b/plugins/woocommerce/changelog/fix-47095-store-api-debug-errors
new file mode 100644
index 00000000000..1f83623244f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-47095-store-api-debug-errors
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return safe debug details for unexpected Store API engine errors.
diff --git a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
index b657ad5aceb..fb9fc11d76f 100644
--- a/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
+++ b/plugins/woocommerce/client/blocks/docs/third-party-developers/extensibility/hooks/filters.md
@@ -72,6 +72,7 @@
- [woocommerce_store_api_cart_item_images](#woocommerce_store_api_cart_item_images)
- [woocommerce_store_api_cart_item_quantity_validation](#woocommerce_store_api_cart_item_quantity_validation)
- [woocommerce_store_api_disable_nonce_check](#woocommerce_store_api_disable_nonce_check)
+- [woocommerce_store_api_expose_error_details](#woocommerce_store_api_expose_error_details)
- [`woocommerce_store_api_product_quantity_{$value_type}`](#woocommerce_store_api_product_quantity_value_type)
- [woocommerce_store_api_rate_limit_id](#woocommerce_store_api_rate_limit_id)
- [woocommerce_store_api_rate_limit_options](#woocommerce_store_api_rate_limit_options)
@@ -1802,6 +1803,36 @@ This can be used to disable the nonce check when testing API endpoints via a RES
---
+## woocommerce_store_api_expose_error_details
+
+
+Filters whether unexpected Store API failures include the error message and exception class in the response.
+
+```php
+apply_filters( 'woocommerce_store_api_expose_error_details', bool $expose_error_details )
+```
+
+### Description
+
+Details are only ever sent to users who can manage WooCommerce; this filter cannot bypass that check. It defaults to WP_DEBUG so store staff can debug a production store without enabling debug mode site-wide.
+
+### Parameters
+
+| Argument | Type | Description |
+| -------- | ---- | ----------- |
+| $expose_error_details | bool | Whether to include the error message and exception class. Defaults to WP_DEBUG. |
+
+### Returns
+
+
+`bool`
+
+### Source
+
+- [StoreApi/Utilities/UnexpectedErrorResponse.php](../../../../../../src/StoreApi/Utilities/UnexpectedErrorResponse.php)
+
+---
+
## `woocommerce_store_api_product_quantity_{$value_type}`
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
index e9a62896f93..423f34b5f2e 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
@@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
+use Automattic\WooCommerce\StoreApi\Utilities\UnexpectedErrorResponse;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
@@ -106,30 +107,17 @@ abstract class AbstractCartRoute extends AbstractRoute {
/**
* Convert a failure during cart session loading into a client-safe error response.
*
- * The original error is logged rather than returned, so nothing internal reaches the client.
+ * Error details are returned only to managers when debug details are enabled.
*
* @param \Throwable $error The error that occurred while loading the cart session.
* @return \WP_REST_Response The error response to return to the client.
*/
protected function get_cart_session_error_response( \Throwable $error ) {
- wc_get_logger()->error(
- sprintf(
- 'Store API could not load the cart session: %1$s in %2$s:%3$d',
- $error->getMessage(),
- $error->getFile(),
- $error->getLine()
- ),
- array(
- 'source' => 'store-api',
- 'exception' => $error,
- )
- );
-
return $this->error_to_response(
- $this->get_route_error_response(
- 'woocommerce_rest_unknown_server_error',
- __( 'The cart could not be loaded. Please try again.', 'woocommerce' ),
- 500
+ UnexpectedErrorResponse::create(
+ $error,
+ sprintf( '%s while loading the cart session', static::class ),
+ __( 'The cart could not be loaded. Please try again.', 'woocommerce' )
)
);
}
@@ -162,6 +150,8 @@ abstract class AbstractCartRoute extends AbstractRoute {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
+ } catch ( \Throwable $error ) {
+ $response = UnexpectedErrorResponse::create( $error, static::class );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php
index b15b2b20299..934162602bf 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractRoute.php
@@ -1,6 +1,7 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+use Automattic\WooCommerce\StoreApi\Utilities\UnexpectedErrorResponse;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Routes\RouteInterface;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
@@ -102,6 +103,8 @@ abstract class AbstractRoute implements RouteInterface {
$response = $this->get_route_error_response_from_object( $error->getError(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
+ } catch ( \Throwable $error ) {
+ $response = UnexpectedErrorResponse::create( $error, static::class );
}
return is_wp_error( $response ) ? $this->error_to_response( $response ) : $response;
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php
index 2984bbba8bd..fd82ac5db4d 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php
@@ -1,6 +1,7 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+use Automattic\WooCommerce\StoreApi\Utilities\UnexpectedErrorResponse;
use Automattic\WooCommerce\StoreApi\Routes\RouteInterface;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
use WP_REST_Request;
@@ -127,6 +128,8 @@ class Batch extends AbstractRoute implements RouteInterface {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
+ } catch ( \Throwable $error ) {
+ $response = UnexpectedErrorResponse::create( $error, static::class );
}
if ( is_wp_error( $response ) ) {
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index cb53a67a90d..a6e424d1e78 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
+use Automattic\WooCommerce\StoreApi\Utilities\UnexpectedErrorResponse;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
@@ -175,6 +176,8 @@ class Checkout extends AbstractCartRoute {
$response = $this->get_route_error_response( $error->getErrorCode(), $error->getMessage(), $error->getCode(), $error->getAdditionalData() );
} catch ( \Exception $error ) {
$response = $this->get_route_error_response( 'woocommerce_rest_unknown_server_error', $error->getMessage(), 500 );
+ } catch ( \Throwable $error ) {
+ $response = UnexpectedErrorResponse::create( $error, static::class );
}
}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UnexpectedErrorResponse.php b/plugins/woocommerce/src/StoreApi/Utilities/UnexpectedErrorResponse.php
new file mode 100644
index 00000000000..16b7b85232a
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UnexpectedErrorResponse.php
@@ -0,0 +1,87 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities;
+
+use Automattic\Jetpack\Constants;
+use WP_Error;
+
+/**
+ * Builds safe Store API responses for unexpected engine failures.
+ *
+ * @internal
+ */
+final class UnexpectedErrorResponse {
+ /**
+ * Maximum number of backtrace frames recorded in the log context.
+ */
+ private const MAX_BACKTRACE_FRAMES = 10;
+
+ /**
+ * Log an engine failure and create its Store API response.
+ *
+ * @since 11.3.0
+ *
+ * @param \Throwable $error Unexpected engine failure.
+ * @param string $failure_context Context in which the failure occurred.
+ * @param string|null $public_message Message shown to clients without debug access.
+ * @return WP_Error
+ */
+ public static function create( \Throwable $error, string $failure_context, ?string $public_message = null ): WP_Error {
+ $log_message = sprintf(
+ 'Store API request failed in %1$s: %2$s: %3$s in %4$s:%5$d',
+ $failure_context,
+ get_class( $error ),
+ $error->getMessage(),
+ $error->getFile(),
+ $error->getLine()
+ );
+
+ try {
+ wc_get_logger()->critical(
+ $log_message,
+ array(
+ 'source' => 'store-api',
+ 'exception' => $error,
+ // Same shape the fatal-error shutdown handler logs, so log handlers and readers see one trace format.
+ 'backtrace' => array_slice( explode( "\n", $error->getTraceAsString() ), 0, self::MAX_BACKTRACE_FRAMES ),
+ )
+ );
+ } catch ( \Throwable $logging_error ) {
+ // Logging must not prevent the safe response, but a failure that also broke logging should not vanish.
+ error_log( $log_message . ' (logging failed: ' . $logging_error->getMessage() . ')' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
+ }
+
+ $message = $public_message ?? __( 'Internal server error', 'woocommerce' );
+ $data = array( 'status' => 500 );
+
+ if ( self::can_expose_details() ) {
+ $message = $error->getMessage();
+ $data['exception_class'] = get_class( $error );
+ }
+
+ return new WP_Error( 'woocommerce_rest_unknown_server_error', $message, $data );
+ }
+
+ /**
+ * Whether the current request may receive the failure message and class.
+ *
+ * @return bool
+ */
+ private static function can_expose_details(): bool {
+ /**
+ * Filters whether unexpected Store API failures include the error message and exception class in the response.
+ *
+ * Details are only ever sent to users who can manage WooCommerce; this filter cannot bypass that check. It defaults to WP_DEBUG so store staff can debug a production store without enabling debug mode site-wide.
+ *
+ * @since 11.3.0
+ *
+ * @param bool $expose_error_details Whether to include the error message and exception class. Defaults to WP_DEBUG.
+ *
+ * @return bool
+ */
+ $expose_error_details = (bool) apply_filters( 'woocommerce_store_api_expose_error_details', Constants::is_true( 'WP_DEBUG' ) );
+
+ return $expose_error_details && current_user_can( 'manage_woocommerce' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
index 1728190fa41..575693ac2a4 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -3798,9 +3798,24 @@ class Checkout extends \WP_Test_REST_TestCase {
}
/**
- * @testdox A failure while the order is still a draft releases the stock it had reserved.
+ * Failures an extension can raise while the order is still a draft.
+ *
+ * @return array<string, array{\Throwable}>
+ */
+ public function provider_draft_order_failures() {
+ return array(
+ 'ordinary exception' => array( new \Exception( 'Extension failed while the order was still a draft.' ) ),
+ 'engine error' => array( new \TypeError( 'Extension raised an engine error while the order was still a draft.' ) ),
+ );
+ }
+
+ /**
+ * @testdox A failure while the order is still a draft releases the stock it had reserved: $_dataName.
+ * @dataProvider provider_draft_order_failures
+ *
+ * @param \Throwable $failure The failure raised while the order is still a draft.
*/
- public function test_failure_before_the_order_leaves_draft_releases_held_stock() {
+ public function test_failure_before_the_order_leaves_draft_releases_held_stock( \Throwable $failure ) {
// Its own product rather than a class fixture, so enabling stock management here cannot
// leak into the other tests in this class.
$product = \WC_Helper_Product::create_simple_product();
@@ -3824,7 +3839,7 @@ class Checkout extends \WP_Test_REST_TestCase {
$state_at_failure = null;
add_action(
'woocommerce_blocks_checkout_order_processed',
- function () use ( &$state_at_failure, $product ) {
+ function () use ( &$state_at_failure, $product, $failure ) {
$draft_ids = wc_get_orders(
array(
'limit' => 1,
@@ -3837,7 +3852,7 @@ class Checkout extends \WP_Test_REST_TestCase {
'held' => (int) wc_get_held_stock_quantity( wc_get_product( $product->get_id() ) ),
'status' => $draft_ids ? wc_get_order( $draft_ids[0] )->get_status() : 'none',
);
- throw new \Exception( 'Extension failed while the order was still a draft.' );
+ throw $failure;
}
);
@@ -3858,11 +3873,11 @@ class Checkout extends \WP_Test_REST_TestCase {
}
/**
- * @testdox An Error raised before any payment was taken still surfaces instead of being swallowed.
+ * @testdox An Error raised before any payment was taken is reported as a failed checkout.
*/
public function test_error_raised_before_payment_is_not_converted_into_a_successful_checkout() {
// No payment_complete() here: the order is still awaiting payment when this lands, so the
- // recovery path must not claim it, and an Error must keep behaving as it did before.
+ // recovery path must not claim it, and the Error must reach the client as a failure.
add_action(
'woocommerce_rest_checkout_process_payment_with_context',
function () {
@@ -3873,14 +3888,10 @@ class Checkout extends \WP_Test_REST_TestCase {
998
);
- $caught = null;
- try {
- rest_get_server()->dispatch( $this->build_checkout_post_request() );
- } catch ( \Throwable $error ) {
- $caught = $error;
- }
+ $response = rest_get_server()->dispatch( $this->build_checkout_post_request() );
- $this->assertInstanceOf( \Error::class, $caught, 'An Error with no payment taken must surface rather than be reported as a successful checkout.' );
+ $this->assertSame( 500, $response->get_status(), 'An Error with no payment taken must be reported as a failed checkout: ' . print_r( $response->get_data(), true ) );
+ $this->assertSame( 'woocommerce_rest_unknown_server_error', $response->get_data()['code'] );
$orders = wc_get_orders(
array(
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ConfiguredFailureRouteTrait.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ConfiguredFailureRouteTrait.php
new file mode 100644
index 00000000000..8a5d2d3d477
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/ConfiguredFailureRouteTrait.php
@@ -0,0 +1,77 @@
+<?php
+/**
+ * Shared fixture behavior for Store API routes that fail on demand.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use WP_REST_Request;
+use WP_REST_Response;
+
+/**
+ * Lets a fixture route throw a configured failure from its request handler.
+ */
+trait ConfiguredFailureRouteTrait {
+ /**
+ * Failure thrown during route dispatch, or null to succeed.
+ *
+ * @var \Throwable|null
+ */
+ private $failure;
+
+ /**
+ * Create the fixture without production constructor dependencies.
+ *
+ * @param \Throwable|null $failure Failure thrown during route dispatch, or null to succeed.
+ */
+ public function __construct( ?\Throwable $failure = null ) {
+ $this->failure = $failure;
+ }
+
+ /**
+ * @return string
+ */
+ public function get_path() {
+ return '/configured-failure-fixture';
+ }
+
+ /**
+ * @return array
+ */
+ public function get_args() {
+ return array();
+ }
+
+ /**
+ * @param WP_REST_Request $request Request object.
+ * @return bool
+ */
+ protected function requires_nonce( WP_REST_Request $request ) {
+ unset( $request ); // Avoid parameter not used PHPCS errors.
+ return false;
+ }
+
+ /**
+ * @param WP_REST_Request $request Request object.
+ * @return WP_REST_Response
+ * @throws \Throwable The configured fixture failure, when one is set.
+ */
+ protected function get_response_by_request_method( WP_REST_Request $request ) {
+ unset( $request ); // Avoid parameter not used PHPCS errors.
+ if ( $this->failure ) {
+ throw $this->failure;
+ }
+
+ return new WP_REST_Response( array( 'success' => true ), 200 );
+ }
+
+ /**
+ * @param WP_REST_Response $response Response object.
+ * @return WP_REST_Response
+ */
+ protected function add_response_headers( WP_REST_Response $response ) {
+ return $response;
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UnexpectedErrorHandlingTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UnexpectedErrorHandlingTest.php
new file mode 100644
index 00000000000..7c30d575bfe
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UnexpectedErrorHandlingTest.php
@@ -0,0 +1,355 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
+use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractCartRoute;
+use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute;
+use Automattic\WooCommerce\StoreApi\Routes\V1\Batch;
+use Automattic\WooCommerce\StoreApi\Routes\V1\Checkout;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+/**
+ * Tests for unexpected Store API route errors.
+ */
+class UnexpectedErrorHandlingTest extends WC_Unit_Test_Case {
+ /**
+ * Set up the current user.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_set_current_user( 0 );
+ Constants::set_constant( 'WP_DEBUG', false );
+ }
+
+ /**
+ * Restore the current user.
+ */
+ public function tearDown(): void {
+ wp_set_current_user( 0 );
+ Constants::clear_single_constant( 'WP_DEBUG' );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should mask an engine failure from a public abstract route response.
+ */
+ public function test_abstract_route_masks_engine_failure(): void {
+ $route = $this->create_abstract_route( new \TypeError( 'Fixture abstract route failure.' ) );
+ $response = $route->get_response( new WP_REST_Request( 'GET', '/unexpected-error-fixture' ) );
+
+ $this->assert_generic_error_response( $response );
+ }
+
+ /**
+ * @testdox Should mask an engine failure from a public cart route response.
+ */
+ public function test_cart_route_masks_engine_failure(): void {
+ $route = $this->create_cart_route( new \TypeError( 'Fixture cart route failure.' ) );
+ $response = $route->get_response( new WP_REST_Request( 'GET', '/unexpected-cart-error-fixture' ) );
+
+ $this->assert_generic_error_response( $response );
+ }
+
+ /**
+ * @testdox Should show limited cart-session failure details to managers in debug mode.
+ */
+ public function test_cart_session_failure_shows_limited_details_to_managers_in_debug_mode(): void {
+ Constants::set_constant( 'WP_DEBUG', true );
+ $this->login_as_administrator();
+ $route = $this->create_cart_route( new \TypeError( 'Fixture cart session failure.' ), true );
+
+ $response = $route->get_response( new WP_REST_Request( 'GET', '/unexpected-cart-error-fixture' ) );
+
+ $this->assertSame( 500, $response->get_status(), 'Cart-session engine failures should keep status 500.' );
+ $this->assertSame(
+ array(
+ 'code' => 'woocommerce_rest_unknown_server_error',
+ 'message' => 'Fixture cart session failure.',
+ 'data' => array(
+ 'status' => 500,
+ 'exception_class' => \TypeError::class,
+ ),
+ ),
+ $response->get_data(),
+ 'Debug responses for managers should include only the cart-session failure message and class.'
+ );
+ }
+
+ /**
+ * @testdox Should preserve the public cart-session failure message.
+ */
+ public function test_cart_session_failure_preserves_public_message(): void {
+ $route = $this->create_cart_route( new \TypeError( 'Fixture cart session failure.' ), true );
+
+ $response = $route->get_response( new WP_REST_Request( 'GET', '/unexpected-cart-error-fixture' ) );
+
+ $this->assertSame( 500, $response->get_status(), 'Cart-session engine failures should keep status 500.' );
+ $this->assertSame(
+ array(
+ 'code' => 'woocommerce_rest_unknown_server_error',
+ 'message' => __( 'The cart could not be loaded. Please try again.', 'woocommerce' ),
+ 'data' => array( 'status' => 500 ),
+ ),
+ $response->get_data(),
+ 'Public cart-session errors should retain their established client-safe response.'
+ );
+ }
+
+ /**
+ * @testdox Should not run cart update side effects after an engine failure.
+ */
+ public function test_cart_route_skips_update_side_effects_after_engine_failure(): void {
+ $route = $this->create_cart_route( new \TypeError( 'Fixture cart update failure.' ) );
+ $response = $route->get_response( new WP_REST_Request( 'POST', '/unexpected-cart-error-fixture' ) );
+
+ $this->assert_generic_error_response( $response );
+ }
+
+ /**
+ * @testdox Should preserve cart update side effects after a successful update request.
+ */
+ public function test_cart_route_preserves_update_side_effects_after_success(): void {
+ $this->expectException( \LogicException::class );
+ $this->expectExceptionMessage( 'Cart update side effects reached.' );
+
+ $route = $this->create_cart_route( null );
+ $route->get_response( new WP_REST_Request( 'POST', '/unexpected-cart-error-fixture' ) );
+ }
+
+ /**
+ * @testdox Should mask an engine failure from a public checkout route response.
+ */
+ public function test_checkout_route_masks_engine_failure(): void {
+ $route = $this->create_checkout_route( new \TypeError( 'Fixture checkout route failure.' ) );
+ $response = $route->get_response( new WP_REST_Request( 'GET', '/unexpected-checkout-error-fixture' ) );
+
+ $this->assert_generic_error_response( $response );
+ }
+
+ /**
+ * @testdox Should mask an engine failure from a public batch route response.
+ */
+ public function test_batch_route_masks_engine_failure(): void {
+ $response = $this->dispatch_batch_route( new \TypeError( 'Fixture batch route failure.' ) );
+
+ $this->assert_generic_error_response( $response );
+ }
+
+ /**
+ * Dispatchers for each route kind, each throwing the given failure during dispatch.
+ *
+ * @return array<string, array{\Closure}>
+ */
+ public function provider_route_dispatchers(): array {
+ $request = static fn() => new WP_REST_Request( 'GET', '/unexpected-error-fixture' );
+
+ return array(
+ 'abstract' => array( static fn( self $test, \Throwable $failure ) => $test->create_abstract_route( $failure )->get_response( $request() ) ),
+ 'cart' => array( static fn( self $test, \Throwable $failure ) => $test->create_cart_route( $failure )->get_response( $request() ) ),
+ 'checkout' => array( static fn( self $test, \Throwable $failure ) => $test->create_checkout_route( $failure )->get_response( $request() ) ),
+ 'batch' => array( static fn( self $test, \Throwable $failure ) => $test->dispatch_batch_route( $failure ) ),
+ );
+ }
+
+ /**
+ * @testdox Should preserve the existing ordinary exception response for the $_dataName route.
+ * @dataProvider provider_route_dispatchers
+ *
+ * @param \Closure $dispatch Dispatches a request whose route throws the given failure.
+ */
+ public function test_ordinary_exception_response_is_unchanged( \Closure $dispatch ): void {
+ $response = $dispatch( $this, new \RuntimeException( 'Fixture ordinary exception.' ) );
+
+ $this->assertSame( 500, $response->get_status(), 'Ordinary exception responses should keep status 500.' );
+ $this->assertSame(
+ array(
+ 'code' => 'woocommerce_rest_unknown_server_error',
+ 'message' => 'Fixture ordinary exception.',
+ 'data' => array( 'status' => 500 ),
+ ),
+ $response->get_data(),
+ 'Ordinary exception responses should remain byte-for-byte compatible.'
+ );
+ }
+
+ /**
+ * @testdox Should preserve expected route error details.
+ */
+ public function test_expected_route_exception_response_is_unchanged(): void {
+ $route = $this->create_abstract_route(
+ new RouteException(
+ 'fixture_expected_error',
+ 'Fixture expected error.',
+ 409,
+ array( 'fixture' => true )
+ )
+ );
+
+ $response = $route->get_response( new WP_REST_Request( 'GET', '/unexpected-error-fixture' ) );
+
+ $this->assertSame( 409, $response->get_status(), 'Expected route errors should keep their status.' );
+ $this->assertSame(
+ array(
+ 'code' => 'fixture_expected_error',
+ 'message' => 'Fixture expected error.',
+ 'data' => array(
+ 'fixture' => true,
+ 'status' => 409,
+ ),
+ ),
+ $response->get_data(),
+ 'Expected route errors should keep their public payload.'
+ );
+ }
+
+ /**
+ * Dispatch a batch request whose inner REST server dispatch throws the given failure.
+ *
+ * @param \Throwable $failure Failure thrown by the REST server while serving the batch.
+ * @return WP_REST_Response
+ */
+ private function dispatch_batch_route( \Throwable $failure ): WP_REST_Response {
+ global $wp_rest_server;
+
+ $original_server = $wp_rest_server;
+ $wp_rest_server = new class( $failure ) extends WP_REST_Server {
+ /** @var \Throwable */
+ private $failure;
+
+ /**
+ * @param \Throwable $failure Failure thrown while serving the batch.
+ */
+ public function __construct( \Throwable $failure ) {
+ parent::__construct();
+ $this->failure = $failure;
+ }
+
+ /**
+ * @param WP_REST_Request $batch_request Batch request.
+ * @throws \Throwable Always throws the configured fixture failure.
+ */
+ public function serve_batch_request_v1( WP_REST_Request $batch_request ) {
+ throw $this->failure;
+ }
+ };
+
+ try {
+ $request = new WP_REST_Request( 'POST', '/unexpected-batch-error-fixture' );
+ $request->set_param(
+ 'requests',
+ array(
+ array( 'path' => '/wc/store/v1/products' ),
+ )
+ );
+
+ $route = new class() extends Batch {
+ /**
+ * Create the fixture without production constructor dependencies.
+ */
+ public function __construct() {}
+ };
+
+ return $route->get_response( $request );
+ } finally {
+ $wp_rest_server = $original_server;
+ }
+ }
+
+ /**
+ * Create a generic route with a configurable failure.
+ *
+ * @param \Throwable $failure Failure thrown during route dispatch.
+ * @return AbstractRoute
+ */
+ private function create_abstract_route( \Throwable $failure ): AbstractRoute {
+ return new class( $failure ) extends AbstractRoute {
+ use ConfiguredFailureRouteTrait;
+ };
+ }
+
+ /**
+ * Create a cart route with a configurable dispatch failure.
+ *
+ * @param \Throwable|null $failure Configured route failure, or null for success.
+ * @param bool $fail_while_loading Whether to fail while loading the cart session.
+ * @return AbstractCartRoute
+ */
+ private function create_cart_route( ?\Throwable $failure, bool $fail_while_loading = false ): AbstractCartRoute {
+ return new class( $failure, $fail_while_loading ) extends AbstractCartRoute {
+ use ConfiguredFailureRouteTrait {
+ __construct as private configure_failure;
+ }
+
+ /** @var bool */
+ private $fail_while_loading;
+
+ /**
+ * @param \Throwable|null $failure Configured route failure, or null for success.
+ * @param bool $fail_while_loading Whether to fail while loading the cart session.
+ */
+ public function __construct( ?\Throwable $failure, bool $fail_while_loading ) {
+ $this->configure_failure( $failure );
+ $this->fail_while_loading = $fail_while_loading;
+ }
+
+ /**
+ * @param WP_REST_Request $request Request object.
+ * @throws \Throwable The configured fixture failure, when set to fail while loading.
+ */
+ protected function load_cart_session( WP_REST_Request $request ) {
+ if ( $this->fail_while_loading ) {
+ throw $this->failure;
+ }
+ }
+
+ /**
+ * @param WP_REST_Request $request Request object.
+ * @throws \LogicException Always throws if this side effect is reached.
+ */
+ protected function cart_updated( WP_REST_Request $request ) {
+ throw new \LogicException( 'Cart update side effects reached.' );
+ }
+ };
+ }
+
+ /**
+ * Create a checkout route with a configurable dispatch failure.
+ *
+ * @param \Throwable $failure Failure thrown during route dispatch.
+ * @return Checkout
+ */
+ private function create_checkout_route( \Throwable $failure ): Checkout {
+ return new class( $failure ) extends Checkout {
+ use ConfiguredFailureRouteTrait;
+
+ /**
+ * @param WP_REST_Request $request Request object.
+ */
+ protected function load_cart_session( WP_REST_Request $request ) {}
+ };
+ }
+
+ /**
+ * Assert a generic public Store API error response.
+ *
+ * @param WP_REST_Response $response Response object.
+ */
+ private function assert_generic_error_response( WP_REST_Response $response ): void {
+ $this->assertSame( 500, $response->get_status(), 'Engine failures should become status-500 Store API responses.' );
+ $this->assertSame(
+ array(
+ 'code' => 'woocommerce_rest_unknown_server_error',
+ 'message' => __( 'Internal server error', 'woocommerce' ),
+ 'data' => array( 'status' => 500 ),
+ ),
+ $response->get_data(),
+ 'Public Store API responses must not disclose engine-error details.'
+ );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/UnexpectedErrorResponseTest.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/UnexpectedErrorResponseTest.php
new file mode 100644
index 00000000000..ff1c10914c3
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Utilities/UnexpectedErrorResponseTest.php
@@ -0,0 +1,161 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Utilities;
+
+use Automattic\Jetpack\Constants;
+use Automattic\WooCommerce\StoreApi\Utilities\UnexpectedErrorResponse;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Logger_Interface;
+use WC_Unit_Test_Case;
+use WP_Error;
+
+/**
+ * Tests for the UnexpectedErrorResponse class.
+ */
+class UnexpectedErrorResponseTest extends WC_Unit_Test_Case {
+ use LoggerSpyTrait;
+
+ /**
+ * Set up the current user.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_set_current_user( 0 );
+ Constants::set_constant( 'WP_DEBUG', false );
+ }
+
+ /**
+ * Restore the current user.
+ */
+ public function tearDown(): void {
+ wp_set_current_user( 0 );
+ Constants::clear_single_constant( 'WP_DEBUG' );
+ parent::tearDown();
+ }
+
+ /**
+ * @testdox Should disclose engine failure details only to managers in debug mode.
+ * @testWith [null, false, false]
+ * ["customer", false, false]
+ * [null, true, false]
+ * ["administrator", false, false]
+ * ["administrator", true, true]
+ *
+ * @param string|null $role Current user's role, or null for a guest.
+ * @param bool $debug_enabled Whether debug mode is enabled.
+ * @param bool $should_show_details Whether error details should be disclosed.
+ */
+ public function test_discloses_engine_failure_details_only_to_managers_in_debug_mode( ?string $role, bool $debug_enabled, bool $should_show_details ): void {
+ Constants::set_constant( 'WP_DEBUG', $debug_enabled );
+ if ( null !== $role ) {
+ $this->login_as_role( $role );
+ }
+
+ $result = UnexpectedErrorResponse::create( new \TypeError( 'Fixture engine failure.' ), self::class );
+
+ $this->assert_error_response( $result, $should_show_details, 'Engine failure disclosure should require both debug mode and manager capability.' );
+ }
+
+ /**
+ * @testdox Should log the engine failure as critical with a bounded backtrace.
+ */
+ public function test_logs_engine_failure_as_critical_with_bounded_backtrace(): void {
+ $error = new \TypeError( 'Fixture logged engine failure.' );
+
+ UnexpectedErrorResponse::create( $error, self::class );
+
+ $this->assertLogged(
+ 'critical',
+ self::class,
+ array(
+ 'source' => 'store-api',
+ 'exception' => $error,
+ )
+ );
+ $this->assertCount( 1, $this->captured_logs );
+ $message = $this->captured_logs[0]['message'];
+ $this->assertStringContainsString( \TypeError::class, $message );
+ $this->assertStringContainsString( 'Fixture logged engine failure.', $message );
+
+ $backtrace = $this->captured_logs[0]['context']['backtrace'];
+ $this->assertIsArray( $backtrace );
+ $this->assertGreaterThanOrEqual( 1, count( $backtrace ) );
+ $this->assertLessThanOrEqual( 10, count( $backtrace ) );
+ $this->assertStringStartsWith( '#0 ', $backtrace[0] );
+ }
+
+ /**
+ * @testdox Should return a safe response and fall back to the PHP error log when logging fails.
+ */
+ public function test_returns_safe_response_when_logging_fails(): void {
+ $logger = $this->createMock( WC_Logger_Interface::class );
+ $logger
+ ->method( 'critical' )
+ ->willThrowException( new \RuntimeException( 'Fixture logger failure.' ) );
+ add_filter( 'woocommerce_logging_class', fn() => $logger );
+
+ $error_log = tempnam( sys_get_temp_dir(), 'wc-store-api-error-log' );
+ $previous_error_log = ini_set( 'error_log', $error_log ); // phpcs:ignore WordPress.PHP.IniSet.Risky
+ try {
+ $result = UnexpectedErrorResponse::create( new \TypeError( 'Fixture engine failure.' ), self::class );
+ } finally {
+ ini_set( 'error_log', $previous_error_log ); // phpcs:ignore WordPress.PHP.IniSet.Risky
+ }
+
+ $this->assert_error_response( $result, false, 'A logging failure must not change the safe response.' );
+
+ $fallback = file_get_contents( $error_log ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
+ $this->assertStringContainsString( 'Fixture engine failure.', $fallback, 'The original failure must reach the PHP error log when the logger fails.' );
+ $this->assertStringContainsString( 'Fixture logger failure.', $fallback, 'The logging failure itself must be recorded alongside the original failure.' );
+ }
+
+ /**
+ * @testdox Should let the disclosure filter override debug mode but never the capability check.
+ * @testWith ["administrator", false, true, true]
+ * ["customer", false, true, false]
+ * ["administrator", true, false, false]
+ *
+ * @param string $role Current user's role.
+ * @param bool $debug_enabled Whether debug mode is enabled.
+ * @param bool $filter_value Value returned by the disclosure filter.
+ * @param bool $should_show_details Whether error details should be disclosed.
+ */
+ public function test_filter_overrides_debug_mode_but_not_capability( string $role, bool $debug_enabled, bool $filter_value, bool $should_show_details ): void {
+ Constants::set_constant( 'WP_DEBUG', $debug_enabled );
+ $this->login_as_role( $role );
+
+ $received_default = null;
+ add_filter(
+ 'woocommerce_store_api_expose_error_details',
+ static function ( $expose ) use ( $filter_value, &$received_default ) {
+ $received_default = $expose;
+ return $filter_value;
+ }
+ );
+
+ $result = UnexpectedErrorResponse::create( new \TypeError( 'Fixture engine failure.' ), self::class );
+
+ $this->assertSame( $debug_enabled, $received_default, 'The filter default must be the debug mode state.' );
+ $this->assert_error_response( $result, $should_show_details, 'Disclosure must require the capability even when the filter allows it.' );
+ }
+
+ /**
+ * Assert the error response for a fixture engine failure, masked or disclosed.
+ *
+ * @param WP_Error $result Response created for the fixture failure.
+ * @param bool $details_shown Whether the message and exception class should be disclosed.
+ * @param string $failure_message Assertion message.
+ */
+ private function assert_error_response( WP_Error $result, bool $details_shown, string $failure_message ): void {
+ $expected_message = $details_shown ? 'Fixture engine failure.' : __( 'Internal server error', 'woocommerce' );
+ $expected_data = array( 'status' => 500 );
+ if ( $details_shown ) {
+ $expected_data['exception_class'] = \TypeError::class;
+ }
+
+ $this->assertSame( 'woocommerce_rest_unknown_server_error', $result->get_error_code(), 'Engine failures should use the established error code.' );
+ $this->assertSame( $expected_message, $result->get_error_message(), $failure_message );
+ $this->assertSame( $expected_data, $result->get_error_data(), 'Response data must follow the same gate as the message.' );
+ }
+}