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 ) );
+ }
+}