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