Jobs & Realtime¶
Background job queues (in-memory, synchronous, and Redis-backed), the in-app scheduler, WebSockets, outgoing mail, multi-channel notifications, and outbound webhooks.
queue ¶
Background jobs: run work off the request/response cycle.
The default queue is an asyncio.Queue living in this process's memory,
drained by a worker task that starts lazily on the first job you push — no
framework wiring required, and it needs no ASGI lifespan hook to work
correctly in tests or under a real server alike.
That also means: a job pushed but not yet run is lost if the process
crashes or restarts. Fine for non-critical background work (a welcome
email, warming a cache); a real limitation for anything you'd be upset to
silently lose (payment capture, anything that must survive a crash).
:class:RedisQueue is the durable, opt-in alternative — the same
trade-off as RateLimiter and Cache, see docs/queues.md.
Job ¶
Bases: ABC
A unit of background work. Subclass and implement :meth:handle.
Jobs are plain Python objects (dataclasses are a natural fit) — the default queue never serializes them, so constructor arguments can be anything, not just JSON-safe values::
@dataclass
class SendWelcomeEmail(Job):
email: str
name: str
async def handle(self) -> None:
...
handle() can also declare type-hinted parameters beyond self to
have them resolved from the container that dispatched the job (the same
autowiring Container.call uses everywhere else)::
async def handle(self, mailer: Mailer) -> None:
await mailer.send(...)
Queue ¶
Bases: ABC
Accepts jobs to run in the background.
container, if given, is used to autowire any extra type-hinted
parameters on a job's handle() — see :class:Job. Without one,
handle() is called with no arguments beyond self.
Source code in src/zeython/queue.py
push
abstractmethod
async
¶
InMemoryQueue ¶
Bases: Queue
Runs jobs on a background asyncio task in this process.
The worker starts on the first :meth:push and keeps running for the
life of the event loop. Failed jobs are retried up to
job.max_attempts times, with each failure logged; :meth:close is
available for a clean shutdown (mainly useful in tests, to avoid
"task was destroyed but it is pending" warnings at interpreter exit).
delay schedules a job to be enqueued after a wait rather than
immediately, via a tracked background task -- also cleaned up by
:meth:close.
Source code in src/zeython/queue.py
join
async
¶
Block until every job pushed so far has finished running. Mainly for tests.
Includes a job pushed with delay -- asyncio.Queue.join()
alone only tracks work already put() onto the queue, and a
delayed push doesn't call put() until its wait elapses. This
first waits for every currently-pending delayed push's own wait
(however long that is) before falling through to the queue's own
join, so this genuinely blocks for "as long as it takes", not
just however long is already in flight.
Source code in src/zeython/queue.py
close
async
¶
Cancel the background worker task and any pending delayed pushes.
Source code in src/zeython/queue.py
SyncQueue ¶
Bases: Queue
Runs jobs immediately and synchronously — no background task, no retries.
Meant for tests and local dev: failures raise straight through push()
instead of being caught and logged, so you see them immediately rather
than digging through logs. delay is ignored -- a synchronous,
immediate-execution queue has nothing to schedule against.
Source code in src/zeython/queue.py
RedisQueue ¶
RedisQueue(
url: str,
*,
container: Container | None = None,
queue_name: str = "default",
prefix: str = "zeython:queue:",
)
Bases: Queue
A Redis-backed durable queue: a job pushed here survives a crash or
restart of the process that pushed it, and is processed by a separate
worker process (zeython queue work) rather than a background task
inside the web server. Requires the redis extra
(pip install zeython[redis]).
Jobs must be @dataclass subclasses of :class:Job -- see
:func:_serialize_job. Failed attempts are retried with capped
exponential backoff (2, 4, 8, ... up to 60 seconds between attempts);
a job that exhausts max_attempts is moved to a failed-jobs list
instead of being dropped, so nothing that couldn't be processed is
silently lost -- see :meth:failed_jobs.
All keys are namespaced under prefix + queue_name (default
"zeython:queue:default:") — safe to point at a Redis instance
shared with other subsystems (cache, rate limiting, sessions).
Source code in src/zeython/queue.py
failed_jobs
async
¶
Every job that exhausted its retries, most recently failed first.
run_worker
async
¶
Block, processing jobs from this queue until shutdown is set
(or forever, if none is given) -- what zeython queue work runs.
Reclaims any delayed/retry jobs whose wait has elapsed on every
poll, then blocks (up to poll_interval seconds) for the next
ready job via Redis's own BRPOP rather than busy-polling.
Source code in src/zeython/queue.py
BatchProgress
dataclass
¶
BatchTracker ¶
Bases: ABC
Tracks how many jobs in a batch are still pending — the shared state
:func:dispatch_batch needs to know when the last one finishes.
Bound automatically by :class:QueueServiceProvider, matching whichever
Queue driver is active; not meant to be used directly.
create
abstractmethod
async
¶
Register a new batch of total jobs, optionally with a
serialized then job spec to hand back via :meth:get_then once it finishes.
Source code in src/zeython/queue.py
record_completion
abstractmethod
async
¶
Record that one job in batch_id finished, and return the batch's progress so far.
progress
abstractmethod
async
¶
get_then
abstractmethod
async
¶
InMemoryBatchTracker ¶
Bases: BatchTracker
Process-local batch progress — correct for :class:InMemoryQueue and
:class:SyncQueue, which only ever run in this same process.
A finished batch's state is kept for the life of the process (so
:func:batch_progress keeps answering after then fires) — like
:class:~zeython.cache.InMemoryCache, fine for typical usage, not a
fit for creating unboundedly many batches over a long-running
process's lifetime.
Source code in src/zeython/queue.py
RedisBatchTracker ¶
Bases: BatchTracker
A Redis-backed :class:BatchTracker, correct across every worker
process draining a :class:RedisQueue — :class:InMemoryBatchTracker's
limitation. Requires the redis extra (pip install zeython[redis]).
Batch state expires after ttl seconds (default 24h) so completed
bookkeeping doesn't accumulate in Redis forever; every completion
refreshes it, so only a genuinely abandoned batch id is ever actually lost.
Source code in src/zeython/queue.py
QueueServiceProvider ¶
Bases: ServiceProvider
Binds a :class:Queue into the container, plus the matching
:class:BatchTracker :func:dispatch_batch needs (:class:RedisBatchTracker
for the redis driver, :class:InMemoryBatchTracker for the others).
.env: QUEUE_DRIVER —
memory(default) — :class:InMemoryQueue, a background task in this process. Jobs are lost on crash/restart.sync— :class:SyncQueue, runs jobs immediately in-line; useful for tests/local dev.redis— :class:RedisQueue, durable, processed by a separatezeython queue workprocess. RequiresREDIS_URLand theredisextra.QUEUE_NAMEpicks the queue (defaultdefault) -- useful if you want a dedicated worker/priority lane for, say, emails vs. report generation.
Source code in src/zeython/providers.py
dispatch
async
¶
Queue job to run in the background rather than blocking this request.
Uses whichever Queue is bound in the container — :class:InMemoryQueue
by default, :class:SyncQueue if QUEUE_DRIVER=sync, :class:RedisQueue
if QUEUE_DRIVER=redis (see :class:QueueServiceProvider). Pass
delay to run the job after a wait instead of as soon as a worker is
free. Outside of a request, push directly to a resolved queue instead:
await app.container.make(Queue).push(job).
Source code in src/zeython/queue.py
chain ¶
Wrap jobs so they run strictly one after another — the next link
only starts once the previous one finishes successfully::
await dispatch(request, chain([DownloadReport(), EmailReport(), CleanupTempFiles()]))
Returns a single Job — dispatch it exactly like any other. If a
link exhausts its own max_attempts, the rest of the chain never
runs (logged and reported the same way any other exhausted job is,
not raised somewhere nothing is watching).
Every job in the chain must be a @dataclass — a chain has to
serialize its remaining links to hand off to a later, independent
dispatch (the next link), the same requirement :class:RedisQueue has
for any job it runs. To run something once a group of independent
jobs all finish, use :func:dispatch_batch instead — nesting one
inside the other isn't supported: a chain link only waits for the
wrapped job's own handle(), not any further dispatch it makes, so
a batch placed inside a chain link wouldn't actually block the next
link, and a chain placed inside a batch would only count that batch
member done once the chain's first link finishes.
Source code in src/zeython/queue.py
dispatch_batch
async
¶
dispatch_batch(
request: Request,
jobs: list[Job],
*,
then: Job | None = None,
batch_id: str | None = None,
) -> str
Dispatch every job in jobs independently — for strict order, use
:func:chain instead — and track their combined progress under a
batch id this returns::
batch_id = await dispatch_batch(request, [ResizeImage(p) for p in photos], then=NotifyGalleryReady(album.id))
Pass then= a job to dispatch automatically, exactly once, the
moment every job in the batch has finished — whether it succeeded or
exhausted its own retries. then's own handle() can call
:func:batch_progress to see how many failed; since the batch id isn't
known until this call returns (after then has already been
constructed), pass your own via batch_id= if then needs it::
batch_id = str(uuid.uuid4())
await dispatch_batch(request, jobs, then=NotifyGalleryReady(batch_id=batch_id), batch_id=batch_id)
Every job (and then, if given) must be a @dataclass — see
:func:chain.
Source code in src/zeython/queue.py
batch_progress
async
¶
The current progress of the batch batch_id (from
:func:dispatch_batch), or None if unknown — never existed, or,
for :class:RedisBatchTracker only, expired (see its docstring).
Source code in src/zeython/queue.py
schedule ¶
In-app task scheduling: recurring jobs defined in code (and therefore version-controlled, reviewed, and deployed alongside the app) instead of scattered across a server's crontab where nobody remembers what runs and why.
Schedule holds a list of ScheduledEvents; zeython schedule run
-- meant to be invoked once a minute by a single cron entry (or a sidecar
loop container, see docs/scheduling.md) -- checks which are due this
minute and runs them. Nothing here polls or sleeps on its own: the actual
"once a minute" cadence is still driven by whatever calls zeython
schedule run, the same way Laravel's own schedule:run works.
ScheduledEvent ¶
ScheduledEvent(
name: str,
callback: Callable[..., Awaitable[None]],
*,
container: Container | None = None,
)
One recurring task: a callback plus a cron expression saying when
it's due. Built fluently off :meth:Schedule.call; every builder
method returns self so calls chain::
schedule.call(send_daily_digest).daily_at("07:00")
Source code in src/zeython/schedule.py
cron ¶
daily_at ¶
"HH:MM", 24-hour, e.g. daily_at("07:30").
weekly ¶
monthly ¶
without_overlapping ¶
Skip a run if a previous one started within the last
for_seconds -- for a task that occasionally runs longer than
its own interval, or one you never want two copies of running
against each other.
Implemented via the container's bound RateLimiter as a "run at
most once per window" gate, keyed by this event's name -- a
time-based window, same trade-off Laravel's own
withoutOverlapping() makes: the lock expires after
for_seconds regardless of whether the previous run actually
finished, it doesn't track "is it still running" directly.
Requires a shared RateLimiter backend (RedisRateLimiter)
to do anything in the normal case. zeython schedule run is a
fresh process every time cron invokes it -- the default
InMemoryRateLimiter's lock lives in that process's memory and
is gone the instant it exits, so back-to-back CLI invocations never
see each other's lock at all. See docs/scheduling.md.
Source code in src/zeython/schedule.py
Schedule ¶
The registry schedule.py builds against: schedule.call(fn).daily().
Source code in src/zeython/schedule.py
call ¶
Register callback (any async function; type-hinted params
beyond the ones you pass are autowired from the container, same as
a Job's handle()) and return the :class:ScheduledEvent to
set its frequency on.
Source code in src/zeython/schedule.py
run_due
async
¶
Run every event due at at (default: now), returning the
ones that were due. An event that raises is logged and doesn't
stop the rest from running -- what zeython schedule run calls.
Source code in src/zeython/schedule.py
ScheduleServiceProvider ¶
Bases: ServiceProvider
Binds a process-wide :class:Schedule singleton and imports
schedule.py from the project root for its side effect of
registering events on it -- the same convention
:class:~zeython.providers.RouteServiceProvider uses for
routes/web.py.
Not registered by default -- add it once you have scheduled tasks to define::
# main.py
app.register(ScheduleServiceProvider(app))
Source code in src/zeython/schedule.py
cron_matches ¶
Whether a standard 5-field cron expression (minute hour day month
weekday) matches at. Supports *, single values, comma lists
(1,3,5), ranges (1-5), and step values (*/15, 1-10/2).
The weekday field is 0-6 (0 = Sunday) only -- unlike some
cron implementations, 7 is not accepted as an alias for Sunday.
Day-of-month and weekday combine the standard cron way when both
are restricted (neither is *): a match on either field is enough
("0 0 1 * 1" -- midnight on the 1st of the month, or every
Monday -- matches far more often than requiring both at once would).
With only one of the two restricted, that field alone decides, same
as every other field.
Source code in src/zeython/schedule.py
websockets ¶
Real-time WebSocket support, built directly on Starlette's ASGI-native WebSocket handling -- no separate server, no extra process.
Router.websocket(...)/Application.websocket(...) registers a
handler the same way @app.get(...) does for HTTP. :class:WebSocketHub
is the process-local "broadcast to everyone connected" registry a chat
window, a live dashboard, or any other push-to-many feature needs;
:class:RedisWebSocketHub is the same thing backed by Redis pub/sub, for
a broadcast to reach every worker process, not just this one.
WebSocketHub ¶
WebSocketHub(
*,
allowed_origins: Iterable[str] | None = None,
max_connections_per_ip: int | None = None,
)
Tracks connected WebSocket clients and broadcasts messages to them.
Process-local: a message only reaches clients connected to this
process. Fine for a single worker; running more than one means each
worker has its own, disjoint set of connections, so a broadcast only
reaches whichever fraction of clients happen to be on the same worker
-- back this with a pub/sub backend (Redis's PUBLISH/SUBSCRIBE
is the usual choice) once that matters. See docs/websockets.md.
A WebSocket handshake is a plain HTTP request that carries cookies
automatically -- without an origin check, any site can open a
connection here using a logged-in visitor's session (cross-site
WebSocket hijacking). Pass allowed_origins to guard against that;
left unset, every origin is accepted (matches every earlier release --
opt in once you actually serve browser clients over more than one
origin you don't control).
Nothing stops a single client from opening hundreds of connections --
each one costs a slot in this hub's memory and a slot in the pool of
connections a broadcast iterates, so a runaway or malicious client can
degrade the service for everyone else. Pass max_connections_per_ip
to cap it; left unset, there's no limit (matches every earlier
release).
Source code in src/zeython/websockets.py
connect
async
¶
Accept the handshake and start tracking this connection.
Returns False (after closing the connection, without ever
accepting it) if allowed_origins is configured and this
handshake's Origin header doesn't match one of them (close code
4403), or if max_connections_per_ip is configured and this
client already has that many connections open (close code 4429).
Check the return value and bail out if it's False -- proceeding
to receive_text()/etc. on a connection that was never accepted
raises::
if not await hub.connect(websocket):
return
Source code in src/zeython/websockets.py
disconnect ¶
Stop tracking a connection -- call this from a finally block
once its handler loop ends, however it ends.
Source code in src/zeython/websockets.py
broadcast
async
¶
Send message to every connected client except exclude
(typically the sender, when echoing a chat message back to everyone
else).
A send failing -- a client that's disconnected but hasn't reached
this hub's disconnect() yet -- doesn't stop the broadcast
reaching everyone else; that connection is just dropped from the
hub instead.
Source code in src/zeython/websockets.py
RedisWebSocketHub ¶
RedisWebSocketHub(
url: str,
*,
channel: str = "zeython:websockets:broadcast",
allowed_origins: Iterable[str] | None = None,
max_connections_per_ip: int | None = None,
)
Bases: WebSocketHub
A :class:WebSocketHub whose broadcasts reach every process, not
just this one -- the distributed backend the base class's docstring
names. Requires the redis extra (pip install zeython[redis]).
Every process running a RedisWebSocketHub against the same Redis
PUBLISHes each broadcast to a shared channel and SUBSCRIBEs to that
same channel, relaying whatever it receives to its own locally
connected clients -- so a message broadcast from any one worker
reaches clients connected to every worker, this one included, with no
special-casing needed (each published message is tagged with this
instance's own id so it doesn't relay its own broadcast back to
clients that already got it directly from :meth:broadcast).
The listener starts automatically on this hub's first :meth:connect
call -- there's no ASGI lifespan hook to start it any earlier, and
nothing needs the listener running before the first connection exists
anyway. That first connect() (and only that one -- later calls see
the subscription already confirmed) doesn't return until the SUBSCRIBE
has actually been acknowledged by Redis, not just scheduled: without
that wait, a client that connects and immediately triggers a broadcast
could publish before this instance's own subscription had taken
effect, and Redis pub/sub never redelivers a message to a subscriber
that wasn't listening yet. Call :meth:stop to shut the listener down
cleanly (mainly useful in tests; a real process just exits, taking the
task with it).
Doesn't attempt to reconnect if the Redis connection drops mid-stream -- the listener task logs the error and stops; broadcasts stop reaching other processes (and this process stops relaying theirs) until the process is restarted. The same accepted trade-off as the other Redis-backed classes here, none of which implement retry logic: simple and predictable beats a hand-rolled reconnect loop that becomes its own source of bugs.
Source code in src/zeython/websockets.py
stop
async
¶
Cancel the background listener task.
Source code in src/zeython/websockets.py
broadcast
async
¶
Deliver to this process's own connections immediately (respecting
exclude, which only ever refers to a connection on this
process -- another process can't have the same object), then
publish so every other process's listener relays it to theirs.
Source code in src/zeython/websockets.py
WebSocketHubServiceProvider ¶
Bases: ServiceProvider
Binds a process-local :class:WebSocketHub into the container.
WEBSOCKET_ALLOWED_ORIGINS -- comma-separated, e.g.
https://example.com,https://app.example.com -- restricts handshakes
to those origins (see :class:WebSocketHub's cross-site hijacking
note). Unset by default, matching every earlier release; set it once
real browser clients are involved and you're not deliberately serving
other origins too.
WEBSOCKET_MAX_CONNECTIONS_PER_IP -- caps concurrent connections
from a single client (see :class:WebSocketHub's resource-exhaustion
note). Unset by default, matching every earlier release.
For a broadcast that reaches every worker process/machine, not just
this one, bind :class:RedisWebSocketHub directly instead of
registering this provider::
app.container.singleton(WebSocketHub, lambda: RedisWebSocketHub(config.get("redis.url")))
See docs/redis.md.
Source code in src/zeython/providers.py
mail ¶
Outbound email: a small Mailer interface, a log-only default so
zeython new works with zero mail configuration, and an SMTP backend for
when you actually have credentials.
Message
dataclass
¶
Message(
to: str | list[str],
subject: str,
body: str,
html: str | None = None,
from_address: str | None = None,
)
An email to send. to accepts a single address or a list.
LogMailer ¶
Bases: Mailer
Writes the email to your app's logs instead of sending it.
The default (MAIL_DRIVER=log), so a fresh zeython new project
can dispatch mail-sending jobs immediately without SMTP credentials.
Switch to :class:SmtpMailer (MAIL_DRIVER=smtp) once you have real
ones. See docs/mail.md.
SmtpMailer ¶
SmtpMailer(
*,
host: str,
port: int,
username: str | None,
password: str | None,
use_tls: bool,
default_from: str,
)
Bases: Mailer
Sends real email over SMTP, via the stdlib (no third-party dependency).
smtplib is blocking, so :meth:send runs it on a worker thread
(asyncio.to_thread) rather than blocking the event loop.
Source code in src/zeython/mail.py
MailServiceProvider ¶
Bases: ServiceProvider
Binds a :class:Mailer into the container from .env.
MAIL_DRIVER—log(default) orsmtpMAIL_HOST,MAIL_PORT(default587)MAIL_USERNAME,MAIL_PASSWORDMAIL_ENCRYPTION—tls(default) ornoneMAIL_FROM_ADDRESS(defaultno-reply@example.com)
Source code in src/zeython/providers.py
notifications ¶
Multi-channel notifications: one :class:Notification subclass
describes what happened and which channels (mail, database,
broadcast) should carry it; :func:notify fires all of them.
Mirrors Laravel's Notification system.
Deliberately separate from :mod:zeython.events -- an event is "this
happened, react however you like" (any number of independent listeners,
no fixed shape); a notification is "tell this one recipient, on
these channels, in this format" -- one Notification instance
describes its own rendering per channel rather than leaving each
listener to reinvent it.
Notification ¶
Base class for a notification.
Override :meth:via to pick channels for a given recipient, and
whichever to_* builder each of those channels actually needs::
class InvoicePaid(Notification):
def __init__(self, invoice: Invoice) -> None:
self.invoice = invoice
def via(self, notifiable: Any) -> list[str]:
return ["mail", "database"]
def to_mail(self, notifiable: Any) -> Message:
return Message(
to=notifiable.email,
subject="Invoice paid",
body=f"Thanks! We received ${self.invoice.amount}.",
)
def to_database(self, notifiable: Any) -> dict:
return {"invoice_id": self.invoice.id, "amount": self.invoice.amount}
NotificationManager ¶
Fires a :class:Notification at a recipient across whichever
channels it asks for. Bound in the container by
:class:NotificationServiceProvider -- use :func:notify from a
request handler, or resolve this directly outside of one (a job, a
scheduled task).
Source code in src/zeython/notifications.py
notify
async
¶
Send notification to notifiable on every channel
:meth:Notification.via names.
A channel's own failure is logged and reported (see
:mod:zeython.error_monitoring), not raised -- the same reasoning
:class:~zeython.events.EventDispatcher uses for listeners: a
down SMTP server shouldn't also silently swallow the in-app
notification that would have told the user something happened.
Source code in src/zeython/notifications.py
NotificationServiceProvider ¶
Bases: ServiceProvider
Binds a :class:NotificationManager into the container.
app.register(NotificationServiceProvider(app, record_model=Notification))
record_model is your own Model subclass for the database
channel (columns: notifiable_id, type, data, read_at)
-- generated by zeython make notification alongside a
Notification example class, wired into a fresh project's
app/Models/notification.py by default. Omit it if you only ever
use the mail/broadcast channels; calling the database
channel without one raises a RuntimeError naming the fix -- but
:meth:NotificationManager.notify catches every channel's exception
for isolation (a down SMTP server shouldn't also swallow an unrelated
in-app notification), logging and reporting it rather than
propagating it to your caller. Check the logs (or Sentry, if
:mod:zeython.error_monitoring is configured) if a notification
silently doesn't show up, rather than expecting notify() itself to
raise.
Source code in src/zeython/notifications.py
notify
async
¶
Send notification to notifiable using whichever
:class:NotificationManager is bound in the container (see
:class:NotificationServiceProvider). Outside of a request -- a job,
a scheduled task -- resolve directly instead::
manager = app.container.make(NotificationManager)
await manager.notify(user, WelcomeNotification())
Source code in src/zeython/notifications.py
unread_notifications
async
¶
Every record_model row for notifiable not yet marked read,
newest first -- the framework doesn't force a mixin onto your
notifiable model to get this, just pass the same record_model you
registered with :class:NotificationServiceProvider.
Source code in src/zeython/notifications.py
mark_as_read
async
¶
Stamp notification.read_at with the current time and save it.
webhooks ¶
Outbound webhooks: notify a third party's URL whenever something
happens in your app, the mirror image of :mod:zeython.notifications
(which notifies a recipient inside your own app).
An endpoint subscribes to one event name; :func:fire_webhook/
:meth:WebhookManager.fire looks up every active subscriber for that
event and hands each one off to the existing background-job queue
(:mod:zeython.queue) as a :class:DeliverWebhookJob -- delivery,
retries, and backoff are all the queue's own well-tested machinery, not
reinvented here. Each POST carries an HMAC-SHA256 signature the
receiver can verify, the same double-submit-adjacent idea CSRF uses:
proof the payload came from you, not from whoever guessed the URL.
UnsafeWebhookUrlError ¶
Bases: Exception
Raised by :func:ensure_safe_webhook_url -- a webhook URL's scheme
isn't http/https, or its hostname resolves to a private,
loopback, link-local, or otherwise non-public address.
DeliverWebhookJob
dataclass
¶
DeliverWebhookJob(
endpoint_id: int,
event: str,
payload: dict[str, Any] = dict(),
max_attempts: int = 5,
)
Bases: Job
Delivers one webhook, dispatched by :meth:WebhookManager.fire --
retried by whichever :class:~zeython.queue.Queue is configured
(capped exponential backoff under :class:~zeython.queue.RedisQueue)
up to max_attempts times before giving up.
WebhookManager ¶
WebhookManager(
container: Container,
*,
endpoint_model: type[Model],
delivery_model: type[Model] | None = None,
timeout: float = DEFAULT_TIMEOUT,
block_private_network_targets: bool = True,
)
Looks up subscribers for an event and hands delivery off to the
queue. Bound in the container by :class:WebhookServiceProvider.
Source code in src/zeython/webhooks.py
fire
async
¶
Queue a delivery to every active endpoint subscribed to
event. A given endpoint's own dispatch failure (only possible
under :class:~zeython.queue.SyncQueue, which runs a job inline
rather than truly queuing it) is logged and reported, not raised
-- the same isolation :class:~zeython.notifications.NotificationManager
applies per channel, so one bad subscriber can't block delivery
to the rest.
Source code in src/zeython/webhooks.py
deliver
async
¶
Actually perform one delivery attempt -- called by
:class:DeliverWebhookJob, but usable directly too (a
zeython.mcp-style introspection tool, or your own "resend this
delivery" admin action).
Re-fetches the endpoint rather than trusting the state at the
moment :meth:fire ran -- it may have been disabled or deleted in
the time a retried delivery sat in the queue.
Source code in src/zeython/webhooks.py
WebhookServiceProvider ¶
WebhookServiceProvider(
app: Any,
*,
endpoint_model: type[Model],
delivery_model: type[Model] | None = None,
timeout: float = DEFAULT_TIMEOUT,
block_private_network_targets: bool = True,
)
Bases: ServiceProvider
Binds a :class:WebhookManager into the container.
app.register(WebhookServiceProvider(app, endpoint_model=WebhookEndpoint))
endpoint_model is your own Model subclass for endpoint
subscriptions (columns: url, event, secret, active) --
one row per (url, event) pair, so a single receiving URL that wants
several event types just gets several rows. delivery_model is
optional (columns: endpoint_id, event, status_code,
success, error) -- if given, every delivery attempt
(including retries) is logged there for an audit trail of what was
sent and whether it arrived, mirroring what Stripe/GitHub show in
their own webhook dashboards. Neither model is mandated by the
framework, the same record_model pattern
:class:~zeython.notifications.NotificationServiceProvider uses --
see docs/webhooks.md.
block_private_network_targets (default True) rejects an
endpoint url that resolves to a private, loopback, link-local, or
otherwise non-public address before ever connecting to it -- see
:func:ensure_safe_webhook_url. There's no legitimate reason a
public webhook subscriber resolves there; pass False only if
you've validated endpoint URLs yourself and genuinely need to target
an internal service (e.g. local development against
http://localhost:...).
Source code in src/zeython/webhooks.py
ensure_safe_webhook_url ¶
Reject url if it isn't safe to let the server itself fetch on an
endpoint owner's behalf -- raises :class:UnsafeWebhookUrlError if so.
A webhook endpoint's url is exactly the shape of input SSRF
guards exist for: if an app lets a user (a customer self-service
integrations page, not just a trusted admin) register their own
subscription URL, fire_webhook() would otherwise happily deliver
a server-signed POST wherever that row says to -- including
http://169.254.169.254/... (a cloud metadata endpoint) or
http://localhost:6379 (an internal service with no auth of its
own), with the response status/error even echoed back into
delivery_model if one is configured, a strong SSRF oracle against
internal infrastructure. This checks the scheme and resolves the
hostname, rejecting anything that isn't a public, routable address --
on by default (see WebhookManager/WebhookServiceProvider's
block_private_network_targets), since there's no legitimate
reason a public webhook subscriber would ever resolve to a private
range.
This resolves the hostname once, up front -- it does not protect against DNS rebinding (a name that resolves safely here but to a private address by the time the actual HTTP connection is made a moment later). Closing that fully needs connecting to the exact address validated here, not re-resolving at connect time; this check is the inexpensive, no-extra-dependency majority case (a flat-out private/loopback/metadata URL), not a complete guarantee against a determined, actively-rebinding adversary.
Source code in src/zeython/webhooks.py
sign_payload ¶
The hex-encoded HMAC-SHA256 signature of body under secret --
sent as the X-Webhook-Signature header on every delivery, and what
:func:verify_signature checks a received payload against.
Source code in src/zeython/webhooks.py
verify_signature ¶
True if signature is the correct HMAC-SHA256 of body under
secret -- for code on the receiving end of a webhook (a Zeython
app included) to check before trusting a delivered payload::
async def receive_webhook(request):
body = await request.body()
signature = request.headers.get("X-Webhook-Signature", "")
if not verify_signature(known_secret, body, signature):
raise ForbiddenException("Bad webhook signature.")
Uses :func:hmac.compare_digest rather than == -- a naive string
comparison leaks how many leading bytes matched through response
timing, letting an attacker recover the correct signature one byte at
a time.
Source code in src/zeython/webhooks.py
fire_webhook
async
¶
Fire event at every subscribed endpoint using whichever
:class:WebhookManager is bound in the container (see
:class:WebhookServiceProvider). Outside of a request -- an event
listener, a scheduled task -- resolve directly instead::
manager = app.container.make(WebhookManager)
await manager.fire("order.shipped", {"order_id": order.id})