PushX Logo

Modern push notifications for Elixir
Supports Apple APNS and Google FCM with HTTP/2, JWT authentication, and a clean unified API.

CIHex.pmHex DocsLicense


Table of Contents


Features

Requirements

Tested on Elixir 1.18/1.19 with OTP 26, 27, and 28.


Quick Start

1. Install

Add pushx to your dependencies in mix.exs:

def deps do
  [
    {:pushx, "~> 0.9"}
  ]
end

2. Configure

Add credentials to config/runtime.exs:

config :pushx,
  # APNS (iOS)
  apns_key_id: System.fetch_env!("APNS_KEY_ID"),
  apns_team_id: System.fetch_env!("APNS_TEAM_ID"),
  apns_private_key: System.fetch_env!("APNS_PRIVATE_KEY"),
  apns_mode: :prod,

  # FCM (Android)
  fcm_project_id: System.fetch_env!("FCM_PROJECT_ID"),
  fcm_credentials: System.fetch_env!("FCM_CREDENTIALS") |> JSON.decode!()

PushX starts its own HTTP/2 connection pools and OAuth processes automatically — no additional supervision tree setup needed.

Need help getting credentials? See Getting Your Credentials below.

3. Send a notification

# Send to iOS
PushX.push(:apns, device_token, "Hello!", topic: "com.example.app")

# Send to Android
PushX.push(:fcm, device_token, "Hello!")

# With title and body
PushX.push(:apns, token, %{title: "Welcome", body: "Thanks for signing up!"},
  topic: "com.example.app")

That's it. PushX handles HTTP/2 connections, JWT/OAuth authentication, and automatic retry.


Usage Guide

Message Builder

Build rich notifications with the fluent API:

message = PushX.message()
  |> PushX.Message.title("Order Update")
  |> PushX.Message.body("Your order #1234 has shipped")
  |> PushX.Message.badge(1)
  |> PushX.Message.sound("default")
  |> PushX.Message.data(%{order_id: "1234", status: "shipped"})

PushX.push(:apns, token, message, topic: "com.example.app")
Function Description
title(msg, string) Set notification title
body(msg, string) Set notification body
badge(msg, integer) Set app badge count (iOS)
sound(msg, string) Set notification sound
data(msg, map) Set custom data payload
put_data(msg, key, value) Add single data key-value
category(msg, string) Set notification category (iOS)
thread_id(msg, string) Set thread ID for grouping (iOS)
image(msg, url) Set image URL (FCM)
priority(msg, :high | :normal) Set delivery priority
ttl(msg, seconds) Set time-to-live
collapse_key(msg, string) Set collapse key (FCM)

You can also pass a plain string, a %{title: ..., body: ...} map, or a raw APNS/FCM payload map directly to push/4.

Response Handling

Every push returns {:ok, Response} or {:error, Response}:

case PushX.push(:apns, token, message, topic: "com.example.app") do
  {:ok, %PushX.Response{status: :sent, id: apns_id}} ->
    Logger.info("Notification sent with ID: #{apns_id}")

  {:error, %PushX.Response{} = response} ->
    if PushX.Response.should_remove_token?(response) do
      # Token is invalid, expired, or unregistered — delete it
      MyApp.Tokens.delete(token)
    else
      Logger.error("Push failed: #{response.status} - #{response.reason}")
    end
end

Response struct:

%PushX.Response{
  provider: :apns | :fcm,
  status: :sent | :invalid_token | :expired_token | ...,
  id: "message-id" | nil,
  reason: "error reason" | nil,
  raw: raw_response_body,
  retry_after: seconds | nil
}
Status Description Action
:sent Successfully delivered None
:invalid_token Token is malformed or invalid Remove token
:expired_token Token has expired Remove token
:unregistered Device unregistered Remove token
:payload_too_large Payload exceeds limit (APNS: 4KB, FCM: 4000 bytes) Reduce payload size
:rate_limited Too many requests Automatic retry with backoff
:server_error Provider server error Automatic retry with backoff
:connection_error Network failure Automatic retry with backoff
:invalid_request Missing required option (e.g., no :topic for APNS) Fix request parameters
:auth_error JWT/credential failure (e.g., invalid private key) Check credentials
:unknown_error Unrecognized error Check reason field

