= 1 (validated at construction). */ private int $maxAttempts; /** * Backoff schedule in seconds, indexed by zero-based * just-completed-attempt slot. Element at index `i` is the * number of seconds to wait after attempt `i + 1` failed * before launching attempt `i + 2`. * * Length is always >= max(0, maxAttempts - 1) so every * non-terminal attempt has a backoff value. * * @var list */ private array $backoffSecondsByAttempt; /** * Construct a retry policy with explicit values. * * Validation summary: * * - `maxAttempts` MUST be >= 1; an unreachable retry policy * is a programming error. * - Every entry in `backoffSecondsByAttempt` MUST be a * non-negative integer; negative waits would invert the * schedule. * - The array MUST have length >= max(0, maxAttempts - 1) so * every non-terminal attempt has a backoff value. The last * attempt has no further retry, so its slot is allowed to * be absent. * * @param int $maxAttempts Maximum attempt count. * @param list $backoffSecondsByAttempt Backoff schedule. * * @throws InvalidArgumentException When `$maxAttempts` is less * than 1, when any backoff * entry is negative, or when * the schedule is shorter than * the non-terminal attempt * count. */ public function __construct( int $maxAttempts, array $backoffSecondsByAttempt ) { if ( $maxAttempts < 1 ) { $message = sprintf( 'RetryPolicy maxAttempts must be >= 1; got %d.', $maxAttempts ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized = array(); $index = 0; foreach ( $backoffSecondsByAttempt as $seconds ) { if ( ! is_int( $seconds ) || $seconds < 0 ) { $message = sprintf( 'RetryPolicy backoff entries must be non-negative integers; got "%s" at index %d.', is_scalar( $seconds ) ? (string) $seconds : gettype( $seconds ), $index ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $normalized[] = $seconds; ++$index; } $requiredLength = $maxAttempts - 1; if ( count( $normalized ) < $requiredLength ) { $message = sprintf( 'RetryPolicy backoff schedule must cover %d non-terminal attempt(s); got %d entr(ies).', $requiredLength, count( $normalized ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $this->maxAttempts = $maxAttempts; $this->backoffSecondsByAttempt = $normalized; } /** * Default retry policy from §12.2 / §12.4 for `sync_product`: * `max_attempts = 5`, backoffs `[300, 900, 3600, 21600]`. */ public static function default(): self { return new self( 5, array( 300, 900, 3600, 21600 ) ); } /** * Canonical retry policy for the supplied job-type token, per * §12.4. * * Mapping: * * - `sync_product` → max 5, backoffs [300, 900, 3600, 21600] * - `refresh_provider_health` → max 3, backoffs [300, 900] * - `rebuild_render_cache` → max 2, backoffs [600] * - `link_health_batch` → max 3, backoffs [300, 900] * * @param string $jobType One of the {@see self::JOB_*} tokens. * * @throws InvalidArgumentException When `$jobType` is not a * recognized §12.4 token. */ public static function forJobType( string $jobType ): self { switch ( $jobType ) { case self::JOB_SYNC_PRODUCT: return self::default(); case self::JOB_REFRESH_PROVIDER_HEALTH: return new self( 3, array( 300, 900 ) ); case self::JOB_REBUILD_RENDER_CACHE: return new self( 2, array( 600 ) ); case self::JOB_LINK_HEALTH_BATCH: return new self( 3, array( 300, 900 ) ); default: $message = sprintf( 'RetryPolicy::forJobType received unknown job type "%s"; expected one of [%s].', $jobType, implode( ', ', array( self::JOB_SYNC_PRODUCT, self::JOB_REFRESH_PROVIDER_HEALTH, self::JOB_REBUILD_RENDER_CACHE, self::JOB_LINK_HEALTH_BATCH, ) ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } } /** * Maximum number of attempts before the job is marked failed. */ public function maxAttempts(): int { return $this->maxAttempts; } /** * Backoff schedule in seconds, indexed by zero-based * just-completed-attempt slot. * * Exposed for diagnostics surfaces and for callers that need * to render the full schedule (admin diagnostics view, audit * log). Most retry-loop call sites should prefer * {@see self::backoffSeconds()} or * {@see self::withRetryAfter()}. * * @return list */ public function backoffSchedule(): array { return $this->backoffSecondsByAttempt; } /** * Number of seconds to wait before the next attempt, given * that `$attemptNumber` attempts have just completed. * * `$attemptNumber` is 1-based and represents the count of * attempts already made (so a value of 1 means the first * attempt has just failed and we are deciding when to launch * the second). * * @param int $attemptNumber 1-based count of completed attempts. * * @throws InvalidArgumentException When `$attemptNumber` is * less than 1, or when no * next attempt is allowed * (i.e. `$attemptNumber >= * $this->maxAttempts`). */ public function backoffSeconds( int $attemptNumber ): int { if ( $attemptNumber < 1 ) { $message = sprintf( 'RetryPolicy::backoffSeconds attemptNumber must be >= 1; got %d.', $attemptNumber ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } if ( ! $this->hasNextAttempt( $attemptNumber ) ) { $message = sprintf( 'RetryPolicy::backoffSeconds called after attempt %d but maxAttempts is %d; no next attempt allowed.', $attemptNumber, $this->maxAttempts ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML. throw new InvalidArgumentException( $message ); } $index = $attemptNumber - 1; // Schedule length is validated at construction to cover // every non-terminal attempt, so this lookup is safe. return $this->backoffSecondsByAttempt[ $index ]; } /** * Whether a further attempt is allowed after `$attemptNumber` * attempts have already completed. * * @param int $attemptNumber 1-based count of completed attempts. */ public function hasNextAttempt( int $attemptNumber ): bool { return $attemptNumber >= 1 && $attemptNumber < $this->maxAttempts; } /** * Resolve the wait time before the next attempt, honoring a * provider-supplied `Retry-After` directive per §12.3. * * §12.3 states that the provider's `Retry-After` value takes * priority over the policy's static backoff. When * `$retryAfterSeconds` is positive, this method returns it * verbatim; when `$retryAfterSeconds` is zero or negative * (interpreted as "no directive supplied"), the method falls * back to {@see self::backoffSeconds()} for `$attemptNumber`. * * Note: this is a deliberate "provider wins" interpretation of * §12.3. A provider that returns a very small `Retry-After` * value will therefore shrink the wait below the exponential * backoff. Call sites that need a floor (for example, to avoid * hammering a flaky provider) should clamp the result * themselves. * * @param int $retryAfterSeconds Provider-supplied directive, or * <= 0 when no directive was * provided. * @param int $attemptNumber 1-based count of completed * attempts. * * @throws InvalidArgumentException When `$attemptNumber` is * invalid or no next attempt * is allowed; see * {@see self::backoffSeconds()}. */ public function withRetryAfter( int $retryAfterSeconds, int $attemptNumber ): int { $backoff = $this->backoffSeconds( $attemptNumber ); if ( $retryAfterSeconds > 0 ) { return $retryAfterSeconds; } return $backoff; } }