Commit 6eb03d211f2 for woocommerce

commit 6eb03d211f20013e7e197a015d5c5f61baf680e7
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Thu Aug 20 17:52:49 2026 +0100

    Fix Store API fatal when the cart session fails to load (#67769)

    Cart initialization ran before the route try/catch block, so any Throwable
    raised while restoring the cart from the session escaped the Store API error
    conversion and surfaced as a fatal 500 with no Cart-Token, Cart-Hash or Nonce
    headers. Checkout duplicated the same load-before-try ordering in its own
    get_response() override.

    Wrap the load in both AbstractCartRoute::get_response() and Checkout's
    override, log the original throwable server-side, and return a client-safe
    error response through a shared get_cart_session_error_response() helper.

    Cart-dependent response headers are now emitted only when a cart is available,
    so header generation cannot fatal a second time on the failure path.

diff --git a/plugins/woocommerce/changelog/67124-fix-store-api-cart-session-error-boundary b/plugins/woocommerce/changelog/67124-fix-store-api-cart-session-error-boundary
new file mode 100644
index 00000000000..a1b3c65014b
--- /dev/null
+++ b/plugins/woocommerce/changelog/67124-fix-store-api-cart-session-error-boundary
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Return a Store API error response instead of a fatal error when the cart cannot be loaded from the session.
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
index 98b565ae0f0..06a688097a5 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
@@ -103,6 +103,37 @@ abstract class AbstractCartRoute extends AbstractRoute {
 		return in_array( $request->get_method(), [ 'POST', 'PUT', 'PATCH', 'DELETE' ], true );
 	}

+	/**
+	 * 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.
+	 *
+	 * @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
+			)
+		);
+	}
+
 	/**
 	 * Get the route response based on the type of request.
 	 *
@@ -111,7 +142,11 @@ abstract class AbstractCartRoute extends AbstractRoute {
 	 * @return \WP_REST_Response
 	 */
 	public function get_response( \WP_REST_Request $request ) {
-		$this->load_cart_session( $request );
+		try {
+			$this->load_cart_session( $request );
+		} catch ( \Throwable $error ) {
+			return $this->add_response_headers( $this->get_cart_session_error_response( $error ) );
+		}

 		$response    = null;
 		$nonce_check = $this->requires_nonce( $request ) ? $this->check_nonce( $request ) : null;
@@ -156,10 +191,13 @@ abstract class AbstractCartRoute extends AbstractRoute {
 		$response->header( 'Nonce', $nonce );
 		$response->header( 'Nonce-Timestamp', time() );
 		$response->header( 'User-ID', get_current_user_id() );
-		$response->header( 'Cart-Token', $this->get_cart_token() );
-		$response->header( 'Cart-Hash', WC()->cart->get_cart_hash() );
 		$response->header( 'Cache-Control', 'no-store' );

+		if ( WC()->cart instanceof \WC_Cart ) {
+			$response->header( 'Cart-Token', $this->get_cart_token() );
+			$response->header( 'Cart-Hash', WC()->cart->get_cart_hash() );
+		}
+
 		return $response;
 	}

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
index c33f1a42451..773fa04f6e8 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php
@@ -153,7 +153,11 @@ class Checkout extends AbstractCartRoute {
 	 * @return \WP_REST_Response
 	 */
 	public function get_response( \WP_REST_Request $request ) {
-		$this->load_cart_session( $request );
+		try {
+			$this->load_cart_session( $request );
+		} catch ( \Throwable $error ) {
+			return $this->add_response_headers( $this->get_cart_session_error_response( $error ) );
+		}

 		$response    = null;
 		$nonce_check = $this->requires_nonce( $request ) ? $this->check_nonce( $request ) : null;
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
index a1058d80d23..625628034b6 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -33,6 +33,13 @@ class Cart extends ControllerTestCase {
 	 */
 	private static $coupon_id;

+	/**
+	 * Cart instance removed to mimic a REST request, restored on teardown.
+	 *
+	 * @var \WC_Cart|null
+	 */
+	private $cart_backup = null;
+
 	/**
 	 * Create immutable catalog rows shared by all test methods.
 	 */
@@ -1550,4 +1557,64 @@ class Cart extends ControllerTestCase {
 		remove_action( 'internal_woocommerce_cart_item_added_from_user_request', $callback );
 		remove_filter( 'woocommerce_store_api_add_to_cart_data', $add_to_cart_data_callback );
 	}
+
+	/**
+	 * @testdox Should return an error response when restoring the cart session throws.
+	 */
+	public function test_cart_session_failure_returns_error_response() {
+		wc()->session->set( 'cart', wc()->cart->get_cart_for_session() );
+
+		// The route restores the cart only when this action has not run yet, so reset
+		// the counter to put the process back into the state a REST request starts in.
+		unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+
+		add_filter(
+			'woocommerce_get_cart_item_from_session',
+			static function () {
+				throw new \RuntimeException( 'Synthetic Store API cart-session failure.' );
+			}
+		);
+
+		$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+
+		$this->assertSame( 500, $response->get_status(), 'A cart session failure should return a Store API error response.' );
+		$this->assertSame( 'woocommerce_rest_unknown_server_error', $response->get_data()['code'] );
+	}
+
+	/**
+	 * @testdox Should return an error response when the cart session fails before it is restored.
+	 */
+	public function test_cart_session_failure_before_restore_returns_error_response() {
+		// This filter runs before `get_cart_from_session()` fires its action, so nothing
+		// stops the response headers attempting a second load of the failed cart.
+		unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+
+		// A REST request never runs `initialize_cart()`, so the route starts with no
+		// cart at all. The test bootstrap leaves one behind.
+		$this->cart_backup = WC()->cart;
+		WC()->cart         = null;
+
+		add_filter(
+			'woocommerce_session_handler',
+			static function () {
+				throw new \RuntimeException( 'Synthetic session handler failure.' );
+			}
+		);
+
+		$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+
+		$this->assertSame( 500, $response->get_status(), 'A cart session failure should return a Store API error response.' );
+	}
+
+	/**
+	 * Restore the cart instance removed by the cart session failure tests.
+	 */
+	public function tearDown(): void {
+		if ( $this->cart_backup instanceof \WC_Cart ) {
+			WC()->cart         = $this->cart_backup;
+			$this->cart_backup = null;
+		}
+
+		parent::tearDown();
+	}
 }
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 f63c2b0f8e8..3065948c43e 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -3208,4 +3208,27 @@ class Checkout extends \WP_Test_REST_TestCase {
 		$this->assertNull( $result, 'No payment method on a zero-total order should resolve to a null gateway.' );
 		$this->assertSame( 0, $gateway_resolution_count, 'Available payment gateways must not be resolved when no payment method is supplied.' );
 	}
+
+	/**
+	 * @testdox Should return an error response when restoring the cart session throws.
+	 */
+	public function test_cart_session_failure_returns_error_response() {
+		wc()->session->set( 'cart', wc()->cart->get_cart_for_session() );
+
+		// The route restores the cart only when this action has not run yet, so reset
+		// the counter to put the process back into the state a REST request starts in.
+		unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+
+		add_filter(
+			'woocommerce_get_cart_item_from_session',
+			static function () {
+				throw new \RuntimeException( 'Synthetic Store API cart-session failure.' );
+			}
+		);
+
+		$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/checkout' ) );
+
+		$this->assertSame( 500, $response->get_status(), 'A cart session failure should return a Store API error response.' );
+		$this->assertSame( 'woocommerce_rest_unknown_server_error', $response->get_data()['code'] );
+	}
 }