All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

0.12.0 - 2026-08-16

Security

  • FCM INVALID_ARGUMENT no longer classified as :invalid_token — it now maps to :invalid_request. FCM returns INVALID_ARGUMENT for any malformed request (oversized fields, reserved data keys, bad android/apns/webpush blocks), not just bad tokens. Under the old mapping, a single developer-side payload bug produced :invalid_token for every recipient, and with the documented :on_invalid_token auto-cleanup pattern wired up, would have deleted every device token in the caller's database. UNREGISTERED and SENDER_ID_MISMATCH — the codes that genuinely mean "drop this token" — are unchanged. Breaking (minor): callers matching on status: :invalid_token for FCM 400s should match :invalid_request instead.

  • Unvalidated per-instance APNS credentials could crash the shared PushX.JWTCache and escalate to a cross-tenant outageInstance.start/3 and reconfigure/2 only checked that :private_key was present. A malformed PEM (or a {:file, path} whose file is missing / {:system, VAR} unset) made Joken/JOSE raise badarg on the first push — inside PushX.JWTCache's handle_call, a GenServer shared by every instance and the default APNS path. The caller crashed instead of receiving the documented {:error, %Response{}}, the cache's ETS table (holding every tenant's cached JWT) was destroyed with it, and three such pushes within five seconds exceeded PushX.Supervisor's default restart intensity, stopping the :pushx application — in a start_permanent release, the whole node. One tenant's bad credential became an availability failure for all tenants. Fixed in three layers:

    • Instance.start/3 now eagerly resolves the APNS private key and performs a test sign, returning {:error, {:invalid_private_key, reason}} instead of accepting a credential that can never sign. reconfigure/2 validates the merged config before stopping the old instance, so a bad rotation leaves the running instance untouched.
    • generate_jwt in both PushX.APNS and PushX.Instance now rescues exceptions from key resolution and signing, returning the documented {:error, reason} tuple.
    • PushX.JWTCache wraps the caller-supplied generator in try/rescue/catch, so no generator — raise, throw, exit, or bad return shape — can crash the shared cache process.
  • Unusable FCM credentials on a named instance could take down every named instanceInstance.start/3 only checked that :credentials was present. Goth eagerly exchanges the service-account credentials with Google when it starts, so a map missing "private_key"/"client_email" (or holding a PEM that cannot sign RS256) made Goth raise on that prefetch and crash-loop; the restarts escalated through the instance supervisor to PushX.Instance.DynamicSupervisor, whose restart wiped the instance registry and killed all tenants — while start/3 had already returned {:ok, name}. This is the FCM twin of the APNS credential issue above and gets the same fix: start/3 and reconfigure/2 now decode the credentials, require both keys, and perform a test RS256 signature, returning {:error, {:invalid_credentials, reason}} before anything is started (a bad rotation leaves the running instance untouched).

  • PushX.push(:fcm, …) no longer exits the caller when FCM is not configured — with no PushX.Goth process, Goth.fetch/1 exited the calling process with {:noproc, …} instead of honouring the documented {:error, %Response{}} contract. The static and instance FCM paths now return {:error, %Response{status: :auth_error, reason: "FCM is not configured: …"}} (non-retryable); transient OAuth token-endpoint failures still map to the retryable :connection_error.

Added

  • Coverage gate and hardened CI/release workflowsmix test --cover enforces a 94% line-coverage threshold (test_coverage: [summary: [threshold: 94]] in mix.exs) and now runs as a dedicated CI job with the HTML report uploaded as an artifact; mix hex.build checks the package in the quality job; docs build with --warnings-as-errors; workflows run with permissions: contents: read and CI also runs weekly so newly published dependency advisories fail the build without a push. The release workflow no longer publishes on a bare tag: it first verifies the tag matches mix.exs's @version and runs the full suite with the coverage gate.
  • Docs logo works on the dark theme — the hexdocs sidebar logo is now an icon-only, square, transparent PNG (assets/pushx_icon.png, also used as the favicon). ExDoc renders the logo at 48×48 next to the project name it already prints, and the previous wide wordmark image had an opaque white background baked in (no alpha channel), which showed as a white block on the dark theme. The README wordmark (pushx_logo.png) is likewise transparent now, with the "PushX" lettering recoloured to the brand gradient so it reads on GitHub's, hex.pm's and hexdocs' light and dark themes alike.
  • Property-based tests (stream_data, test-only) for the pure, input-shaped parts of the library: PushX.Token.validate/2 (any even-length 64–512-char hex token is valid; length/format failures are classified correctly; never raises on arbitrary binaries), the PushX.Message payload builders and PushX.FCM.build_message/3 (always JSON-encodable, aps can never be overwritten by caller data, FCM data values are always strings), PushX.Response error classification (total functions; unknown FCM codes can never trigger token removal), PushX.HTTP.parse_retry_after/1 (nil or a non-negative integer for any header value), and PushX.Config.batch_timeout_ms/0 (never below the 30 s floor, always covers the worst-case retry cycle).
  • Credo now runs in CI (mix credo --strict) alongside Dialyzer, with a checked-in .credo.exs. The sweep it triggered: Response.apns_reason_to_status/1 rewritten as a map lookup, two single-branch conds converted to if, and alias ordering fixed.
  • CI dependency audit switched from mix hex.audit to mix_audit — cowlib 2.19.0 (a test-only dependency via Bypass) has two published advisories with no fixed release, which made the un-ignorable hex.audit step permanently red. mix deps.audit pins exactly those two advisory IDs (the response-splitting one is mitigated downstream by cowboy ≥ 2.16, which is locked); the ignores are documented in the workflow and should be removed when a fixed cowlib ships.

Fixed

  • Batch sends no longer kill retrying tasks mid-backoff by default — the per-task :timeout in PushX.push_batch/4, PushX.APNS.send_batch/3, and PushX.FCM.send_batch/3 defaulted to a flat 30 s, while a single send's blocking retry cycle can legitimately take ~3 minutes with the default retry config (3 attempts, up to 60 s delays). A retrying batch task was therefore killed mid-backoff and reported as a timeout even though a later attempt would have succeeded. The default is now PushX.Config.batch_timeout_ms/0, computed from the retry config (attempts × (receive_timeout + pool_timeout) + (attempts − 1) × max(retry_max_delay_ms, 60 s rate-limit delay), floor 30 s; plain 30 s when retries are disabled). An explicit :timeout still always wins. Breaking (minor): with retries enabled, a hung batch task is now killed after ~180 s (default config) instead of 30 s — pass timeout: 30_000 to keep the old behavior.
  • Low-severity hardening sweep (L1–L7 from the v0.11.0 review):
    • APNS token validation no longer hard-codes 64 characters (Apple warns token length may change): any even-length hex string of 64–512 chars now passes PushX.Token.validate/2.
    • verify: :verify_peer is set explicitly on every HTTPS pool (static and instance) so a future refactor can't silently disable TLS peer verification — this was already Mint's default, now it's pinned.
    • The :on_invalid_token callback now runs under PushX.TaskSupervisor instead of an unsupervised Task.start, so a crashing cleanup callback is logged with a stacktrace instead of dying silently.
    • PushX.Retry.retryable?/1 now delegates to PushX.Response.retryable?/1 (the logic existed in both places and could drift).
    • Documented that Message.to_apns_payload/1 injects "sound": "default" for titled messages, and how to opt out.
  • Circuit breaker no longer serializes every send result through its GenServerrecord_success/1 previously issued a GenServer.call per successful send, making the breaker process a throughput chokepoint at high send rates. In steady state (:closed, zero failures) a success changes nothing, so it now checks that with one lock-free ETS read and skips the round-trip entirely — the breaker process sees no traffic at all on the healthy hot path. State transitions (failure counting, resets after failures, probe results) remain fully serialized through the GenServer as before. The elision can race a concurrent failure, which at worst opens the breaker one failure earlier than the configured threshold; the trade-off is documented on record_success/1.
  • Circuit breaker now admits exactly one half-open probe — the open→half_open transition was a read-modify-write on ETS performed in the caller process, so under concurrency multiple requests could each flip the state and all be admitted as "probes", defeating the point of half-open. The transition now runs inside the breaker's GenServer (the hot allow-path remains a lock-free ETS read; only the rare transition takes the call), other requests are rejected while a probe is in flight, and a probe that never reports back (e.g. its task was killed) is replaced after a full cooldown instead of wedging the breaker.
  • Rate limiter is now race-free (single atomic counter per window) — the old implementation did an ETS lookup followed by a conditional increment/insert: concurrent callers could all pass the check and overshoot the limit, and two callers hitting the "window expired" branch clobbered each other's :ets.insert, resetting the count and letting far more than the limit through. Counters are now keyed {key, window_id} and bumped with one atomic :ets.update_counter/4 — there is no separate check step to race. The moduledoc also stops claiming a "sliding window": it is and was a fixed window, now documented as such (and as best-effort, which client-side limiting inherently is).
  • Background/silent APNS pushes now default to apns-priority: 5 — Apple requires priority 5 for apns-push-type: background; the previous unconditional default of 10 produced an invalid request unless the caller remembered priority: 5 themselves. When push_type: "background" is set and no explicit :priority is given, PushX now sends 5. Explicit :priority always wins. Header building is shared between the static and instance paths (PushX.APNS.build_headers/3).
  • Named instances no longer bypass the hardening the static path has — three drift bugs closed by extracting shared helpers:
    • Instance sends now pass through the same circuit-breaker + rate-limiter gate as PushX.APNS/PushX.FCM (new internal PushX.SendGate). Breakers are keyed by instance name so one tenant's failing pool can't open the breaker for others; rate limits count per instance using the provider-level config.
    • Instance requests now go through the shared PushX.HTTP.finch_request/4, which converts Finch's NimblePool CaseClauseError (e.g. :connection_process_went_down) into a retryable :connection_error — the headline v0.11.0 fix that the instance path had missed, where it still crashed the calling task.
    • The instance FCM builder now delegates to PushX.FCM.build_message/3, restoring the apns override key that the instance copy silently dropped (iOS-via-FCM overrides vanished on named instances).
  • Automatic pool reconnects are now coalesced instead of cascading — the retry logic restarts the shared Finch pool on the first connection error of a send, but under load (e.g. a network blip during a push_batch of thousands) every concurrent task observed the error and every one of them restarted the pool, killing all other in-flight connections and amplifying a brief blip into a sustained outage. The new PushX.ReconnectGuard grants at most one automatic restart per pool per cooldown window (default 5 s, config :pushx, reconnect_cooldown_ms: ...), keyed separately for the static pool and each named instance. Manual PushX.reconnect/0 calls are not gated.
  • PushX.Message priority/2, ttl/2, and collapse_key/2 now actually reach the wire — these documented builder setters were silently dropped: neither to_apns_payload/1 nor the FCM builders ever read them, so Message.new() |> Message.ttl(3600) |> Message.priority(:normal) sent a notification with none of that applied. They now translate to APNS headers (apns-priority, apns-expiration — computed from ttl seconds, apns-collapse-id) and to the FCM android block (priority, ttl, collapse_key) on both the static and instance paths, via the new public helpers Message.to_apns_options/1 and Message.to_fcm_android/1. Explicit call-site opts always win over struct-derived values. Breaking (minor): %Message{}.priority now defaults to nil ("use the provider's default") instead of :high, so an unset struct can never fight provider rules such as APNS requiring priority 5 for background pushes; callers who relied on reading .priority from a fresh struct should set it explicitly.
  • A crashing batch task no longer takes down the whole batch — or the caller — batch sends used Task.async_stream, which links tasks to the calling process: a task that raised (rather than returning an error tuple) killed the caller outright, and even a trapped exit would have hit a result aggregator that only matched {:ok, _} and {:exit, :timeout}. Batches now run under a dedicated Task.Supervisor via async_stream_nolink, and the aggregators in PushX.push_batch/4, PushX.APNS.send_batch/3, and PushX.FCM.send_batch/3 map any non-timeout task exit to {token, {:error, %Response{status: :unknown_error}}}, preserving per-token isolation.
  • APNS provider-token (JWT) rejections now self-heal instead of causing up to ~50 minutes of failures — Apple's ExpiredProviderToken, InvalidProviderToken, MissingProviderToken, and TooManyProviderTokenUpdates reasons previously fell through to :unknown_error: not retryable, and nothing invalidated the cached JWT, so every send failed until the 50-minute cache TTL rolled over (clock skew, key rotation, or Apple expiring the token early all trigger this). They now classify as :auth_error, and for the first three the send path invalidates the cached JWT and retries once with a freshly signed one (both the static PushX.APNS path and named instances). TooManyProviderTokenUpdates deliberately does not regenerate — minting JWTs faster is exactly what that error is complaining about.
  • The test suite now exercises the real send paths — the FCM, APNS, batch, facade, and named-instance "HTTP integration" tests were asserting against hand-copied re-implementations of the send pipeline living inside the test files, so PushX.FCM.send/3, PushX.push/4 and the whole PushX.Instance send path had effectively zero coverage through their public API. Two internal, test-only seams close that gap: :fcm_url_override (mirrors the existing :apns_url_override) and :fcm_token_fetcher (a {module, function, args} replacement for Goth.fetch/1; when set, named FCM instances do not start a Goth process). Line coverage rose from 65% to 94% and the 90% coverage threshold is now enforced in mix test --cover. Neither seam is documented for production use.
  • Test fixture APNS key was on the wrong curve — and is no longer committed at all — the test key in test_helper.exs was a secp256k1 key, which JOSE cannot sign ES256 with (APNS requires P-256); the suite never noticed because JWT generation was only exercised lazily at push time. Both the APNS P-256 key and the FCM RSA service-account key are now generated fresh for every test run instead of being checked in, so secret scanners have nothing to flag and the keys are provably tied to nothing.
  • CI actions bumped to their Node 24 majors (actions/checkout@v7, actions/cache@v6, actions/upload-artifact@v7) to clear the Node.js 20 deprecation warnings on every job.

0.11.0 - 2026-05-07

Documentation

  • AGENTS.md — usage guide for AI coding assistants integrating PushX into projects: mental model (function-call API, no supervision-tree setup), decision tree (push / push_batch / push_data / instances), idiomatic patterns (token cleanup via :on_invalid_token, multi-tenant via PushX.Instance, web push topic IDs), and a curated list of mistakes commonly made (forgetting APNS topic:, push_data on APNS, mode mismatch, fcm_credentials as raw string, multiline apns_private_key via env). Shipped in the hex package and rendered on hexdocs.
  • CONTRIBUTING.md — repo orientation for contributors: layout, test commands, conventions for error semantics and telemetry. CLAUDE.md is a symlink to AGENTS.md for tool compatibility.
  • README banner pointing AI assistants at AGENTS.md.

Fixed

  • APNS/FCM crash on transient Finch pool errors — Finch's outer case in lib/finch.ex:516 only matches {:ok, …} or the 3-tuple {:error, err, _acc} shape. When NimblePool returns a 2-tuple error — {:error, :connection_process_went_down} (HTTP/2 connection process death under concurrent-request-limit pressure) is the one observed in production, but the same pattern can produce other atom reasons — Finch raises CaseClauseError on itself. The exception escaped past PushX.Retry, killed the sending Task, and (in batch sends with caller-side Enum.each) silently skipped every recipient after the failing one. Now rescued in both PushX.APNS and PushX.FCM: any CaseClauseError{term: {:error, reason}} where reason is an atom is converted to a retryable Response.error(_, :connection_error, _), so PushX.Retry handles reconnection normally. The previous narrow rescue only matched the literal :connection_process_went_down term and reraised any other 2-tuple shape.
  • APNS payload corruption when custom data uses an atom :aps keyMessage.to_apns_payload/1, APNS.notification_with_data/4, APNS.silent_notification/1, and APNS.web_notification_with_data/5 previously stripped only the string "aps" key from caller data. A map containing both atom :aps and the constructed string "aps" was JSON-encoded with two aps keys, which APNS could reject or interpret unpredictably. All four functions now drop both "aps" and :aps from custom data.
  • APNS URL injection via unvalidated device tokens — Device tokens were interpolated directly into the request URL (/3/device/<token>). A token containing /, ?, #, or whitespace could redirect the request to an unintended path. APNS.send/3, APNS.send_once/3, and the named-instance APNS path now reject tokens that contain anything other than alphanumerics, underscore, or hyphen with {:error, %Response{status: :invalid_token}}.
  • PushX.push_data/4 silently produced an invalid APNS payload for APNS named instances — Calling push_data(:my_apns_instance, …) previously routed through push/4 with a %{"data" => …} map, which APNS doesn't understand. Now rejected with {:error, %Response{status: :invalid_request, provider: :apns}} and a message pointing at push/4 with push_type: "background" for APNS silent push.
  • JWT refresh could deadlock if the lock holder was killed — The previous APNS JWT cache used :atomics as a mutex with try/after to release. If the holder was killed forcibly (e.g. Process.exit(pid, :kill)), the after clause did not run and every subsequent JWT request failed indefinitely with "JWT refresh timeout after 10 attempts". The cache is now a supervised GenServer (PushX.JWTCache) with lock-free ETS reads and serialized refresh through GenServer.call/3. A killed refresher only delays callers until the supervisor restarts the process.
  • APNS empty-string :topic was forwarded to Apple — Treating "" as a valid topic produced a remote MissingTopic error. Both static and named-instance APNS paths now treat nil and "" as missing and return :invalid_request locally.
  • Invalid APNS :mode raised FunctionClauseError — A typo'd apns_mode (e.g. :production) crashed the sending Task past the try/rescue (which only catches CaseClauseError). Mode is now validated upfront and returns {:error, %Response{status: :invalid_request}} cleanly.
  • push_batch/4 with :validate_tokens silently dropped invalid tokens — Callers got a result list shorter than their input list with no signal of which tokens were skipped, so iterating in lockstep (e.g. to mark tokens) misaligned. Invalid tokens now get {:error, %Response{status: :invalid_token, reason: "Invalid token format"}} instead, so the result list always matches the input length. Same option is now honored by APNS.send_batch/3 and FCM.send_batch/3.
  • HTTP.stringify_map/1 raised Protocol.UndefinedError on nested maps/lists — The previous to_string(v) worked only for binaries, atoms, and numbers. A nested map or list as an FCM data value crashed the calling process past the try/rescue. Nested maps and lists are now JSON-encoded so they survive transport as strings; PIDs and other non-stringable terms fall back to inspect/1.
  • JSON.encode! crashed the calling process on un-encodable terms — A payload containing a PID, ref, function, or tuple raised past the rescue block (which only catches CaseClauseError). Encoding now goes through PushX.HTTP.safe_encode/1; failures return {:error, %Response{status: :invalid_request, reason: "Failed to encode payload: ..."}} cleanly. Encoding also happens before JWT/OAuth acquisition so an oversized or un-encodable payload doesn't waste a credential round-trip.
  • push_batch/4 and push_batch!/4 type specs missed instance names — Both functions accept instance atoms but the spec was provider() :: :apns | :fcm. Dialyzer flagged legitimate calls. Specs now include instance_name().

  • Response.error(provider, …) could embed an instance atom in the response structpush_batch/4's :exit, :timeout branch used the caller-supplied provider atom directly, violating the Response.provider :: :apns | :fcm | :unknown typespec. Now mapped through response_provider/1 so instance atoms collapse to :unknown.

  • CircuitBreaker.record_failure/1 lost updates under concurrency:ets.lookup followed by :ets.insert is non-atomic, so concurrent failures undercounted and the real threshold was fuzzy. All circuit-breaker writes now route through the GenServer via GenServer.call/2, serializing them while reads stay lock-free.

Added

  • Pre-flight payload size check — APNS rejects payloads >4 KB (>5 KB for push_type: "voip") and FCM rejects payloads >4 KB locally, returning {:error, %Response{status: :payload_too_large}} instead of round-tripping a guaranteed-fail request.
  • HTTP-date Retry-After parsingHTTP.parse_retry_after/1 now handles RFC 1123 HTTP-date format (e.g. "Wed, 21 Oct 2015 07:28:00 GMT") in addition to delta-seconds, per RFC 7231 §7.1.3. Falls back to nil (default backoff) for malformed or past dates.
  • 25 new tests covering atom-:aps (3), URL-special characters (3), APNS-instance push_data guard (1), JWTCache GenServer (6), :validate_tokens error responses (3), empty :topic and unknown :mode (2), payload size and encode failures (2), and the PushX.HTTP module (5+ groups, 21 tests).
  • Total test count: 340 tests, 25 doctests.

Changed

  • Hot-path Logger.debug calls deferred — APNS and FCM debug log lines now use the function form, so PushX.Telemetry.truncate_token/1 no longer runs when debug logging is disabled. Measurable on high-volume batch sends.
  • Payload validation moved before credential acquisition — APNS and FCM now encode + size-check the payload before requesting a JWT or OAuth token. Saves one ES256 signing or OAuth round-trip per rejected request and gives faster local error feedback.
  • Internal: shared HTTP helpers extractedPushX.URLs centralizes APNS/FCM endpoint constants and PushX.HTTP consolidates header parsing, Retry-After parsing, FCM data stringification, and JSON encoding. Eliminates ~100 lines of duplication between PushX.APNS, PushX.FCM, PushX.Instance, and PushX.Application.

0.10.0 - 2026-02-19

Added

  • PushX.push_data/3,4 — Send data-only (silent) push notifications via both :fcm and named instances. Returns a clear error for :apns with guidance to use push/4 with push_type: "background".
  • PushX.Response.extract_fcm_error_code/1 — Public function to extract FCM-specific error codes from the details array in FCM v1 API responses. Eliminates duplicated parsing logic across modules.
  • 16 new tests (8 for extract_fcm_error_code, 4 for FCM data-only/structured payloads, 3 for push_data, 1 for NOT_FOUND mapping)
  • Total test count: 302 tests, 25 doctests

Fixed

  • FCM UNREGISTERED errors parsed as unknown_error — FCM v1 API wraps the real error code (e.g., UNREGISTERED) in a details array with NOT_FOUND as the top-level gRPC status. The parser only read the top-level status, so on_invalid_token callbacks never fired for unregistered tokens. Now extracts the FCM-specific errorCode from the details array. (Fixes #3)
  • FCM build_message always added notification keybuild_message hardcoded a "notification" key in the base map, making data-only messages impossible and sending "notification": null for empty Message structs. Now uses conditional logic to only include notification when content exists. (Fixes #2)
  • FCM structured payloads treated as notifications — Raw maps with "notification" and/or "data" keys were wrapped in another "notification" key instead of being passed through. Now detects structured payloads and preserves their structure.

0.9.0 - 2026-02-16

Added

  • Dynamic instances (runtime config) — Start, stop, reconfigure, enable/disable APNS and FCM instances at runtime without application restart. Each instance gets its own HTTP/2 pool, JWT cache, and OAuth process. Enables database-backed admin panels for multi-provider setups. See Dynamic Instances in the README.
  • New response statuses:invalid_request (missing required options like :topic) and :auth_error (JWT/credential failure). Both are non-retryable and don't trip the circuit breaker.
  • Credential rotation docs — README now documents how to hot-swap APNS/FCM credentials without restart for both static config and dynamic instances
  • HexDocs module groups — Modules are now organized into Core API, Providers, Runtime Instances, Infrastructure, and Observability groups
  • 45 new tests (Instance lifecycle, pool management, concurrent instances, error paths)
  • Total test count: 286 tests, 23 doctests

Fixed

  • APNS missing :topic no longer raises — Returns {:error, %Response{status: :invalid_request}} instead of raising ArgumentError, consistent with the error-tuple API contract
  • JWT generation failure no longer crashes — Returns {:error, %Response{status: :auth_error}} instead of raising, preventing process crashes from invalid private keys
  • JWT refresh no longer recurses infinitely — Added depth limit (10 retries, 500ms max wait) to prevent stack overflow if the atomic lock holder crashes

Changed

  • PushX.Response provider type now includes :unknown for instance-not-found/disabled errors

0.8.0 - 2026-02-13

Added

  • Circuit breaker — Opt-in circuit breaker tracks consecutive failures per provider and temporarily blocks requests when a provider is consistently failing. Configurable threshold and cooldown. See Circuit Breaker in the README.
  • PushX.health_check/0 — Returns configuration status and circuit breaker state for each provider
  • Per-request timeout overrides — Pass :receive_timeout and :pool_timeout as opts to individual send calls to override global config
  • Token cleanup callback — Configure on_invalid_token: {Mod, :fun, args} to automatically clean up invalid tokens from your database
  • PushX.Telemetry.truncate_token/1 is now a public function for use in custom logging
  • 23 doctests across 7 modules (Token, Telemetry, APNS, FCM, Message, Response, PushX)
  • Circuit breaker test suite (13 tests)
  • Integration tests for batch sending with mixed success/failure responses
  • Total test count: 241 tests, 23 doctests

Fixed

  • APNS payload injection — Custom data containing an "aps" key can no longer overwrite the notification payload in Message.to_apns_payload/1, notification_with_data/4, silent_notification/1, and web_notification_with_data/5
  • FCM send_data paritysend_data/3 and send_data_once/3 now have circuit breaker, telemetry, per-request timeouts, debug logging, and exception handling matching the regular send/3 path
  • Reconnect error logging — Retry logic now logs a warning if PushX.reconnect/0 fails instead of silently ignoring the error
  • Device tokens redacted in debug logs — APNS and FCM debug log messages now truncate tokens (first 8 + last 4 chars) matching the telemetry module's privacy behavior
  • Fixed incorrect doctest for Token.validate/2 (was :invalid_format, actually :invalid_length)

0.7.1 - 2026-02-11

Added

  • Automatic pool reconnect on connection errors — When the first retry attempt fails with a connection error (stale HTTP/2 connections), PushX now restarts the Finch pool to force fresh connections before retrying. This fixes the issue where retries on stale connections always fail with too_many_concurrent_requests.
  • PushX.reconnect/0 — Public function to manually restart the HTTP connection pool. Useful for recovering from persistent connection issues without restarting the app.
  • TCP keepalive on all connections — Enables OS-level dead connection detection on APNS and FCM pools, helping prevent zombie HTTP/2 connections on cloud infrastructure.
  • 4 new tests (reconnect, concurrent reconnect, retry-triggered reconnect, no reconnect on non-connection errors)
  • Total test count: 219 tests

Fixed

  • Retries on stale HTTP/2 connections no longer fail repeatedly with too_many_concurrent_requests — the pool is recycled on first connection error

0.7.0 - 2026-02-09

Fixed

  • FCM OAuth error handlingget_access_token/0 no longer raises on Goth failure, returns {:ok, token} | {:error, reason} instead

  • FCM data-only messages missing timeoutssend_data now uses configured receive_timeout and pool_timeout
  • JWT cache thundering herd — Added atomic compare-and-swap lock to prevent concurrent JWT refresh
  • Rate limiter O(n) scaling — Replaced timestamp list with O(1) fixed-window counter in ETS
  • Batch timeout loses token identity — Timed-out tokens now correctly reported via Enum.zip

Changed

  • Rewritten README — New structure with Quick Start, complete Usage Guide, and consolidated Configuration section
  • Deprecated request_timeout/0 (was never passed to Finch; use receive_timeout and pool_timeout)
  • Fixed CHANGELOG FCM token validation range (was 100-500, actually 20-500)

0.6.2 - 2026-02-04

Fixed

  • Logo now has solid white background (fixes transparency grid on GitHub)
  • Fixed HexDocs logo path configuration
  • README now uses GitHub raw URL for logo (works on both GitHub and HexDocs)

0.6.1 - 2026-02-04

Added

  • Configurable request timeouts — New configuration options to handle slow connections:
    • :request_timeout — Overall request timeout (default: 30s)
    • :receive_timeout — Timeout for receiving response data (default: 15s)
    • :pool_timeout — Timeout for acquiring connection from pool (default: 5s)
    • :connect_timeout — TCP connection timeout (default: 10s)
  • Timeouts are now passed to Finch for both APNS and FCM requests
  • Connection timeout configured at Finch pool level for better TCP handling
  • New logo — Modern purple bell/arrow logo added to README and HexDocs
  • 10 new config tests for timeout options
  • Total test count: 215 tests

Fixed

  • request_timeout errors when connecting to APNS from distant regions (e.g., EU to Apple's US servers)

0.6.0 - 2026-02-04

Changed

  • Increased default pool size from 10 to 25 connections per pool
  • Increased default pool count from 1 to 2 pools
  • Faster retry for connection errors — connection errors now use 1s base delay (was 10s) since these are typically transient network issues, not provider throttling
  • Added explicit FCM HTTP/2 pool — FCM endpoint now has dedicated HTTP/2 pool configuration (was using default pool)

Added

  • Troubleshooting section in README with solutions for common errors:
    • too_many_concurrent_requests — HTTP/2 stream limit exceeded
    • request_timeout — connection timeout issues
  • Pool sizing guide in README with recommendations by traffic level
  • Updated documentation for pool configuration options

Fixed

  • Connection errors (request_timeout, too_many_concurrent_requests) now retry faster with 1s/2s/4s delays instead of 10s/20s/40s

0.5.0 - 2026-01-22

Added

Changed

  • FCM token validation now accepts shorter web tokens (min 20 chars, was 100)
  • Updated Finch dependency to ~> 0.21
  • Updated documentation with Web Push examples

0.4.1 - 2026-01-22

Added

  • Expanded Config module test coverage to 100% (24 new tests)
  • Total test count: 185 tests

0.4.0 - 2026-01-22

Added

  • Batch sending — send to multiple tokens concurrently with configurable parallelism
  • Token validation — validate token format before sending
  • Rate limiting — optional client-side rate limiting
    • PushX.check_rate_limit/1 - Check if under rate limit
    • PushX.RateLimiter module with sliding window algorithm
    • Configurable per-provider limits via config
    • Automatic rate limit check before each request (when enabled)

Changed

  • Updated README with batch sending, token validation, and rate limiting documentation
  • Removed completed items from roadmap

0.3.3 - 2026-01-22

Fixed

  • Fixed release workflow cache conflict with ex_doc

0.3.2 - 2026-01-22 [YANKED]

Fixed

  • Fixed code formatting in retry tests

0.3.1 - 2026-01-22 [YANKED]

Fixed

  • Fixed release workflow to use MIX_ENV=dev for ex_doc availability

0.3.0 - 2026-01-22 [YANKED]

Added

  • Telemetry integration with events for monitoring push notification delivery:
    • [:pushx, :push, :start] - Request started
    • [:pushx, :push, :stop] - Request succeeded
    • [:pushx, :push, :error] - Request failed
    • [:pushx, :push, :exception] - Exception raised
    • [:pushx, :retry, :attempt] - Retry attempted
  • PushX.Telemetry module with documentation and examples
  • telemetry ~> 1.3 dependency
  • Comprehensive retry and telemetry test suites (116 total tests)
  • Credential rotation documentation in README
  • Retry configuration documentation in README

Changed

  • Made all examples generic (removed domain-specific references)
  • Updated README with telemetry usage examples and Telemetry.Metrics integration

0.2.4 - 2026-01-22

Added

  • Comprehensive API reference documentation with all functions, options, and types
  • Credential storage options guide (filesystem, env vars, Fly.io, AWS Secrets Manager)

0.2.3 - 2026-01-22

Added

  • GitHub Actions CI workflow (tests on Elixir 1.18/1.19 with OTP 26-28)
  • APNS and FCM credential setup guides
  • Roadmap and contributing sections

Changed

  • Updated Finch dependency to ~> 0.20
  • Improved CI with code quality checks, security audit, and unused deps check
  • Clarified test key comment to avoid false positive security alerts

0.2.2 - 2026-01-12

Added

  • Added CHANGELOG.md with full version history
  • Added Changelog link to hex.pm package

0.2.1 - 2026-01-12

Fixed

  • Fixed CI workflow for documentation generation
  • Fixed code formatting issues

Changed

  • Updated documentation examples to use generic messaging

0.2.0 - 2026-01-12

Added

  • Automatic retry with exponential backoff following Apple/Google best practices
  • PushX.Retry module for retry logic
  • send_once/3 functions for APNS and FCM (single attempt without retry)
  • retry_after field in PushX.Response struct
  • retryable?/1 helper function in PushX.Response
  • Configuration options for retry behavior:
    • retry_enabled - Enable/disable retry (default: true)
    • retry_max_attempts - Maximum retry attempts (default: 3)
    • retry_base_delay_ms - Base delay in milliseconds (default: 10_000)
    • retry_max_delay_ms - Maximum delay in milliseconds (default: 60_000)

Fixed

  • Fixed APNS sandbox URL (api.sandbox.push.apple.com)

0.1.1 - 2026-01-09

Fixed

  • Initial bug fixes and improvements

0.1.0 - 2026-01-09

Added

  • Initial release
  • APNS (Apple Push Notification Service) support with JWT authentication
  • FCM (Firebase Cloud Messaging) support with OAuth2 via Goth
  • Unified API for both providers (PushX.push/4)
  • Message builder API (PushX.Message)
  • Structured response handling (PushX.Response)
  • HTTP/2 connections via Finch
  • Zero external JSON dependency (uses Elixir 1.18+ built-in JSON)