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-afterheader, 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 maxReferences
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
@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)
@spec maybe_with_retry( :apns | :fcm | :webpush, 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):
:blocking—with_retry/2: retryable failures are retried in the calling process with backoff (subject to the:retry_enabledconfig).:none— a single attempt; retryable failures are returned as-is withretry_afterset 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_errorstill 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.
@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)
@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)