• File: NotificationRetryHandler.php
  • Full Path: /home/slvgwrnz/public_html/wp-content/plugins/woocommerce/src/Internal/PushNotifications/Services/NotificationRetryHandler.php
  • Date Modified: 10/26/2026 10:38 AM
  • File size: 7.18 KB
  • MIME-type: text/x-php
  • Charset: utf-8
<?php

declare( strict_types = 1 );

namespace Automattic\WooCommerce\Internal\PushNotifications\Services;

defined( 'ABSPATH' ) || exit;

use Automattic\WooCommerce\Internal\PushNotifications\Notifications\Notification;
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,
	);

	/**
	 * The step logger.
	 *
	 * @var NotificationStepLogger
	 */
	private NotificationStepLogger $step_logger;

	/**
	 * Initialize injected dependencies.
	 *
	 * @internal
	 *
	 * @param NotificationStepLogger $step_logger The step logger.
	 *
	 * @since 11.3.0
	 */
	final public function init( NotificationStepLogger $step_logger ): void {
		$this->step_logger = $step_logger;
	}

	/**
	 * 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, 4 );
	}

	/**
	 * 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.
	 *
	 * Every path that ends without a scheduled retry calls
	 * {@see Notification::reset_processing_meta()}, including a scheduling call
	 * that returns 0. Action Scheduler returns 0 when it is not initialised or
	 * when the store rejects the action, and nothing else would then clear the
	 * claimed marker that {@see NotificationProcessor::process()} checks.
	 *
	 * @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 ] ) ) {
			$this->step_logger->log_failure(
				$notification,
				'retry',
				'exhausted',
				'error',
				sprintf(
					'Push notification permanently failed after %d attempts (type=%s, resource_id=%d).',
					$next_attempt,
					$notification->get_type(),
					$notification->get_resource_id()
				),
				array( 'attempt' => $current_attempt )
			);
			$notification->reset_processing_meta();
			return;
		}

		$delay = $retry_after ?? self::BACKOFF_SCHEDULE[ $next_attempt ];

		if ( $delay > self::MAX_RETRY_DELAY ) {
			$this->step_logger->log_failure(
				$notification,
				'retry',
				'delay_too_long',
				'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(
					'attempt' => $current_attempt,
					'delay'   => $delay,
				)
			);
			$notification->reset_processing_meta();
			return;
		}

		// Action Scheduler dispatches array_values( $args ), so these keys are
		// decorative and the order alone decides which handle_retry() parameter
		// each value lands in.
		$args = array(
			'type'        => $notification->get_type(),
			'resource_id' => $notification->get_resource_id(),
			'attempt'     => $next_attempt,
		);

		$identity_data = $notification->get_identity_data();

		// Appended only when there is any, so orders and reviews keep the exact
		// argument list they had before this field existed and `$unique` still
		// matches a retry scheduled before it. Stock retries do change, so
		// during the deploy both formats can be pending for one product.
		if ( ! empty( $identity_data ) ) {
			$args['extra'] = $identity_data;
		}

		$action_id = as_schedule_single_action(
			time() + $delay,
			self::RETRY_HOOK,
			$args,
			NotificationProcessor::ACTION_SCHEDULER_GROUP,
			true
		);

		if ( ! $action_id ) {
			$this->step_logger->log_failure(
				$notification,
				'retry',
				'schedule_failed',
				'error',
				sprintf(
					'Push notification retry could not be scheduled (type=%s, resource_id=%d, attempt=%d).',
					$notification->get_type(),
					$notification->get_resource_id(),
					$next_attempt
				),
				array( 'attempt' => $current_attempt )
			);
			$notification->reset_processing_meta();
			return;
		}

		$this->step_logger->log_notification_step(
			$notification,
			'retry',
			'scheduled',
			array(
				'attempt'      => $current_attempt,
				'next_attempt' => $next_attempt,
				'delay'        => $delay,
			)
		);
	}

	/**
	 * ActionScheduler callback for retry jobs.
	 *
	 * Reconstructs the notification from the stored identity, then delegates to
	 * the processor with is_retry=true.
	 *
	 * `$extra` defaults to empty so retries scheduled before it was added still
	 * run, rebuilding a stock notification as low_stock as they always did.
	 *
	 * @param string $type        The notification type.
	 * @param int    $resource_id The resource ID.
	 * @param int    $attempt     The current retry attempt number (1-based).
	 * @param array  $extra       Identity fields from {@see Notification::get_identity_data()}.
	 * @return void
	 *
	 * @since 10.8.0
	 */
	public function handle_retry( string $type, int $resource_id, int $attempt, array $extra = array() ): void {
		try {
			// `+` rather than array_merge for the reason given in
			// NotificationProcessor::handle_safety_net().
			$notification = Notification::from_array(
				array(
					'type'        => $type,
					'resource_id' => $resource_id,
				) + $extra
			);
		} catch ( Exception $e ) {
			$this->step_logger->log_unattributed_failure(
				'retry',
				'invalid_notification',
				'error',
				sprintf( 'Retry failed: %s', $e->getMessage() ),
				array(
					'type'        => $type,
					'resource_id' => $resource_id,
					'attempt'     => $attempt,
				)
			);
			return;
		}

		try {
			wc_get_container()->get( NotificationProcessor::class )->process(
				$notification,
				true,
				$attempt
			);
		} catch ( Exception $e ) {
			$this->step_logger->log_failure(
				$notification,
				'retry',
				'exception',
				'error',
				sprintf( 'Retry failed: %s', $e->getMessage() ),
				array( 'attempt' => $attempt )
			);
			$this->schedule( $notification, null, $attempt );
		}
	}
}