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.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 viaPushX.Instance, web push topic IDs), and a curated list of mistakes commonly made (forgetting APNStopic:,push_dataon APNS, mode mismatch,fcm_credentialsas raw string, multilineapns_private_keyvia 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.mdis a symlink toAGENTS.mdfor 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:516only 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 raisesCaseClauseErroron itself. The exception escaped pastPushX.Retry, killed the sending Task, and (in batch sends with caller-sideEnum.each) silently skipped every recipient after the failing one. Now rescued in bothPushX.APNSandPushX.FCM: anyCaseClauseError{term: {:error, reason}}where reason is an atom is converted to a retryableResponse.error(_, :connection_error, _), soPushX.Retryhandles reconnection normally. The previous narrow rescue only matched the literal:connection_process_went_downterm and reraised any other 2-tuple shape. - APNS payload corruption when custom data uses an atom
:apskey —Message.to_apns_payload/1,APNS.notification_with_data/4,APNS.silent_notification/1, andAPNS.web_notification_with_data/5previously stripped only the string"aps"key from caller data. A map containing both atom:apsand the constructed string"aps"was JSON-encoded with twoapskeys, which APNS could reject or interpret unpredictably. All four functions now drop both"aps"and:apsfrom 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/4silently produced an invalid APNS payload for APNS named instances — Callingpush_data(:my_apns_instance, …)previously routed throughpush/4with a%{"data" => …}map, which APNS doesn't understand. Now rejected with{:error, %Response{status: :invalid_request, provider: :apns}}and a message pointing atpush/4withpush_type: "background"for APNS silent push.- JWT refresh could deadlock if the lock holder was killed — The previous APNS JWT cache used
:atomicsas a mutex withtry/afterto release. If the holder was killed forcibly (e.g.Process.exit(pid, :kill)), theafterclause 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 throughGenServer.call/3. A killed refresher only delays callers until the supervisor restarts the process. - APNS empty-string
:topicwas forwarded to Apple — Treating""as a valid topic produced a remoteMissingTopicerror. Both static and named-instance APNS paths now treatniland""as missing and return:invalid_requestlocally. - Invalid APNS
:moderaisedFunctionClauseError— A typo'dapns_mode(e.g.:production) crashed the sending Task past thetry/rescue(which only catchesCaseClauseError). Mode is now validated upfront and returns{:error, %Response{status: :invalid_request}}cleanly. push_batch/4with:validate_tokenssilently 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 byAPNS.send_batch/3andFCM.send_batch/3.HTTP.stringify_map/1raisedProtocol.UndefinedErroron nested maps/lists — The previousto_string(v)worked only for binaries, atoms, and numbers. A nested map or list as an FCMdatavalue crashed the calling process past thetry/rescue. Nested maps and lists are now JSON-encoded so they survive transport as strings; PIDs and other non-stringable terms fall back toinspect/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 catchesCaseClauseError). Encoding now goes throughPushX.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/4andpush_batch!/4type specs missed instance names — Both functions accept instance atoms but the spec wasprovider() :: :apns | :fcm. Dialyzer flagged legitimate calls. Specs now includeinstance_name().Response.error(provider, …)could embed an instance atom in the response struct —push_batch/4's:exit, :timeoutbranch used the caller-supplied provider atom directly, violating theResponse.provider :: :apns | :fcm | :unknowntypespec. Now mapped throughresponse_provider/1so instance atoms collapse to:unknown.CircuitBreaker.record_failure/1lost updates under concurrency —:ets.lookupfollowed by:ets.insertis non-atomic, so concurrent failures undercounted and the real threshold was fuzzy. All circuit-breaker writes now route through the GenServer viaGenServer.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-Afterparsing —HTTP.parse_retry_after/1now 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 tonil(default backoff) for malformed or past dates. - 25 new tests covering atom-
:aps(3), URL-special characters (3), APNS-instancepush_dataguard (1),JWTCacheGenServer (6),:validate_tokenserror responses (3), empty:topicand unknown:mode(2), payload size and encode failures (2), and thePushX.HTTPmodule (5+ groups, 21 tests). - Total test count: 340 tests, 25 doctests.
Changed
- Hot-path
Logger.debugcalls deferred — APNS and FCM debug log lines now use the function form, soPushX.Telemetry.truncate_token/1no 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 extracted —
PushX.URLscentralizes APNS/FCM endpoint constants andPushX.HTTPconsolidates header parsing,Retry-Afterparsing, FCMdatastringification, and JSON encoding. Eliminates ~100 lines of duplication betweenPushX.APNS,PushX.FCM,PushX.Instance, andPushX.Application.
0.10.0 - 2026-02-19
Added
PushX.push_data/3,4— Send data-only (silent) push notifications via both:fcmand named instances. Returns a clear error for:apnswith guidance to usepush/4withpush_type: "background".PushX.Response.extract_fcm_error_code/1— Public function to extract FCM-specific error codes from thedetailsarray 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 forpush_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 adetailsarray withNOT_FOUNDas the top-level gRPC status. The parser only read the top-level status, soon_invalid_tokencallbacks never fired for unregistered tokens. Now extracts the FCM-specificerrorCodefrom the details array. (Fixes #3) - FCM
build_messagealways added notification key —build_messagehardcoded a"notification"key in the base map, making data-only messages impossible and sending"notification": nullfor 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.
PushX.Instance.start/3— Start a named APNS or FCM instancePushX.Instance.stop/1— Stop and clean up an instancePushX.Instance.reconfigure/2— Hot-swap credentials or config without restartPushX.Instance.enable/1/disable/1— Toggle instances without tearing down poolsPushX.Instance.list/0/status/1/resolve/1— Query running instancesPushX.Instance.reconnect/1— Restart an instance's HTTP/2 poolPushX.push/4accepts instance names (e.g.,PushX.push(:apns_prod, token, msg, opts))
- 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
:topicno longer raises — Returns{:error, %Response{status: :invalid_request}}instead of raisingArgumentError, 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.Responseprovider type now includes:unknownfor 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_timeoutand:pool_timeoutas opts to individualsendcalls 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/1is 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 inMessage.to_apns_payload/1,notification_with_data/4,silent_notification/1, andweb_notification_with_data/5 - FCM
send_dataparity —send_data/3andsend_data_once/3now have circuit breaker, telemetry, per-request timeouts, debug logging, and exception handling matching the regularsend/3path - Reconnect error logging — Retry logic now logs a warning if
PushX.reconnect/0fails 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 handling —
get_access_token/0no longer raises on Goth failure, returns{:ok, token} | {:error, reason}instead- FCM data-only messages missing timeouts —
send_datanow uses configuredreceive_timeoutandpool_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; usereceive_timeoutandpool_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_timeouterrors 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 exceededrequest_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
- Web Push support for browsers:
- FCM Web Push (Chrome, Firefox, Edge) - same API as mobile
- Safari Web Push (macOS) via APNS with
web.topic prefix
PushX.FCM.web_notification/4- Create web push payloads with click actionPushX.FCM.send_web/5- Convenience function for web notificationsPushX.APNS.web_notification/4- Safari web push payloads with URL argsPushX.APNS.web_notification_with_data/5- Safari web push with custom data- 20 new tests for web push functionality
- Total test count: 205 tests
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
PushX.push_batch/4- Returns list of{token, result}tuplesPushX.push_batch!/4- Returns summary%{success: n, failure: n, total: n}PushX.APNS.send_batch/3andPushX.FCM.send_batch/3for direct provider access- Configurable
:concurrency(default: 50) and:timeout(default: 30s) options
- Token validation — validate token format before sending
PushX.validate_token/2- Returns:okor{:error, reason}PushX.valid_token?/2- Returns booleanPushX.Tokenmodule with validation for APNS (64 hex chars) and FCM (20-500 chars) tokens:validate_tokensoption for batch sending to filter invalid tokens
- Rate limiting — optional client-side rate limiting
PushX.check_rate_limit/1- Check if under rate limitPushX.RateLimitermodule 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.Telemetrymodule with documentation and examplestelemetry ~> 1.3dependency- 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.Retrymodule for retry logicsend_once/3functions for APNS and FCM (single attempt without retry)retry_afterfield inPushX.Responsestructretryable?/1helper function inPushX.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)