Helper functions:

PushX.Response.success?(response)              # true if status == :sent
PushX.Response.should_remove_token?(response)  # true for invalid/expired/unregistered
PushX.Response.retryable?(response)            # true for connection_error/rate_limited/server_error

Batch Sending

Send to multiple devices concurrently:

results = PushX.push_batch(:apns, tokens, message, topic: "com.example.app")

# Process results
Enum.each(results, fn
  {token, {:ok, response}} -> Logger.info("Sent to #{token}")
  {token, {:error, response}} ->
    if PushX.Response.should_remove_token?(response) do
      MyApp.Tokens.delete(token)
    end
end)
Option Type Default Description
:concurrencyinteger()50 Max concurrent requests
:timeoutinteger()30_000 Timeout per request (ms)
:validate_tokensboolean()false Filter invalid tokens before sending

For aggregate counts, use the bang variant:

%{success: 95, failure: 5, total: 100} =
  PushX.push_batch!(:fcm, tokens, "Hello!")

Silent/Background Notification

payload = PushX.APNS.silent_notification(%{action: "sync", resource: "messages"})

PushX.APNS.send(token, payload,
  topic: "com.example.app",
  push_type: "background",
  priority: 5
)

Data-Only Message (FCM)

Send data without a visible notification. All values are automatically converted to strings (FCM requirement):

# Via unified API (supports named instances)
PushX.push_data(:fcm, token, %{action: "sync", id: 123})
PushX.push_data(:my_fcm, token, %{action: "sync", id: 123})

# Via provider module directly
PushX.FCM.send_data(token, %{action: "sync", id: 123})

Notification with Custom Data (FCM)

Send a visible notification with a custom data payload attached:

# Structured payload — notification + data
PushX.push(:fcm, token, %{
  "notification" => %{"title" => "Alert", "body" => "Something happened"},
  "data" => %{"event_id" => "1", "action" => "open_event"}
})

# Works with named instances too
PushX.push(:my_fcm, token, %{
  "notification" => %{"title" => "Alert", "body" => "Something happened"},
  "data" => %{"event_id" => "1"}
})

Web Push

FCM Web Push (Chrome, Firefox, Edge)

FCM uses the same API for web and mobile. Web tokens come from Firebase Messaging SDK.

# Same API as mobile
PushX.push(:fcm, web_token, %{title: "Hello", body: "From web!"})

# With click action
PushX.FCM.send_web(web_token, "New Message", "Check it out",
  "https://example.com/messages")

# With icon and badge
PushX.FCM.send_web(web_token, "Alert", "Important update",
  "https://example.com",
  icon: "https://example.com/icon.png",
  badge: "https://example.com/badge.png"
)

# Build payload manually for more control
payload = PushX.FCM.web_notification("Title", "Body", "https://example.com",
  icon: "https://example.com/icon.png",
  require_interaction: true
)
PushX.FCM.send(web_token, payload)

Safari Web Push (macOS)

Safari uses APNS with a web. topic prefix. Tokens are 64 hex characters (same as iOS).

# Topic format: web.{website-push-id}
payload = PushX.APNS.web_notification("New Article", "Check it out",
  "https://example.com/article/123")
PushX.APNS.send(safari_token, payload, topic: "web.com.example.website")

# With custom action button and data
payload = PushX.APNS.web_notification_with_data("Sale!", "50% off",
  "https://shop.com",
  %{"promo_id" => "summer50"},
  action: "Shop Now"
)

Direct Provider Access

The unified PushX.push/4 normalizes payloads across providers. When you need provider-specific features, use the modules directly:

