Commit 1817137a41a for woocommerce

commit 1817137a41a41ea3c495dac77adc4c6ac0153671
Author: Hannah Tinkler <hannah.tinkler@gmail.com>
Date:   Fri May 29 18:04:45 2026 +0100

    Add functionality to retry failed requests to the wpcom send (#63980)

    * Add notification processor, send controller, and safety net.

    - Add PushNotificationRestController for JWT-authed loopback send endpoint.
    - Add NotificationProcessor to orchestrate sending via WPCOM dispatcher.
    - Wire PendingNotificationStore to dispatch on shutdown and schedule AS safety net.
    - Register new controller and processor in PushNotifications on_init.

diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
index 621267720e0..265f9b92ebb 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Controllers/PushNotificationRestController.php
@@ -115,7 +115,7 @@ class PushNotificationRestController {
 	 * @since 10.7.0
 	 */
 	public function authorize( WP_REST_Request $request ) {
-		$header = $request->get_header( 'authorization' );
+		$header = trim( (string) $request->get_header( 'authorization' ) );

 		if ( empty( $header ) ) {
 			return new WP_Error(
@@ -125,7 +125,7 @@ class PushNotificationRestController {
 			);
 		}

-		$token = (string) preg_replace( '/^\s*Bearer\s+/i', '', $header );
+		$token = strncasecmp( $header, 'Bearer ', 7 ) === 0 ? substr( $header, 7 ) : $header;

 		if ( ! JsonWebToken::validate( $token, wp_salt( 'auth' ) ) ) {
 			return new WP_Error(
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
index 690d3cd9630..a80250d3f53 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/PushNotifications.php
@@ -12,6 +12,7 @@ use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushNotificati
 use Automattic\WooCommerce\Internal\PushNotifications\Controllers\PushTokenRestController;
 use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\PendingNotificationStore;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewOrderNotificationTrigger;
 use Automattic\WooCommerce\Internal\PushNotifications\Triggers\NewReviewNotificationTrigger;
@@ -88,6 +89,7 @@ class PushNotifications {
 		( new StockNotificationRecoveryHandler() )->register();

 		wc_get_container()->get( NotificationProcessor::class )->register();
+		wc_get_container()->get( NotificationRetryHandler::class )->register();
 	}

 	/**
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
index f3c3093cb69..058cdb13294 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationProcessor.php
@@ -70,6 +70,13 @@ class NotificationProcessor {
 	 */
 	private NotificationPreferencesService $preferences_service;

+	/**
+	 * The retry handler.
+	 *
+	 * @var NotificationRetryHandler
+	 */
+	private NotificationRetryHandler $retry_handler;
+
 	/**
 	 * Initialize dependencies.
 	 *
@@ -78,17 +85,20 @@ class NotificationProcessor {
 	 * @param WpcomNotificationDispatcher    $dispatcher          The WPCOM dispatcher.
 	 * @param PushTokensDataStore            $data_store          The push tokens data store.
 	 * @param NotificationPreferencesService $preferences_service The notification preferences service.
+	 * @param NotificationRetryHandler       $retry_handler The retry handler.
 	 *
 	 * @since 10.7.0
 	 */
 	final public function init(
 		WpcomNotificationDispatcher $dispatcher,
 		PushTokensDataStore $data_store,
-		NotificationPreferencesService $preferences_service
+		NotificationPreferencesService $preferences_service,
+		NotificationRetryHandler $retry_handler
 	): void {
 		$this->dispatcher          = $dispatcher;
 		$this->data_store          = $data_store;
 		$this->preferences_service = $preferences_service;
+		$this->retry_handler       = $retry_handler;
 	}

 	/**
@@ -107,11 +117,12 @@ class NotificationProcessor {
 	 *
 	 * @param Notification $notification The notification to process.
 	 * @param bool         $is_retry     Whether this is a retry or safety net attempt.
+	 * @param int          $attempt      The current attempt number (0 = first attempt).
 	 * @return bool True if successfully sent (or already sent).
 	 *
 	 * @since 10.7.0
 	 */
-	public function process( Notification $notification, bool $is_retry = false ): bool {
+	public function process( Notification $notification, bool $is_retry = false, int $attempt = 0 ): bool {
 		/**
 		 * This notification has already been sent - don't continue.
 		 */
@@ -159,6 +170,7 @@ class NotificationProcessor {
 		 */
 		if ( empty( $tokens ) ) {
 			$notification->write_meta( self::SENT_META_KEY );
+			$this->cancel_safety_net( $notification );
 			return true;
 		}

@@ -167,13 +179,12 @@ class NotificationProcessor {
 		if ( ! empty( $result['success'] ) ) {
 			$notification->write_meta( self::SENT_META_KEY );
 			$notification->delete_meta( self::CLAIMED_META_KEY );
+			$this->cancel_safety_net( $notification );
 			return true;
 		}

-		/**
-		 * Retry scheduling and safety net deletion will be added here when
-		 * NotificationRetryHandler is added.
-		 */
+		$this->retry_handler->schedule( $notification, $result['retry_after'] ?? null, $attempt );
+		$this->cancel_safety_net( $notification );

 		return false;
 	}
@@ -223,6 +234,28 @@ class NotificationProcessor {
 		);
 	}

+	/**
+	 * Cancels the pending safety net ActionScheduler job for a notification.
+	 *
+	 * Called after the processor handles the notification (whether success or
+	 * failure with retry scheduled) so the safety net doesn't fire redundantly.
+	 *
+	 * @param Notification $notification The notification whose safety net to cancel.
+	 * @return void
+	 *
+	 * @since 10.9.0
+	 */
+	private function cancel_safety_net( Notification $notification ): void {
+		as_unschedule_all_actions(
+			self::SAFETY_NET_HOOK,
+			array(
+				'type'        => $notification->get_type(),
+				'resource_id' => $notification->get_resource_id(),
+			),
+			self::ACTION_SCHEDULER_GROUP
+		);
+	}
+
 	/**
 	 * ActionScheduler callback for the safety net job. This will be scheduled
 	 * for 60 seconds in the future when a notification is added to the
@@ -250,13 +283,22 @@ class NotificationProcessor {
 			) + $extra;

 			$notification = Notification::from_array( $data );
+		} catch ( Exception $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Safety net failed: %s', $e->getMessage() ),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+			return;
+		}

+		try {
 			$this->process( $notification, true );
 		} catch ( Exception $e ) {
 			wc_get_logger()->error(
 				sprintf( 'Safety net failed: %s', $e->getMessage() ),
 				array( 'source' => PushNotifications::FEATURE_NAME )
 			);
+			$this->retry_handler->schedule( $notification, null, 0 );
 		}
 	}
 }
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
new file mode 100644
index 00000000000..f5374926fef
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
@@ -0,0 +1,170 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\PushNotifications\Services;
+
+defined( 'ABSPATH' ) || exit;
+
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Exception;
+
+/**
+ * Handles retry scheduling for failed WPCOM push notification sends.
+ *
+ * Uses ActionScheduler to schedule retries with exponential backoff.
+ * After all retry attempts are exhausted, logs a permanent failure.
+ *
+ * @since 10.8.0
+ */
+class NotificationRetryHandler {
+
+	/**
+	 * ActionScheduler hook for retry jobs.
+	 */
+	const RETRY_HOOK = 'wc_push_notification_retry';
+
+	/**
+	 * Maximum number of retries before giving up (5 total attempts including
+	 * the initial send).
+	 */
+	const MAX_RETRIES = 4;
+
+	/**
+	 * Maximum retry delay in seconds (24 hours). If WPCOM requests a
+	 * Retry-After longer than this the notification is dropped — a push
+	 * notification arriving days late would be more confusing than helpful.
+	 */
+	const MAX_RETRY_DELAY = 86400;
+
+	/**
+	 * Backoff delays in seconds, indexed by attempt number (1-based).
+	 *
+	 * Attempt 1: 60s (1 minute)
+	 * Attempt 2: 300s (5 minutes)
+	 * Attempt 3: 900s (15 minutes)
+	 * Attempt 4: 3600s (60 minutes)
+	 *
+	 * @var array<int, int>
+	 */
+	const BACKOFF_SCHEDULE = array(
+		1 => 60,
+		2 => 300,
+		3 => 900,
+		4 => 3600,
+	);
+
+	/**
+	 * Registers the ActionScheduler hook for retry jobs.
+	 *
+	 * @return void
+	 *
+	 * @since 10.8.0
+	 */
+	public function register(): void {
+		add_action( self::RETRY_HOOK, array( $this, 'handle_retry' ), 10, 3 );
+	}
+
+	/**
+	 * Schedules a retry for a failed notification send.
+	 *
+	 * If the maximum number of retries has been reached, logs a permanent
+	 * failure instead of scheduling another attempt.
+	 *
+	 * @param Notification $notification    The notification that failed.
+	 * @param int|null     $retry_after     Optional Retry-After value from WPCOM (seconds).
+	 * @param int          $current_attempt The attempt number that just failed (0-based).
+	 * @return void
+	 *
+	 * @since 10.8.0
+	 */
+	public function schedule( Notification $notification, ?int $retry_after, int $current_attempt ): void {
+		$next_attempt = max( 0, $current_attempt ) + 1;
+
+		if ( $next_attempt > self::MAX_RETRIES || ! isset( self::BACKOFF_SCHEDULE[ $next_attempt ] ) ) {
+			wc_get_logger()->error(
+				sprintf(
+					'Push notification permanently failed after %d attempts (type=%s, resource_id=%d).',
+					$next_attempt,
+					$notification->get_type(),
+					$notification->get_resource_id()
+				),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+			return;
+		}
+
+		$delay = $retry_after ?? self::BACKOFF_SCHEDULE[ $next_attempt ];
+
+		if ( $delay > self::MAX_RETRY_DELAY ) {
+			wc_get_logger()->warning(
+				sprintf(
+					'Push notification dropped: retry delay %ds exceeds maximum %ds (type=%s, resource_id=%d).',
+					$delay,
+					self::MAX_RETRY_DELAY,
+					$notification->get_type(),
+					$notification->get_resource_id()
+				),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+			return;
+		}
+
+		as_schedule_single_action(
+			time() + $delay,
+			self::RETRY_HOOK,
+			array(
+				'type'        => $notification->get_type(),
+				'resource_id' => $notification->get_resource_id(),
+				'attempt'     => $next_attempt,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP,
+			true
+		);
+	}
+
+	/**
+	 * ActionScheduler callback for retry jobs.
+	 *
+	 * Reconstructs the notification from the stored type and resource ID,
+	 * then delegates to the processor with is_retry=true.
+	 *
+	 * @param string $type        The notification type.
+	 * @param int    $resource_id The resource ID.
+	 * @param int    $attempt     The current retry attempt number (1-based).
+	 * @return void
+	 *
+	 * @since 10.8.0
+	 */
+	public function handle_retry( string $type, int $resource_id, int $attempt ): void {
+		try {
+			$notification = Notification::from_array(
+				array(
+					'type'        => $type,
+					'resource_id' => $resource_id,
+				)
+			);
+		} catch ( Exception $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Retry failed: %s', $e->getMessage() ),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+			return;
+		}
+
+		try {
+			wc_get_container()->get( NotificationProcessor::class )->process(
+				$notification,
+				true,
+				$attempt
+			);
+		} catch ( Exception $e ) {
+			wc_get_logger()->error(
+				sprintf( 'Retry failed: %s', $e->getMessage() ),
+				array( 'source' => PushNotifications::FEATURE_NAME )
+			);
+			$this->schedule( $notification, null, $attempt );
+		}
+	}
+}
diff --git a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
index c9bcc3e0185..227dac2038d 100644
--- a/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
+++ b/plugins/woocommerce/src/Internal/PushNotifications/Services/PendingNotificationStore.php
@@ -139,7 +139,8 @@ class PendingNotificationStore {
 			time() + NotificationProcessor::SAFETY_NET_DELAY,
 			NotificationProcessor::SAFETY_NET_HOOK,
 			$args,
-			NotificationProcessor::ACTION_SCHEDULER_GROUP
+			NotificationProcessor::ACTION_SCHEDULER_GROUP,
+			true
 		);
 	}

diff --git a/plugins/woocommerce/tests/php/helpers/LoggerSpyTrait.php b/plugins/woocommerce/tests/php/helpers/LoggerSpyTrait.php
index 033493db34e..1aaf7a7201b 100644
--- a/plugins/woocommerce/tests/php/helpers/LoggerSpyTrait.php
+++ b/plugins/woocommerce/tests/php/helpers/LoggerSpyTrait.php
@@ -41,6 +41,16 @@ trait LoggerSpyTrait {
 		$this->captured_logs = array();
 		$this->spy_logger    = $this->getMockBuilder( \WC_Logger_Interface::class )->getMock();

+		$capture = function ( $level ) {
+			return function ( $message, $context = array() ) use ( $level ) {
+				$this->captured_logs[] = array(
+					'level'   => $level,
+					'message' => $message,
+					'context' => $context,
+				);
+			};
+		};
+
 		$this->spy_logger->method( 'log' )->willReturnCallback(
 			function ( $level, $message, $context = array() ) {
 				$this->captured_logs[] = array(
@@ -51,6 +61,10 @@ trait LoggerSpyTrait {
 			}
 		);

+		foreach ( array( 'emergency', 'alert', 'critical', 'error', 'warning', 'notice', 'info', 'debug' ) as $level ) {
+			$this->spy_logger->method( $level )->willReturnCallback( $capture( $level ) );
+		}
+
 		add_filter( 'woocommerce_logging_class', array( $this, 'get_spy_logger' ) );
 	}

diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
index c8871bb5957..1e8f03abe49 100644
--- a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationProcessorTest.php
@@ -14,6 +14,8 @@ use Automattic\WooCommerce\Internal\PushNotifications\Notifications\StockNotific
 use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
 use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
 use WC_Helper_Product;
 use WC_Unit_Test_Case;

@@ -22,6 +24,8 @@ use WC_Unit_Test_Case;
  */
 class NotificationProcessorTest extends WC_Unit_Test_Case {

+	use LoggerSpyTrait;
+
 	/**
 	 * The System Under Test.
 	 *
@@ -50,6 +54,13 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 	 */
 	private $preferences_service;

+	/**
+	 * Mock retry handler.
+	 *
+	 * @var NotificationRetryHandler
+	 */
+	private $retry_handler;
+
 	/**
 	 * A test order ID.
 	 *
@@ -66,10 +77,11 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->dispatcher          = $this->createMock( WpcomNotificationDispatcher::class );
 		$this->data_store          = $this->createMock( PushTokensDataStore::class );
 		$this->preferences_service = $this->createMock( NotificationPreferencesService::class );
+		$this->retry_handler       = $this->createMock( NotificationRetryHandler::class );
 		$this->order_id            = wc_create_order( array( 'status' => 'processing' ) )->get_id();

 		$this->sut = new NotificationProcessor();
-		$this->sut->init( $this->dispatcher, $this->data_store, $this->preferences_service );
+		$this->sut->init( $this->dispatcher, $this->data_store, $this->preferences_service, $this->retry_handler );

 		// By default every user has every notification type enabled, so existing
 		// tests behave as before. Per-user/per-type filtering is exercised in
@@ -233,7 +245,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $data_store, $this->preferences_service );
+		$sut->init( $this->dispatcher, $data_store, $this->preferences_service, $this->retry_handler );

 		$notification = new NewOrderNotification( $this->order_id );
 		$result       = $sut->process( $notification );
@@ -260,7 +272,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );

 		$notification = new NewOrderNotification( $this->order_id );
 		$result       = $sut->process( $notification );
@@ -331,7 +343,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 			);

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler );

 		$notification = new NewOrderNotification( $this->order_id );
 		$result       = $sut->process( $notification );
@@ -362,7 +374,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 			);

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );

 		// store_order is enabled — should dispatch.
 		$order_notification = new NewOrderNotification( $this->order_id );
@@ -442,7 +454,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 			);

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $data_store, $preferences_service, $this->retry_handler );

 		$this->assertTrue( $sut->process( new NewOrderNotification( $this->order_id ) ) );
 	}
@@ -490,7 +502,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );

 		$result = $sut->process( $notification );

@@ -544,6 +556,154 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
 	}

+	/**
+	 * @testdox Should schedule retry via handler on dispatch failure.
+	 */
+	public function test_process_schedules_retry_on_failure(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => false,
+				'retry_after' => 120,
+			)
+		);
+
+		$this->retry_handler->expects( $this->once() )
+			->method( 'schedule' )
+			->with(
+				$this->isInstanceOf( NewOrderNotification::class ),
+				$this->equalTo( 120 ),
+				$this->equalTo( 0 )
+			);
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$this->sut->process( $notification );
+	}
+
+	/**
+	 * @testdox Should pass attempt number through to retry handler on failure.
+	 */
+	public function test_process_passes_attempt_to_retry_handler(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => false,
+				'retry_after' => null,
+			)
+		);
+
+		$this->retry_handler->expects( $this->once() )
+			->method( 'schedule' )
+			->with(
+				$this->anything(),
+				$this->anything(),
+				$this->equalTo( 3 )
+			);
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$this->sut->process( $notification, true, 3 );
+	}
+
+	/**
+	 * @testdox Should cancel safety net after successful dispatch.
+	 */
+	public function test_process_cancels_safety_net_on_success(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$notification = new NewOrderNotification( $this->order_id );
+
+		as_schedule_single_action(
+			time() + NotificationProcessor::SAFETY_NET_DELAY,
+			NotificationProcessor::SAFETY_NET_HOOK,
+			array(
+				'type'        => $notification->get_type(),
+				'resource_id' => $this->order_id,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->sut->process( $notification );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationProcessor::SAFETY_NET_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertFalse( $scheduled, 'Safety net should be cancelled after successful send.' );
+	}
+
+	/**
+	 * @testdox Should cancel safety net after failed dispatch with retry scheduled.
+	 */
+	public function test_process_cancels_safety_net_on_failure(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => false,
+				'retry_after' => null,
+			)
+		);
+
+		$notification = new NewOrderNotification( $this->order_id );
+
+		as_schedule_single_action(
+			time() + NotificationProcessor::SAFETY_NET_DELAY,
+			NotificationProcessor::SAFETY_NET_HOOK,
+			array(
+				'type'        => $notification->get_type(),
+				'resource_id' => $this->order_id,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->sut->process( $notification );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationProcessor::SAFETY_NET_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertFalse( $scheduled, 'Safety net should be cancelled when retry is scheduled.' );
+	}
+
+	/**
+	 * @testdox Should not schedule retry on successful dispatch.
+	 */
+	public function test_process_does_not_retry_on_success(): void {
+		$this->dispatcher->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$this->retry_handler->expects( $this->never() )->method( 'schedule' );
+
+		$notification = new NewOrderNotification( $this->order_id );
+		$this->sut->process( $notification );
+	}
+
+	/**
+	 * @testdox Should catch and log exception when safety net receives an unknown type.
+	 */
+	public function test_handle_safety_net_logs_error_for_unknown_type(): void {
+		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );
+
+		$this->sut->handle_safety_net( 'unknown_type', 1 );
+
+		$this->assertLogged( 'error', 'Safety net failed:', array( 'source' => PushNotifications::FEATURE_NAME ) );
+	}
+
 	/**
 	 * @testdox Should skip dispatch when the order total is below the user's min_amount threshold.
 	 */
@@ -566,7 +726,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );

 		$notification = new NewOrderNotification( $order->get_id() );
 		$result       = $sut->process( $notification );
@@ -602,7 +762,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );

 		$notification = new NewReviewNotification( $comment_id );
 		$result       = $sut->process( $notification );
@@ -788,7 +948,7 @@ class NotificationProcessorTest extends WC_Unit_Test_Case {
 		$this->dispatcher->expects( $this->never() )->method( 'dispatch' );

 		$sut = new NotificationProcessor();
-		$sut->init( $this->dispatcher, $this->data_store, $preferences_service );
+		$sut->init( $this->dispatcher, $this->data_store, $preferences_service, $this->retry_handler );

 		$notification = new StockNotification( $product->get_id(), StockNotification::EVENT_LOW_STOCK );
 		$result       = $sut->process( $notification );
diff --git a/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
new file mode 100644
index 00000000000..3ba567854de
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/PushNotifications/Services/NotificationRetryHandlerTest.php
@@ -0,0 +1,251 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\PushNotifications\Services;
+
+use Automattic\WooCommerce\Internal\PushNotifications\DataStores\PushTokensDataStore;
+use Automattic\WooCommerce\Internal\PushNotifications\Dispatchers\WpcomNotificationDispatcher;
+use Automattic\WooCommerce\Internal\PushNotifications\Entities\PushToken;
+use Automattic\WooCommerce\Internal\PushNotifications\Notifications\NewOrderNotification;
+use Automattic\WooCommerce\Internal\PushNotifications\PushNotifications;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationPreferencesService;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationProcessor;
+use Automattic\WooCommerce\Internal\PushNotifications\Services\NotificationRetryHandler;
+use Automattic\WooCommerce\RestApi\UnitTests\LoggerSpyTrait;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the NotificationRetryHandler class.
+ */
+class NotificationRetryHandlerTest extends WC_Unit_Test_Case {
+
+	use LoggerSpyTrait;
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var NotificationRetryHandler
+	 */
+	private $sut;
+
+	/**
+	 * A test order ID.
+	 *
+	 * @var int
+	 */
+	private int $order_id;
+
+	/**
+	 * Set up test fixtures.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+
+		$this->sut      = new NotificationRetryHandler();
+		$this->order_id = wc_create_order( array( 'status' => 'processing' ) )->get_id();
+	}
+
+	/**
+	 * Tear down test fixtures.
+	 */
+	public function tearDown(): void {
+		as_unschedule_all_actions( NotificationRetryHandler::RETRY_HOOK );
+		parent::tearDown();
+	}
+
+	/**
+	 * @testdox Should schedule retry with default backoff delay for attempt $attempt.
+	 * @testWith [0, 1, 60]
+	 *           [1, 2, 300]
+	 *           [2, 3, 900]
+	 *           [3, 4, 3600]
+	 *
+	 * @param int $current_attempt The attempt that just failed.
+	 * @param int $expected_next   The expected next attempt number in the AS args.
+	 * @param int $expected_delay  The expected delay in seconds.
+	 */
+	public function test_schedule_uses_default_backoff( int $current_attempt, int $expected_next, int $expected_delay ): void {
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->schedule( $notification, null, $current_attempt );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+				'attempt'     => $expected_next,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertNotFalse( $scheduled, 'A retry action should be scheduled.' );
+		$this->assertEqualsWithDelta( time() + $expected_delay, $scheduled, 2, 'Retry should be scheduled with the correct backoff delay.' );
+	}
+
+	/**
+	 * @testdox Should use Retry-After value when provided instead of default backoff.
+	 */
+	public function test_schedule_respects_retry_after(): void {
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->schedule( $notification, 120, 0 );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+				'attempt'     => 1,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertNotFalse( $scheduled );
+		$this->assertEqualsWithDelta( time() + 120, $scheduled, 2, 'Retry should use the Retry-After delay.' );
+	}
+
+	/**
+	 * @testdox Should log permanent failure and not schedule when max retries exceeded.
+	 */
+	public function test_schedule_logs_permanent_failure_after_max_retries(): void {
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->schedule( $notification, null, NotificationRetryHandler::MAX_RETRIES );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+				'attempt'     => NotificationRetryHandler::MAX_RETRIES + 1,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertFalse( $scheduled, 'No retry should be scheduled after max retries.' );
+		$this->assertLogged( 'error', 'permanently failed after 5 attempts', array( 'source' => PushNotifications::FEATURE_NAME ) );
+	}
+
+	/**
+	 * @testdox Should clamp a negative current_attempt to zero and schedule attempt 1.
+	 */
+	public function test_schedule_clamps_negative_attempt(): void {
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->schedule( $notification, null, -3 );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+				'attempt'     => 1,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertNotFalse( $scheduled, 'A retry action should be scheduled for attempt 1.' );
+		$this->assertEqualsWithDelta( time() + 60, $scheduled, 2, 'Retry should use the attempt-1 backoff delay.' );
+	}
+
+	/**
+	 * @testdox Should drop notification and log warning when retry delay exceeds the 24-hour cap.
+	 */
+	public function test_schedule_drops_notification_when_retry_after_exceeds_max(): void {
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->schedule( $notification, NotificationRetryHandler::MAX_RETRY_DELAY + 1, 0 );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+				'attempt'     => 1,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertFalse( $scheduled, 'No retry should be scheduled when delay exceeds maximum.' );
+		$this->assertLogged( 'warning', 'retry delay', array( 'source' => PushNotifications::FEATURE_NAME ) );
+	}
+
+	/**
+	 * @testdox Should schedule retry when retry delay equals the 24-hour cap exactly.
+	 */
+	public function test_schedule_allows_retry_at_max_delay(): void {
+		$notification = new NewOrderNotification( $this->order_id );
+
+		$this->sut->schedule( $notification, NotificationRetryHandler::MAX_RETRY_DELAY, 0 );
+
+		$scheduled = as_next_scheduled_action(
+			NotificationRetryHandler::RETRY_HOOK,
+			array(
+				'type'        => 'store_order',
+				'resource_id' => $this->order_id,
+				'attempt'     => 1,
+			),
+			NotificationProcessor::ACTION_SCHEDULER_GROUP
+		);
+
+		$this->assertNotFalse( $scheduled, 'A retry should be scheduled when delay equals the maximum.' );
+	}
+
+	/**
+	 * @testdox Should delegate to NotificationProcessor with is_retry and attempt on retry callback.
+	 */
+	public function test_handle_retry_delegates_to_processor(): void {
+		$dispatcher          = $this->createMock( WpcomNotificationDispatcher::class );
+		$data_store          = $this->createMock( PushTokensDataStore::class );
+		$preferences_service = $this->createMock( NotificationPreferencesService::class );
+		$retry_handler       = $this->createMock( NotificationRetryHandler::class );
+
+		$preferences_service->method( 'get_preferences' )->willReturn(
+			array( 'store_order' => array( 'enabled' => true ) )
+		);
+
+		$dispatcher->expects( $this->once() )->method( 'dispatch' )->willReturn(
+			array(
+				'success'     => true,
+				'retry_after' => null,
+			)
+		);
+
+		$data_store->method( 'get_tokens_for_roles' )->willReturn(
+			array(
+				new PushToken(
+					array(
+						'user_id'       => 1,
+						'token'         => 'test-token',
+						'origin'        => PushToken::ORIGIN_WOOCOMMERCE_IOS,
+						'platform'      => PushToken::PLATFORM_APPLE,
+						'device_locale' => 'en_US',
+						'device_uuid'   => 'test-uuid',
+					)
+				),
+			)
+		);
+
+		$processor = new NotificationProcessor();
+		$processor->init( $dispatcher, $data_store, $preferences_service, $retry_handler );
+		wc_get_container()->replace( NotificationProcessor::class, $processor );
+
+		$this->sut->handle_retry( 'store_order', $this->order_id, 2 );
+
+		$order = wc_get_order( $this->order_id );
+		$this->assertNotEmpty( $order->get_meta( NotificationProcessor::SENT_META_KEY ) );
+
+		$this->reset_container_replacements();
+	}
+
+	/**
+	 * @testdox Should catch and log exception when retry receives an unknown type.
+	 */
+	public function test_handle_retry_logs_error_for_unknown_type(): void {
+		$this->sut->handle_retry( 'unknown_type', 1, 1 );
+
+		$this->assertLogged( 'error', 'Retry failed:', array( 'source' => PushNotifications::FEATURE_NAME ) );
+	}
+}