Commit 693740ba210 for woocommerce

commit 693740ba210288c95fea4911554f41ca4cde9de2
Author: Thomas Roberts <5656702+opr@users.noreply.github.com>
Date:   Wed Sep 16 10:00:13 2026 +0100

    Prevent cart loss after Store API batch load failures (#67920)

    * Fix cart persistence after Store API batch load failures

    * Add changelog entry for Store API cart session fix

    * Fix PHPStan cart type regression

    * Prevent failed Store API carts from updating sessions

    * Track failed carts explicitly for session updates

    * Cover failed cart session callbacks

    * Expand Store API cart failure regression coverage

    * Guard all failed cart session writes

    * Fix Batch regression test deadlock

    * Keep failed Store API carts available to extensions

    * Limit cart invalidation to session load failures

    * Document Store API batch cart failure responses

diff --git a/docs/apis/store-api/cart-tokens.md b/docs/apis/store-api/cart-tokens.md
index f9ac5d6499f..36759fb6c4a 100644
--- a/docs/apis/store-api/cart-tokens.md
+++ b/docs/apis/store-api/cart-tokens.md
@@ -9,7 +9,7 @@ Cart tokens can be used instead of cookies based sessions for headless interacti

 ## Obtaining a Cart Token

-Requests to `/cart` endpoints return a `Cart-Token` header alongside the response. This contains a token which can later be sent as a request header to the Store API Cart and Checkout endpoints to identify the cart.
+Successful requests to `/cart` endpoints return a `Cart-Token` header alongside the response. This contains a token which can later be sent as a request header to the Store API Cart and Checkout endpoints to identify the cart. Error responses caused by a cart session loading failure do not include `Cart-Token` or `Cart-Hash` headers.

 The quickest method of obtaining a Cart Token is to make a GET request `/wp-json/wc/store/v1/cart` and observe the response headers. You should see a `Cart-Token` header there.

diff --git a/docs/apis/store-api/resources-endpoints/cart.md b/docs/apis/store-api/resources-endpoints/cart.md
index dce25c3863d..f3ec22e67ca 100644
--- a/docs/apis/store-api/resources-endpoints/cart.md
+++ b/docs/apis/store-api/resources-endpoints/cart.md
@@ -483,6 +483,8 @@ The JSON payload for adding multiple items to the cart would look like this:
 }
 ```

+If a cart subrequest fails while loading its session, it returns a `500` response with the `woocommerce_rest_unknown_server_error` error code. Later cart subrequests in the same batch also return `500` instead of using the incomplete cart, while non-cart subrequests continue normally. These failed cart responses do not include `Cart-Token` or `Cart-Hash` headers.
+
 ## Remove Item

 Remove an item from the cart and return the full cart response, or an error.
diff --git a/plugins/woocommerce/changelog/fix-store-api-batch-cart-session-failure b/plugins/woocommerce/changelog/fix-store-api-batch-cart-session-failure
new file mode 100644
index 00000000000..8677b5752c2
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-store-api-batch-cart-session-failure
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Prevent failed Store API batch requests from clearing the shopper's saved cart.
diff --git a/plugins/woocommerce/includes/class-wc-cart-session.php b/plugins/woocommerce/includes/class-wc-cart-session.php
index a98a69a28a4..a5f17d40650 100644
--- a/plugins/woocommerce/includes/class-wc-cart-session.php
+++ b/plugins/woocommerce/includes/class-wc-cart-session.php
@@ -20,6 +20,13 @@ if ( ! defined( 'ABSPATH' ) ) {
  */
 final class WC_Cart_Session {

+	/**
+	 * Carts whose session updates have been disabled.
+	 *
+	 * @var WeakReference<WC_Cart>[]
+	 */
+	private static $carts_with_disabled_updates = array();
+
 	/**
 	 * Reference to cart object.
 	 *
@@ -96,6 +103,10 @@ final class WC_Cart_Session {
 	 * @since 3.2.0
 	 */
 	public function get_cart_from_session() {
+		if ( $this->should_skip_session_updates() ) {
+			return;
+		}
+
 		/**
 		 * Fires when cart is loaded from session.
 		 *
@@ -305,12 +316,60 @@ final class WC_Cart_Session {
 		}
 	}

+	/**
+	 * Enables or disables session updates for a cart.
+	 *
+	 * @internal
+	 * @since 11.2.0
+	 *
+	 * @param WC_Cart $cart    Cart object.
+	 * @param bool    $enabled Whether session updates should be enabled.
+	 */
+	public static function set_updates_enabled_for_cart( WC_Cart $cart, $enabled ): void {
+		$cart_id = spl_object_id( $cart );
+
+		if ( $enabled ) {
+			unset( self::$carts_with_disabled_updates[ $cart_id ] );
+		} else {
+			self::$carts_with_disabled_updates[ $cart_id ] = WeakReference::create( $cart );
+		}
+	}
+
+	/**
+	 * Checks whether session updates are enabled for a cart.
+	 *
+	 * @internal
+	 * @since 11.2.0
+	 *
+	 * @param WC_Cart $cart Cart object.
+	 * @return bool
+	 */
+	public static function are_updates_enabled_for_cart( WC_Cart $cart ): bool {
+		$cart_id = spl_object_id( $cart );
+
+		if ( ! isset( self::$carts_with_disabled_updates[ $cart_id ] ) ) {
+			return true;
+		}
+
+		$disabled_cart = self::$carts_with_disabled_updates[ $cart_id ]->get();
+		if ( null === $disabled_cart ) {
+			unset( self::$carts_with_disabled_updates[ $cart_id ] );
+			return true;
+		}
+
+		return $disabled_cart !== $cart;
+	}
+
 	/**
 	 * Destroy cart session data.
 	 *
 	 * @since 3.2.0
 	 */
 	public function destroy_cart_session() {
+		if ( $this->should_skip_session_updates() ) {
+			return;
+		}
+
 		$wc_session = WC()->session;

 		$wc_session->set( 'cart', null );
@@ -335,7 +394,7 @@ final class WC_Cart_Session {
 	 * @since 3.2.0
 	 */
 	public function maybe_set_cart_cookies() {
-		if ( headers_sent() || ! did_action( 'wp_loaded' ) ) {
+		if ( $this->should_skip_session_updates() || headers_sent() || ! did_action( 'wp_loaded' ) ) {
 			return;
 		}
 		if ( ! $this->cart->is_empty() ) {
@@ -402,6 +461,10 @@ final class WC_Cart_Session {
 	 * Sets the php session data for the cart and coupons.
 	 */
 	public function set_session() {
+		if ( $this->should_skip_session_updates() ) {
+			return;
+		}
+
 		$wc_session = WC()->session;

 		$cart                       = $this->get_cart_for_session();
@@ -458,6 +521,10 @@ final class WC_Cart_Session {
 	 * Save the persistent cart when the cart is updated.
 	 */
 	public function persistent_cart_update() {
+		if ( $this->should_skip_session_updates() ) {
+			return;
+		}
+
 		/**
 		 * Filters whether the persistent cart is enabled.
 		 *
@@ -479,6 +546,10 @@ final class WC_Cart_Session {
 	 * Delete the persistent cart permanently.
 	 */
 	public function persistent_cart_destroy() {
+		if ( $this->should_skip_session_updates() ) {
+			return;
+		}
+
 		/**
 		 * Filters whether the persistent cart is enabled.
 		 *
@@ -721,12 +792,25 @@ final class WC_Cart_Session {
 		return false;
 	}

+	/**
+	 * Checks whether session updates have been disabled for this cart.
+	 *
+	 * @return bool
+	 */
+	private function should_skip_session_updates() {
+		return ! self::are_updates_enabled_for_cart( $this->cart );
+	}
+
 	/**
 	 * Removes items from the removed cart contents on next user initiated request.
 	 *
 	 * @return void
 	 */
 	public function clean_up_removed_cart_contents() {
+		if ( $this->should_skip_session_updates() ) {
+			return;
+		}
+
 		// Limit to page requests initiated by the user.
 		$is_page = is_singular() || is_archive() || is_search();

diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
index 06a688097a5..610563d1edb 100644
--- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php
@@ -193,7 +193,7 @@ abstract class AbstractCartRoute extends AbstractRoute {
 		$response->header( 'User-ID', get_current_user_id() );
 		$response->header( 'Cache-Control', 'no-store' );

-		if ( WC()->cart instanceof \WC_Cart ) {
+		if ( WC()->cart instanceof \WC_Cart && \WC_Cart_Session::are_updates_enabled_for_cart( WC()->cart ) ) {
 			$response->header( 'Cart-Token', $this->get_cart_token() );
 			$response->header( 'Cart-Hash', WC()->cart->get_cart_hash() );
 		}
@@ -204,19 +204,34 @@ abstract class AbstractCartRoute extends AbstractRoute {
 	/**
 	 * Load the cart session before handling responses.
 	 *
+	 * @throws \RuntimeException When a previous cart session load failed.
+	 * @throws \Throwable When the cart cannot be loaded or normalized.
 	 * @param \WP_REST_Request $request Request object.
 	 */
 	protected function load_cart_session( \WP_REST_Request $request ) {
-		if ( $this->has_cart_token( $request ) ) {
-			// Overrides the core session class.
-			add_filter(
-				'woocommerce_session_handler',
-				function () {
-					return SessionHandler::class;
-				}
-			);
+		if ( WC()->cart instanceof \WC_Cart && ! \WC_Cart_Session::are_updates_enabled_for_cart( WC()->cart ) ) {
+			throw new \RuntimeException( 'The cart is unavailable after its session failed to load.' );
 		}
-		$this->cart_controller->load_cart();
+
+		try {
+			if ( $this->has_cart_token( $request ) ) {
+				// Overrides the core session class.
+				add_filter(
+					'woocommerce_session_handler',
+					function () {
+						return SessionHandler::class;
+					}
+				);
+			}
+			$this->cart_controller->load_cart();
+		} catch ( \Throwable $error ) {
+			if ( WC()->cart instanceof \WC_Cart ) {
+				\WC_Cart_Session::set_updates_enabled_for_cart( WC()->cart, false );
+			}
+
+			throw $error;
+		}
+
 		$this->cart_controller->normalize_cart();
 	}

diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Batch.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Batch.php
index 0882cf1fdc1..670711f0c34 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Batch.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Batch.php
@@ -130,6 +130,147 @@ class Batch extends ControllerTestCase {
 	}


+	/**
+	 * @testdox Should preserve the session cart when loading it fails in a batch sub-request.
+	 */
+	public function test_cart_session_failure_does_not_clear_session_cart(): void {
+		WC()->cart->add_to_cart( $this->products[0]->get_id() );
+		WC()->cart->add_to_cart( $this->products[1]->get_id() );
+
+		global $wp_query;
+
+		$stored_cart              = WC()->cart->get_cart_for_session();
+		$cart_contents_backup     = WC()->cart->get_cart_contents();
+		$cart_backup              = WC()->cart;
+		$load_action_count        = $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] ?? null;
+		$current_user_id          = get_current_user_id();
+		$is_singular_backup       = $wp_query->is_singular;
+		$is_archive_backup        = $wp_query->is_archive;
+		$is_search_backup         = $wp_query->is_search;
+		$restored_item_count      = 0;
+		$session_failure_callback = static function ( $session_data ) use ( &$restored_item_count ) {
+			++$restored_item_count;
+			if ( 2 === $restored_item_count ) {
+				throw new \RuntimeException( 'Synthetic Store API cart-session failure.' );
+			}
+			return $session_data;
+		};
+		WC()->session->set( 'cart', $stored_cart );
+		WC()->cart->set_cart_contents( array() );
+		unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+		add_filter( 'woocommerce_get_cart_item_from_session', $session_failure_callback );
+
+		$request = new \WP_REST_Request( 'POST', '/wc/store/v1/batch' );
+		$request->set_body_params(
+			array(
+				'requests' => array(
+					array(
+						'method'  => 'POST',
+						'path'    => '/wc/store/v1/cart/update-customer',
+						'body'    => array(),
+						'headers' => array( 'Nonce' => wp_create_nonce( 'wc_store_api' ) ),
+					),
+					array(
+						'method'  => 'POST',
+						'path'    => '/wc/store/v1/cart/update-customer',
+						'body'    => array(),
+						'headers' => array( 'Nonce' => wp_create_nonce( 'wc_store_api' ) ),
+					),
+					array(
+						'method' => 'GET',
+						'path'   => '/wc/store/v1/products',
+					),
+				),
+			)
+		);
+
+		try {
+			$response      = rest_get_server()->dispatch( $request );
+			$response_data = $response->get_data();
+
+			$this->assertSame( 500, $response_data['responses'][0]['status'], 'The request that fails to load the cart should return an error.' );
+			$this->assertSame( 500, $response_data['responses'][1]['status'], 'Later cart requests should not use a partially loaded cart.' );
+			$this->assertSame( 200, $response_data['responses'][2]['status'], 'Later non-cart requests should remain available.' );
+			$this->assertCount( 1, $cart_backup->get_cart_contents(), 'The synthetic failure should occur after partially restoring the cart.' );
+			$this->assertArrayNotHasKey( 'Cart-Token', $response_data['responses'][0]['headers'], 'A failed cart response should not include a cart token.' );
+			$this->assertArrayNotHasKey( 'Cart-Hash', $response_data['responses'][0]['headers'], 'A failed cart response should not include a cart hash.' );
+
+			$failed_cart_session = new \WC_Cart_Session( $cart_backup );
+			$failed_cart_session->get_cart_from_session();
+			$this->assertCount( 1, $cart_backup->get_cart_contents(), 'The failed cart should not resume loading from the session.' );
+			$this->assertSame( $cart_backup, WC()->cart, 'The failed cart should remain available to extension callbacks.' );
+
+			WC()->cart->empty_cart();
+			$this->assertSame( $stored_cart, WC()->session->get( 'cart' ), 'Emptying the failed cart should not destroy the session.' );
+
+			do_action( 'woocommerce_removed_coupon', 'synthetic-coupon' );
+			$this->assertSame( $stored_cart, WC()->session->get( 'cart' ), 'The failed cart should not update the session.' );
+
+			$user_id                = self::factory()->user->create();
+			$persistent_cart_key    = '_woocommerce_persistent_cart_' . get_current_blog_id();
+			$stored_persistent_cart = array( 'cart' => $stored_cart );
+			wp_set_current_user( $user_id );
+			update_user_meta( $user_id, $persistent_cart_key, $stored_persistent_cart );
+			do_action( 'woocommerce_cart_item_set_quantity', 'synthetic-item', 2, $cart_backup );
+			$this->assertSame( $stored_persistent_cart, get_user_meta( $user_id, $persistent_cart_key, true ), 'The failed cart should not update the persistent cart.' );
+			$failed_cart_session->persistent_cart_destroy();
+			$this->assertSame( $stored_persistent_cart, get_user_meta( $user_id, $persistent_cart_key, true ), 'The failed cart should not destroy the persistent cart.' );
+
+			$stored_removed_cart_contents = array( 'synthetic-item' => array( 'quantity' => 1 ) );
+			WC()->session->set( 'removed_cart_contents', $stored_removed_cart_contents );
+			$wp_query->is_singular = true;
+			$wp_query->is_archive  = false;
+			$wp_query->is_search   = false;
+			$failed_cart_session->clean_up_removed_cart_contents();
+			$this->assertSame( $stored_removed_cart_contents, WC()->session->get( 'removed_cart_contents' ), 'The failed cart should not clean up removed cart contents.' );
+		} finally {
+			remove_filter( 'woocommerce_get_cart_item_from_session', $session_failure_callback );
+			\WC_Cart_Session::set_updates_enabled_for_cart( $cart_backup, true );
+			WC()->cart = $cart_backup;
+			WC()->cart->set_cart_contents( $cart_contents_backup );
+			wp_set_current_user( $current_user_id );
+			$wp_query->is_singular = $is_singular_backup;
+			$wp_query->is_archive  = $is_archive_backup;
+			$wp_query->is_search   = $is_search_backup;
+			if ( null === $load_action_count ) {
+				unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+			} else {
+				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action count changed by the test.
+				$GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] = $load_action_count;
+			}
+		}
+	}
+
+	/**
+	 * @testdox Disabling cart-session updates only affects the exact marked cart.
+	 */
+	public function test_cart_session_update_registry_is_scoped_to_the_marked_cart(): void {
+		$marked_cart         = WC()->cart;
+		$unmarked_clone      = clone $marked_cart;
+		$marked_session      = new \WC_Cart_Session( $marked_cart );
+		$unmarked_session    = new \WC_Cart_Session( $unmarked_clone );
+		$stored_session_cart = $marked_cart->get_cart_for_session();
+
+		try {
+			\WC_Cart_Session::set_updates_enabled_for_cart( $marked_cart, false );
+
+			WC()->session->set( 'cart', $stored_session_cart );
+			$marked_session->destroy_cart_session();
+			$this->assertSame( $stored_session_cart, WC()->session->get( 'cart' ), 'The marked cart should not destroy session data.' );
+
+			$unmarked_session->destroy_cart_session();
+			$this->assertNull( WC()->session->get( 'cart' ), 'An unmarked clone should continue updating session data.' );
+
+			WC()->session->set( 'cart', $stored_session_cart );
+			\WC_Cart_Session::set_updates_enabled_for_cart( $marked_cart, true );
+			$marked_session->destroy_cart_session();
+			$this->assertNull( WC()->session->get( 'cart' ), 'Re-enabled carts should resume updating session data.' );
+		} finally {
+			\WC_Cart_Session::set_updates_enabled_for_cart( $marked_cart, true );
+			WC()->session->set( 'cart', $stored_session_cart );
+		}
+	}
+
 	/**
 	 * Do a batch request with a get request.
 	 */
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 1f8ee4666ab..ea631492200 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Cart.php
@@ -33,13 +33,6 @@ 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.
 	 */
@@ -2301,19 +2294,33 @@ class Cart extends ControllerTestCase {

 		// 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.
+		$load_action_count = $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] ?? null;
 		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.' );
-			}
-		);
+		$cart_backup = WC()->cart;
+		$callback    = static function () {
+			throw new \RuntimeException( 'Synthetic Store API cart-session failure.' );
+		};
+		add_filter( 'woocommerce_get_cart_item_from_session', $callback );

-		$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+		try {
+			$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+		} finally {
+			remove_filter( 'woocommerce_get_cart_item_from_session', $callback );
+			\WC_Cart_Session::set_updates_enabled_for_cart( $cart_backup, true );
+			WC()->cart = $cart_backup;
+			if ( null === $load_action_count ) {
+				unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+			} else {
+				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action count changed by the test.
+				$GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] = $load_action_count;
+			}
+		}

 		$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'] );
+		$this->assertArrayNotHasKey( 'Cart-Token', $response->get_headers(), 'A failed cart response should not include a cart token.' );
+		$this->assertArrayNotHasKey( 'Cart-Hash', $response->get_headers(), 'A failed cart response should not include a cart hash.' );
 	}

 	/**
@@ -2322,34 +2329,33 @@ class Cart extends ControllerTestCase {
 	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.
+		$load_action_count = $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] ?? null;
 		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.' );
-			}
-		);
+		$cart_backup = WC()->cart;
+		WC()->cart   = null;

-		$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.' );
-	}
+		$callback = static function () {
+			throw new \RuntimeException( 'Synthetic session handler failure.' );
+		};
+		add_filter( 'woocommerce_session_handler', $callback );

-	/**
-	 * 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;
+		try {
+			$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/cart' ) );
+		} finally {
+			remove_filter( 'woocommerce_session_handler', $callback );
+			\WC_Cart_Session::set_updates_enabled_for_cart( $cart_backup, true );
+			WC()->cart = $cart_backup;
+			if ( null === $load_action_count ) {
+				unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+			} else {
+				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action count changed by the test.
+				$GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] = $load_action_count;
+			}
 		}

-		parent::tearDown();
+		$this->assertSame( 500, $response->get_status(), 'A cart session failure should return a Store API error response.' );
 	}
 }
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 7a3d7053c35..ff1c42db37d 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php
@@ -3381,16 +3381,28 @@ class Checkout extends \WP_Test_REST_TestCase {

 		// 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.
+		$load_action_count = $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] ?? null;
 		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.' );
-			}
-		);
+		$cart_backup = WC()->cart;
+		$callback    = static function () {
+			throw new \RuntimeException( 'Synthetic Store API cart-session failure.' );
+		};
+		add_filter( 'woocommerce_get_cart_item_from_session', $callback );

-		$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/checkout' ) );
+		try {
+			$response = rest_get_server()->dispatch( new \WP_REST_Request( 'GET', '/wc/store/v1/checkout' ) );
+		} finally {
+			remove_filter( 'woocommerce_get_cart_item_from_session', $callback );
+			\WC_Cart_Session::set_updates_enabled_for_cart( $cart_backup, true );
+			WC()->cart = $cart_backup;
+			if ( null === $load_action_count ) {
+				unset( $GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] );
+			} else {
+				// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restore the action count changed by the test.
+				$GLOBALS['wp_actions']['woocommerce_load_cart_from_session'] = $load_action_count;
+			}
+		}

 		$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'] );