# APNS — full control over headers and payload
PushX.APNS.send(token, payload, topic: "com.app", push_type: "voip")
PushX.APNS.send_once(token, payload, opts)          # no automatic retry
PushX.APNS.send_batch(tokens, payload, opts)
PushX.APNS.notification("Title", "Body", badge)
PushX.APNS.notification_with_data("Title", "Body", %{key: "value"})
PushX.APNS.silent_notification(%{action: "sync"})
PushX.APNS.web_notification("Title", "Body", "https://url")
PushX.APNS.web_notification_with_data("Title", "Body", "https://url", %{key: "val"})

# FCM — full control over android/webpush/data options
PushX.FCM.send(token, payload, data: %{key: "value"})
PushX.FCM.send_once(token, payload, opts)            # no automatic retry
PushX.FCM.send_batch(tokens, payload, opts)
PushX.FCM.send_data(token, %{key: "value"})          # data-only, no visible notification
PushX.FCM.send_web(token, "Title", "Body", "https://link", opts)
PushX.FCM.notification("Title", "Body", image: "https://img")
PushX.FCM.web_notification("Title", "Body", "https://link", opts)
PushX.FCM.web_notification_with_data("Title", "Body", "https://link", %{key: "val"})

Token Validation

Validate tokens before sending to catch format errors early:

PushX.valid_token?(:apns, token)    # true/false
PushX.validate_token(:apns, token)  # :ok | {:error, :empty | :invalid_length | :invalid_format}

# In batch — filter out bad tokens automatically
PushX.push_batch(:apns, tokens, message, topic: "...", validate_tokens: true)

APNS tokens: exactly 64 hexadecimal characters (32 bytes) FCM tokens: 20-500 characters, alphanumeric with hyphens/underscores/colons


Configuration

All configuration goes under config :pushx. Here's a complete example with all options:

config :pushx,
  # === Credentials ===
  apns_key_id: "ABC123DEFG",
  apns_team_id: "TEAM123456",
  apns_private_key: {:file, "priv/keys/AuthKey.p8"},
  apns_mode: :prod,
  fcm_project_id: "my-project-id",
  fcm_credentials: {:file, "priv/keys/firebase.json"},

  # === HTTP/2 Pool (tune for your traffic level) ===
  finch_pool_size: 2,          # connections per pool (default: 25)
  finch_pool_count: 1,         # number of pools (default: 2)

  # === Timeouts ===
  receive_timeout: 15_000,     # wait for response data (default: 15s)
  pool_timeout: 5_000,         # wait for pool connection (default: 5s)
  connect_timeout: 10_000,     # TCP connect timeout (default: 10s)

  # === Retry ===
  retry_enabled: true,         # default: true
  retry_max_attempts: 3,       # default: 3
  retry_base_delay_ms: 10_000, # default: 10s (Google's recommended minimum)
  retry_max_delay_ms: 60_000,  # default: 60s

  # === Rate Limiting (optional) ===
  rate_limit_enabled: false,   # default: false
  rate_limit_apns: 5000,       # requests per window
  rate_limit_fcm: 5000,        # requests per window
  rate_limit_window_ms: 1000   # 1 second window

Credentials

APNS

Option Type Description
:apns_key_idString.t() 10-character Key ID from Apple
:apns_team_idString.t() 10-character Team ID from Apple
:apns_private_keyString.t() | {:file, path} | {:system, env_var} PEM string, file path, or env var name
:apns_mode:prod | :sandbox APNS environment (default: :prod)

FCM

Option Type Description
:fcm_project_idString.t() Firebase project ID
:fcm_credentialsmap() | {:file, path} | {:json, string} | {:system, env_var} Service account as map, file, JSON string, or env var

Pool Sizing

Each HTTP/2 connection supports ~100 concurrent streams. Pool capacity = pool_size x pool_count x 100.

Traffic Level pool_sizepool_count Concurrent Capacity
Low (<100/min) 2 1 ~200
Medium (<1000/min) 10 1 ~1,000
High (>1000/min) 25 2 ~5,000
Very high 50 4 ~20,000

