Receiving messages, from one-shot polls to supervised streaming subscriptions.
Polling: one-shot fetches
ExNtfy.poll/2 returns cached messages and closes the connection — no
processes involved. Full filter surface in ExNtfy.Subscribe.Options:
{:ok, messages} = ExNtfy.poll("mytopic") # everything cached
{:ok, messages} = ExNtfy.poll(["alerts", "backups"], since: "10m")
{:ok, messages} = ExNtfy.poll("mytopic", since: last_seen.id) # resume by message id
{:ok, urgent} = ExNtfy.poll("mytopic", priority: [:high, :urgent], tags: [:prod])priority: filters match any listed value (OR); tags: must all
match (AND). scheduled: true includes not-yet-delivered scheduled messages.
Streaming subscriptions
ExNtfy.Subscription holds one connection open and:
- reconnects on any disconnect with exponential backoff + jitter,
resuming from the last seen message (
since=<id>) so nothing is missed; - tears down dead-quiet connections via a keepalive watchdog
(
idle_timeout:, default 90 s against the server's ~45 s keepalives); - treats non-2xx responses as fatal — a
403won't fix itself, so the subscription delivers{:down, %ExNtfy.Error{}}and stops; - stops when its owner process dies.
Style 1 — message passing
{:ok, sub} = ExNtfy.subscribe("alerts", since: "10m")
receive do
{:ntfy, ^sub, %ExNtfy.Message{} = message} -> handle(message)
{:ntfy_lifecycle, ^sub, :connected} -> :ok
{:ntfy_lifecycle, ^sub, :disconnected} -> :ok # reconnect is automatic
{:ntfy_lifecycle, ^sub, {:message_clear, message}} -> dismiss(message)
{:ntfy_lifecycle, ^sub, {:message_delete, message}} -> remove(message)
{:ntfy_lifecycle, ^sub, {:down, reason}} -> react(reason)
end
ExNtfy.unsubscribe(sub)open and keepalive events are internal and never surface.
Style 2 — a handler under supervision
Implement ExNtfy.Handler and put the subscription in your tree:
defmodule MyApp.NtfyHandler do
@behaviour ExNtfy.Handler
@impl true
def init(arg), do: {:ok, arg}
@impl true
def handle_message(%ExNtfy.Message{} = message, state) do
MyApp.Alerts.process(message)
{:ok, state}
end
@impl true # optional callback
def handle_lifecycle(:connected, state), do: {:ok, state}
def handle_lifecycle(_event, state), do: {:ok, state}
end
children = [
{ExNtfy.Subscription,
topics: ["alerts", "backups"],
handler: {MyApp.NtfyHandler, []},
auth: {:token, "tk_..."},
name: MyApp.NtfySub}
]Callbacks run inside the subscription process; a crashing handler takes the subscription down and your supervisor restarts both.
Style 3 — a lazy stream
ExNtfy.stream("alerts")
|> Stream.filter(&(&1.priority >= 4))
|> Enum.take(5)Blocks the calling process; halting the enumeration (or the consumer dying) stops the underlying subscription.
Filters and reconnect tuning
All poll filters apply to subscriptions too, plus:
ExNtfy.subscribe("alerts",
since: :latest, # start from the most recent cached message
priority: [:high, :urgent],
reconnect_base_ms: 1_000, # backoff: base * 2^attempt, capped
reconnect_max_ms: 60_000,
idle_timeout: 90_000,
reconnect: true # false = stop instead of reconnecting
)Other transports
format: selects the wire format — semantics are identical everywhere:
:json(default) — ndjson over HTTP, the primary transport.:sse— Server-Sent Events.:raw— message bodies only; no metadata, so nosinceresume.:ws— WebSocket, requiring the optional dependency:{:mint_web_socket, "~> 1.0"}. Without it,subscribe/2raises anArgumentErrortelling you what to add.
Running many subscriptions: pool sizing
Each HTTP-format subscription (:json/:sse/:raw) holds one connection
from Req's default shared Finch pool — the same pool your publishes and
polls use. A handful of subscriptions is fine; dozens against the same host
can exhaust the pool and starve your publishes (or vice versa). Give
subscriptions their own pool:
# in your supervision tree
children = [
{Finch, name: MyApp.NtfySubscriptionPool, pools: %{default: [size: 50]}},
{ExNtfy.Subscription,
topics: "alerts",
handler: {MyApp.NtfyHandler, []},
req_options: [finch: MyApp.NtfySubscriptionPool]}
]format: :ws subscriptions open their own dedicated connection (Mint,
outside any pool), so they don't compete.
Telemetry
[:ex_ntfy, :subscription, :connected | :disconnected | :message] fire with
%{topics: topics} metadata; polls emit [:ex_ntfy, :poll, ...] spans.