PushX.Retry (PushX v0.14.0)

Copy Markdown View Source

Retry logic for push notification delivery following Apple and Google best practices.

Retry Strategy

Based on official Apple APNS and Google FCM documentation:

  • Connection errors: Retry with exponential backoff (10s, 20s, 40s)
  • Server errors (5xx): Retry with exponential backoff
  • Rate limited (429): Respect retry-after header, or default to 60 seconds
  • Permanent failures: Do not retry (bad token, payload too large, etc.)

Backoff runs in the calling process via Process.sleep/1 — a single send can block its caller for the sum of all retry delays (tens of seconds with default config). Inside push_batch/4 this competes with the per-task :timeout; see the batch docs for sizing guidance.

Configuration

config :pushx,
  retry_enabled: true,
  retry_max_attempts: 3,
  retry_base_delay_ms: 10_000,  # 10 seconds (Google recommends minimum 10s)
  retry_max_delay_ms: 60_000    # 60 seconds max

References

Summary

Functions

Calculates the delay before the next retry attempt.

Runs fun under the retry policy selected by the caller's :retry option.

Returns true if the error is retryable.

Executes a function with retry logic.

Functions

calculate_delay(response, attempt, base_delay, max_delay)

@spec calculate_delay(PushX.Response.t(), pos_integer(), pos_integer(), pos_integer()) ::
  pos_integer()

Calculates the delay before the next retry attempt.

  • For rate limiting: Uses retry_after value or 60 seconds default
  • For connection errors: Faster retry (1s base) since these are transient
  • For server errors: Standard exponential backoff from config

Exponential Backoff Formula

delay = min(base_delay * 2^(attempt-1) + jitter, max_delay)

maybe_with_retry(provider, send_opts, fun, retry_opts \\ [])

@spec maybe_with_retry(
  :apns | :fcm,
  keyword(),
  (-> {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}),
  keyword()
) :: {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Runs fun under the retry policy selected by the caller's :retry option.

Every send function accepts retry: :blocking | :none (default :blocking):

  • :blockingwith_retry/2: retryable failures are retried in the calling process with backoff (subject to the :retry_enabled config).
  • :none — a single attempt; retryable failures are returned as-is with retry_after set when the provider supplied it, so the caller can requeue on its own schedule. Useful for large batches, where a blocking backoff would otherwise hold a concurrency slot for up to a minute. A :connection_error still triggers the (coalesced) automatic pool reconnect, exactly as the first retry of the blocking path would — only the retry itself is skipped.

true / false are accepted as aliases for :blocking / :none (older docs described the option as a boolean). Any other value returns {:error, %Response{status: :invalid_request}} for provider without calling fun.

retryable?(response)

@spec retryable?(PushX.Response.t()) :: boolean()

Returns true if the error is retryable.

Retryable errors:

  • :connection_error - Network/connection failure
  • :rate_limited - Too many requests (with backoff)
  • :server_error - Provider server error (5xx)

Non-retryable (permanent) errors:

  • :invalid_token - Device token is invalid
  • :expired_token - Device token has expired
  • :unregistered - Device is no longer registered
  • :payload_too_large - Payload exceeds size limit
  • :unknown_error - Unrecognized error (could be client-side issue)

with_retry(fun, opts \\ [])

@spec with_retry(
  (-> {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}),
  keyword()
) :: {:ok, PushX.Response.t()} | {:error, PushX.Response.t()}

Executes a function with retry logic.

The function should return {:ok, response} or {:error, response}. Retries are only attempted for retryable errors.

Options

  • :max_attempts - Maximum number of attempts (default: 3)
  • :base_delay_ms - Base delay in milliseconds (default: 10_000)
  • :max_delay_ms - Maximum delay in milliseconds (default: 60_000)

Examples

PushX.Retry.with_retry(fn -> PushX.APNS.send_once(token, payload, opts) end)