Important: For low-traffic apps, reduce pool size from the defaults. Large pools create many idle HTTP/2 connections that can go stale on cloud infrastructure (Fly.io, AWS, GCP), leading to too_many_concurrent_requests errors. Start small and increase only if needed.

Retry Behavior

PushX automatically retries transient failures with exponential backoff:

To skip retry for a specific call, use send_once:

PushX.APNS.send_once(token, payload, topic: "com.example.app")
PushX.FCM.send_once(token, payload)

Timeouts

Option Default Description
:receive_timeout 15s How long to wait for response data from APNS/FCM
:pool_timeout 5s How long to wait for a connection from the pool
:connect_timeout 10s TCP connection establishment timeout

Tip: Increase timeouts if connecting from distant regions (e.g., EU to Apple's US servers).

You can also override timeouts per-request:

PushX.APNS.send(token, payload,
  topic: "com.example.app",
  receive_timeout: 30_000,
  pool_timeout: 10_000
)

Rate Limiting

Optional client-side rate limiting prevents exceeding provider limits. Disabled by default.

# Check manually before sending
case PushX.check_rate_limit(:apns) do
  :ok -> # proceed
  {:error, :rate_limited} -> # back off
end

When enabled, rate limits are checked automatically before each send call.


Dynamic Instances (Runtime Config)

For applications that manage push credentials from a database or admin panel, PushX supports starting, stopping, and reconfiguring provider instances at runtime — no application restart needed.

Each instance gets its own HTTP/2 connection pool, JWT cache (APNS), and OAuth process (FCM). Multiple instances can run concurrently (e.g., APNS sandbox + APNS prod + FCM).

Starting Instances

# APNS sandbox (for development/testing)
PushX.Instance.start(:apns_sandbox, :apns,
  key_id: "ABC123",
  team_id: "TEAM456",
  private_key: apns_key_pem,
  mode: :sandbox
)

# APNS production
PushX.Instance.start(:apns_prod, :apns,
  key_id: "ABC123",
  team_id: "TEAM456",
  private_key: apns_key_pem,
  mode: :prod
)

# FCM
PushX.Instance.start(:my_fcm, :fcm,
  project_id: "my-firebase-project",
  credentials: service_account_map
)

The names :apns and :fcm are reserved for the static config path and cannot be used as instance names.

Sending via Instances

Pass the instance name instead of :apns or :fcm:

PushX.push(:apns_prod, device_token, "Hello!", topic: "com.example.app")
PushX.push(:my_fcm, device_token, %{title: "Alert", body: "Something happened"})

# Data-only (silent) message via instance
PushX.push_data(:my_fcm, device_token, %{action: "sync", id: 123})

Batch sending works the same way:

PushX.push_batch(:apns_prod, tokens, message, topic: "com.example.app")

Enable / Disable

Disable an instance to reject new pushes while keeping the connection pool warm:

PushX.Instance.disable(:apns_sandbox)
# => PushX.push(:apns_sandbox, ...) returns {:error, %Response{status: :provider_disabled}}

PushX.Instance.enable(:apns_sandbox)
# => pushes work again

Reconfigure

Update config without restarting the application. The old pool is stopped and a new one starts with the merged config:

# Switch environment
PushX.Instance.reconfigure(:apns_sandbox, mode: :prod)

# Rotate credentials
PushX.Instance.reconfigure(:apns_prod,
  key_id: "NEW_KEY_ID",
  private_key: new_pem_string
)

List and Status

PushX.Instance.list()
#=> [
#=>   %{name: :apns_prod, provider: :apns, enabled: true},
#=>   %{name: :apns_sandbox, provider: :apns, enabled: false},
#=>   %{name: :my_fcm, provider: :fcm, enabled: true}
#=> ]

PushX.Instance.status(:apns_prod)
#=> {:ok, %{provider: :apns, enabled: true}}

Stop

PushX.Instance.stop(:apns_sandbox)

Cleans up the Finch pool, JWT cache, Goth process (FCM), and ETS entry.

Example: Database-Backed Admin Panel

defmodule MyApp.PushAdmin do
  @doc "Boot all saved instances on application start."
  def boot do
    MyApp.Repo.all(MyApp.PushConfig)
    |> Enum.each(fn config ->
      PushX.Instance.start(
        String.to_atom(config.name),
        String.to_atom(config.provider),
        build_opts(config)
      )
    end)
  end

  @doc "Called from admin panel when config is updated."
  def update(config) do
    name = String.to_atom(config.name)
    PushX.Instance.reconfigure(name, build_opts(config))
  end

  @doc "Called from admin panel toggle."
  def toggle(name, enabled?) do
    if enabled?,
      do: PushX.Instance.enable(name),
      else: PushX.Instance.disable(name)
  end

  defp build_opts(%{provider: "apns"} = c) do
    [
      key_id: c.key_id,
      team_id: c.team_id,
      private_key: c.private_key,
      mode: String.to_atom(c.mode)
    ]
  end

  defp build_opts(%{provider: "fcm"} = c) do
    [
      project_id: c.project_id,
      credentials: JSON.decode!(c.credentials_json)
    ]
  end
end

Call MyApp.PushAdmin.boot() from your Application.start/2 after PushX starts.

Instance Config Options

Option Type Default Description
:key_idString.t() required (APNS) Apple Key ID
:team_idString.t() required (APNS) Apple Team ID
:private_keyString.t() | {:file, path} | {:system, env} required (APNS) PEM private key
:mode:prod | :sandbox:prod APNS environment
:project_idString.t() required (FCM) Firebase project ID
:credentialsmap() | String.t() required (FCM) Service account (map or JSON string)
:pool_sizeinteger()2 Finch connections per pool
:pool_countinteger()1 Number of Finch pools
:receive_timeoutinteger()15_000 Response timeout (ms)
:pool_timeoutinteger()5_000 Pool checkout timeout (ms)
:connect_timeoutinteger()10_000 TCP connect timeout (ms)

Credential Storage

File System (Development)

# config/dev.exs
config :pushx,
  apns_private_key: {:file, "priv/keys/AuthKey.p8"},
  fcm_credentials: {:file, "priv/keys/firebase-service-account.json"}

Add /priv/keys/ to .gitignore.

Environment Variables (Production)

# config/runtime.exs
config :pushx,
  apns_key_id: System.get_env("APNS_KEY_ID"),
  apns_team_id: System.get_env("APNS_TEAM_ID"),
  apns_private_key: System.get_env("APNS_PRIVATE_KEY"),
  apns_mode: if(System.get_env("APNS_SANDBOX") == "true", do: :sandbox, else: :prod),

  fcm_project_id: System.get_env("FCM_PROJECT_ID"),
  fcm_credentials: System.get_env("FCM_CREDENTIALS") |> JSON.decode!()

Tip: For multiline keys (APNS .p8), set the env var directly from the file: export APNS_PRIVATE_KEY="$(cat AuthKey.p8)"

Fly.io Secrets

fly secrets set APNS_KEY_ID="ABC123DEFG"
fly secrets set APNS_TEAM_ID="TEAM123456"
fly secrets set APNS_PRIVATE_KEY="$(cat AuthKey.p8)"
fly secrets set FCM_PROJECT_ID="my-project-id"
fly secrets set FCM_CREDENTIALS="$(cat firebase-service-account.json)"

Then use System.fetch_env!/1 in config/runtime.exs:

if config_env() == :prod do
  config :pushx,
    apns_key_id: System.fetch_env!("APNS_KEY_ID"),
    apns_team_id: System.fetch_env!("APNS_TEAM_ID"),
    apns_private_key: System.fetch_env!("APNS_PRIVATE_KEY"),
    apns_mode: :prod,
    fcm_project_id: System.fetch_env!("FCM_PROJECT_ID"),
    fcm_credentials: System.fetch_env!("FCM_CREDENTIALS") |> JSON.decode!()
end

AWS Secrets Manager / Vault

if config_env() == :prod do
  {:ok, %{"SecretString" => apns_key}} =
    ExAws.SecretsManager.get_secret_value("pushx/apns-key")
    |> ExAws.request()

  config :pushx,
    apns_private_key: apns_key
end

Getting Your Credentials

Apple APNS Setup

You need: Key ID, Team ID, and a Private Key (.p8 file).

Step 1: Get Your Team ID

  1. Go to Apple Developer Account
  2. Your Team ID is shown in the top-right corner (10 characters)

Step 2: Create an APNS Key

  1. Go to Certificates, Identifiers & Profiles
  2. Click Keys > + (Create a new key)
  3. Enter a name (e.g., "Push Notifications Key")
  4. Check Apple Push Notifications service (APNs)
  5. Click Continue > Register
  6. Download the .p8 file (you can only download it once!)
  7. Note the Key ID shown (10 characters)

Google FCM Setup

You need: Project ID and a Service Account JSON file.

Step 1: Create/Open Firebase Project

  1. Go to Firebase Console
  2. Create a new project or select an existing one
  3. Note your Project ID in Project Settings

Step 2: Enable Cloud Messaging API

  1. Go to Google Cloud Console
  2. Select your Firebase project
  3. Go to APIs & Services > Library
  4. Search for "Firebase Cloud Messaging API" and Enable it

Step 3: Create Service Account Key

  1. In Firebase Console, go to Project Settings (gear icon)
  2. Click Service accounts tab
  3. Click Generate new private key
  4. Save the JSON file securely

Credential Rotation

APNS .p8 keys and FCM service accounts don't expire. You only need to rotate them if you revoke a key or want to follow a rotation policy.

With restart (simplest)

  1. Generate new credentials in Apple/Google console
  2. Update your secrets (Fly: fly secrets set, AWS: update in Secrets Manager)
  3. Redeploy your app
  4. Revoke old credentials after all instances are updated

Without restart (static config)

The static config path reads credentials from Application env on each JWT generation, so you can hot-swap them at runtime:

# 1. Update application env with new credentials
Application.put_env(:pushx, :apns_key_id, "NEW_KEY_ID")
Application.put_env(:pushx, :apns_private_key, new_pem_string)

# 2. Clear the cached JWT (otherwise the old token is used for up to 50 min)
:persistent_term.erase(:pushx_apns_jwt_cache)

# 3. Reconnect to discard connections authenticated with the old token
PushX.reconnect()

For FCM, Goth manages OAuth2 tokens automatically. To rotate service account credentials without restart, use the dynamic instance API below.

Without restart (dynamic instances)

If you use PushX.Instance, call reconfigure/2 — it stops the old pool and starts a fresh one with the new credentials:

PushX.Instance.reconfigure(:apns_prod,
  key_id: "NEW_KEY_ID",
  private_key: new_pem_string
)

In-flight requests on the old pool get connection errors, which the retry logic handles automatically.


Telemetry

PushX emits telemetry events for monitoring and metrics:

Event When Measurements Metadata
[:pushx, :push, :start] Request starts system_timeprovider, token
[:pushx, :push, :stop] Request succeeds durationprovider, token, status, id
[:pushx, :push, :error] Request fails durationprovider, token, status, reason
[:pushx, :push, :exception] Exception raised durationprovider, token, kind, reason
[:pushx, :retry, :attempt] Retry attempted delay_ms, attemptprovider, status

Tokens are automatically truncated in telemetry metadata for privacy (first 8 + last 4 characters).

Example: Attach a Logger

# In your Application.start/2
:telemetry.attach_many(
  "pushx-logger",
  [
    [:pushx, :push, :stop],
    [:pushx, :push, :error]
  ],
  fn
    [:pushx, :push, :stop], %{duration: d}, %{provider: p}, _ ->
      ms = System.convert_time_unit(d, :native, :millisecond)
      Logger.info("PushX #{p} sent in #{ms}ms")

    [:pushx, :push, :error], _, %{provider: p, status: s, reason: r}, _ ->
      Logger.warning("PushX #{p} failed: #{s} - #{r}")
  end,
  nil
)

Example: With Telemetry.Metrics

defmodule MyApp.Telemetry do
  import Telemetry.Metrics

  def metrics do
    [
      counter("pushx.push.stop.count", tags: [:provider]),
      counter("pushx.push.error.count", tags: [:provider, :status]),
      distribution("pushx.push.stop.duration",
        unit: {:native, :millisecond},
        tags: [:provider]
      )
    ]
  end
end

Circuit Breaker

PushX includes an optional circuit breaker that temporarily blocks requests to a provider after consecutive failures. This prevents wasting resources on dead connections.

config :pushx,
  circuit_breaker_enabled: true,
  circuit_breaker_threshold: 5,        # consecutive failures to trip
  circuit_breaker_cooldown_ms: 30_000  # ms before retrying

States:

Only :connection_error and :server_error responses count as failures. Invalid tokens and rate limits do not trip the circuit.

# Check circuit breaker state
PushX.CircuitBreaker.state(:apns)
#=> :closed

# Manual reset
PushX.CircuitBreaker.reset(:apns)

Health Check

Check provider configuration and circuit breaker status:

PushX.health_check()
#=> %{
#=>   apns: %{configured: true, circuit: :closed},
#=>   fcm: %{configured: true, circuit: :closed}
#=> }

Token Cleanup Callback

Automatically clean up invalid tokens from your database when a push fails with :invalid_token, :expired_token, or :unregistered:

config :pushx,
  on_invalid_token: {MyApp.Push, :handle_invalid_token, []}

The callback receives (provider, device_token, ...extra_args) and runs asynchronously:

defmodule MyApp.Push do
  def handle_invalid_token(provider, device_token) do
    MyApp.Tokens.delete_by_token(device_token)
    Logger.info("Removed invalid #{provider} token")
  end
end

Troubleshooting

too_many_concurrent_requests Error

This Mint HTTP/2 error means all streams on a connection are in use. It has two common causes with opposite fixes:

[error] [PushX.APNS] Connection error: %Mint.HTTPError{reason: :too_many_concurrent_requests}

Cause 1: Stale connections (low-traffic apps)

On cloud infrastructure (Fly.io, AWS, GCP), idle HTTP/2 connections can be silently dropped by load balancers or firewalls. The client doesn't know the connection is dead, so new requests on it hang or fail. PushX enables TCP keepalive to detect dead connections at the OS level, and automatically reconnects on connection errors during retry.

Fix: Reduce pool size to minimize idle connections:

config :pushx,
  finch_pool_size: 2,
  finch_pool_count: 1

You can also force a reconnect manually:

PushX.reconnect()

Cause 2: Actual overload (high-traffic apps)

If you're sending thousands of notifications per minute, the pool may genuinely run out of HTTP/2 streams.

Fix: Increase pool capacity and use rate limiting:

config :pushx,
  finch_pool_size: 50,
  finch_pool_count: 4,
  rate_limit_enabled: true,
  rate_limit_apns: 2000,
  rate_limit_fcm: 2000

request_timeout Error

[error] [PushX.APNS] Connection error: %Finch.Error{reason: :request_timeout}
  1. Increase timeouts if connecting from distant regions (e.g., EU to US):

    config :pushx,
      receive_timeout: 30_000,
      connect_timeout: 20_000
  2. PushX automatically retries connection errors with exponential backoff (1s, 2s, 4s)

  3. If this follows a too_many_concurrent_requests error, see the stale connections fix above

Debugging Tips

Enable telemetry logging to monitor push performance:

:telemetry.attach("pushx-debug", [:pushx, :push, :error], fn _, _, meta, _ ->
  Logger.warning("Push failed: #{meta.provider} - #{meta.status} - #{meta.reason}")
end, nil)

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Commit your changes (git commit -m 'Add some amazing feature')
  4. Push to the branch (git push origin feature/amazing-feature)
  5. Open a Pull Request

License

MIT License. See LICENSE for details.


Built with care by Cigno Systems AB