Middleware / MiddlewareChain¶
Overview¶
The middleware system provides pluggable hooks into the publish flow. By inserting custom logic before and after publishes, you can implement logging, validation, tracing, monitoring, message transformation, and other cross-cutting concerns without modifying core business handlers.
Middlewares use the onion model (chain of responsibility) design: multiple middlewares
wrap around each other in registration order, with each middleware able to run logic both
before and after calling next().
Architecture¶
publish request
│
▼
┌─────────────────────────────────────┐
│ Middleware 1 (outer) │
│ ┌─────────────────────────────────┐ │
│ │ Middleware 2 (inner) │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Core publish logic │ │ │
│ │ │ ├─ validate → build → queue │ │ │
│ │ │ └─ on_publish chain (onion) │ │ │
│ │ └─────────────────────────────┘ │ │
│ └─────────────────────────────────┘ │
└─────────────────────────────────────┘
The on_publish chain is nested inside the core publish logic,
which is the innermost layer of the before_publish chain.
Code after await next(...) in before_publish executes after
the entire on_publish chain completes.
Two Hook Phases¶
| Phase | Timing | Available Info |
|---|---|---|
before_publish |
Before validation, Event construction, and enqueue | Event name, source, raw data, predecessor event |
on_publish |
After Event is successfully enqueued | Complete Event object (id, sources, timestamps) |
Middleware¶
Abstract base class. Custom middlewares must implement before_publish and on_publish.
class Middleware(ABC):
# Lifecycle
async def on_setup(self, bus: EventBus) -> None: ...
async def on_teardown(self, bus: EventBus) -> None: ...
# Publish hooks (required)
async def before_publish(
self,
event_registry: EventRegistry,
name: str,
source: str,
data: dict[str, Any] | BaseModel | None,
old_event: Event | None,
next: BeforePublishNext,
) -> None: ...
async def on_publish(
self,
event: Event,
next: OnPublishNext,
) -> None: ...
# Error hook (optional)
async def on_publish_error(
self,
error: Exception,
name: str,
source: str,
data: dict[str, Any] | BaseModel | None,
) -> None: ...
# Task pipeline hooks (optional)
async def before_dispatch(
self,
event: Event,
handler_ids: list[str],
next: DispatchNext,
) -> None: ...
async def on_task_start(self, record: TaskRecord) -> None: ...
async def on_task_done(self, record: TaskRecord) -> None: ...
Hook Details¶
on_setup(bus) / on_teardown(bus)¶
Bus lifecycle hooks. on_setup is called during bus.start() (before handler activation, so messages published by handlers at activation go through a fully-ready middleware pipeline), and immediately when hot-adding via add() / insert() on a running bus. on_teardown is called in reverse registration order when stop() finishes, and immediately when remove() / clear() is called on a running bus. Use these for initializing connection pools, registering background tasks, etc.
Note: Middleware lifecycle is fail-fast and atomic, mirroring handler activation: an
on_setupfailure fails the whole bus start (rolled back, retryable after fixing), and a hot-added middleware whoseon_setupfails is rolled back and rejected. Failing middlewares are never silently dropped.Warning: Runtime
remove()does not wait for in-flight hooks to complete. Afteron_teardownis called, a middleware instance that has already enteredbefore_publish/on_publishmay still execute. Middleware authors should ensure their own state cleanup is safe against these residual calls.
before_publish(event_registry, name, source, data, old_event, next)¶
Runs before any publish logic (including event declaration validation). Must call await next(...) for the event to proceed.
| Parameter | Description |
|---|---|
event_registry |
Access event declaration metadata. |
name |
Event type name. |
source |
Publisher identifier. |
data |
Raw payload (dict or BaseModel, may be None). |
old_event |
Predecessor event in a chain (may be None). |
next |
Call to invoke next middleware (or core publish). Must be called for event to proceed. |
on_publish(event, next)¶
Runs after the Event is successfully enqueued. Full runtime event info available via event.name, event.data, event.id, event.sources, etc.
| Parameter | Description |
|---|---|
event |
Complete Event object (name, data, id, sources, timestamps). |
next |
Call to invoke next middleware. |
on_publish_error(error, name, source, data)¶
Called when the publish flow raises an exception (optional). All middlewares are notified in registration order. One middleware's error doesn't block others from being notified.
| Parameter | Description |
|---|---|
error |
The exception that occurred. |
name |
Event type name. |
source |
Publisher identifier. |
data |
Raw payload data. |
before_dispatch(event, handler_ids, next)¶
Runs before task creation, right after route matching. Call await next(event, handler_ids) to proceed; the chain end creates one task per id, in list order (pass a filtered / reordered list if needed). Not calling next blocks this dispatch (the event is dropped — compensate in your middleware, and log it yourself if you need an audit trail).
Warning: This hook runs on the dispatch-loop coroutine — a critical path. Return quickly, or offload heavy work via
asyncio.create_task, otherwise you throttle the entire dispatch rate.
| Parameter | Description |
|---|---|
event |
Dequeued, route-matched Event (tasks not yet created; zero-match events are also observable). |
handler_ids |
Matched handler ids; the list passed to next determines task creation order. |
next |
Invoke next middleware (or the chain end that creates tasks for the final id list). |
on_task_start(record)¶
Task-start hook (notification, optional) — fires after the task acquires the concurrency semaphore, right before handle(). On entry, record.outcome / duration_seconds are None; semaphore wait time is derivable as started_at - dispatched_at. Strictly paired with on_task_done; hook errors are isolated per middleware and never affect task execution.
on_task_done(record)¶
Task-reclaim hook (notification, optional) — fires when the task ends (success / error / timeout / cancellation / shutdown skip) with the same record instance as on_task_start, now filled in:
| Field | Description |
|---|---|
outcome |
success / error / cancelled / skipped (publish rejected during shutdown). |
duration_seconds |
Execution duration in seconds. |
error_type / error_message |
Error info (when outcome=error). |
Tasks cancelled while waiting for the semaphore (never started) do not fire — strictly paired with on_task_start. Hook errors are isolated the same way and never change the task outcome.
MiddlewareChain¶
Manages an ordered list of middlewares with lazy chain building and hot-reload support.
class MiddlewareChain:
def __init__(self) -> None
# CRUD (async — triggers lifecycle immediately when bus is running)
async def add(self, middleware: Middleware) -> 'MiddlewareChain'
async def insert(self, index: int, middleware: Middleware) -> 'MiddlewareChain'
async def remove(self, middleware: Middleware) -> None
async def clear(self) -> None
@property
def middlewares(self) -> list[Middleware]
# Chain builders
def build_before_publish(
self, final_handler: BeforePublishNext
) -> BeforePublishNext
def build_on_publish(
self, final_handler: OnPublishNext
) -> OnPublishNext
def build_on_publish_error(
self, final_handler: OnPublishErrorNext
) -> OnPublishErrorNext
# Lifecycle
async def setup(self, bus: EventBus) -> None
async def teardown(self, bus: EventBus, strict: bool = False) -> list[tuple[Middleware, Exception]]
| Method | Description |
|---|---|
add(mw) |
async. Append to the chain. Calls on_setup immediately if bus is running. If that on_setup fails, its on_teardown is fired first (side-effect rollback), it is not added, and a RuntimeError is raised. Returns self. |
insert(i, mw) |
async. Insert at position. Calls on_setup immediately if bus is running (same failure rollback as add). |
remove(mw) |
async. Remove from chain. A missing middleware is an idempotent no-op returning None (use in chain.middlewares to test membership). Calls on_teardown immediately if bus is running; its failure is returned but the removal still happens. |
clear() |
async. Remove all middlewares. Calls on_teardown on each if bus is running; returns [(middleware, exception), ...] for failures. |
middlewares |
Returns a copy of the current middleware list. |
build_before_publish(f) |
Build the before_publish chain with f as the innermost handler. |
build_on_publish(f) |
Build the on_publish chain with f as the innermost handler. |
build_on_publish_error(f) |
Build the on_publish_error chain with f as the innermost handler. |
build_dispatch(f) |
Build the before_dispatch chain; the innermost handler creates tasks for the final id list. |
build_task_start(f) |
Build the on_task_start notification chain (per-middleware error isolation, always continues). |
build_task_done(f) |
Build the on_task_done notification chain (per-middleware error isolation, always continues). |
setup(bus) |
Call on_setup on all middlewares in registration order. Atomic (fail-fast): if any on_setup raises, the chain unbinds and fires on_teardown on every middleware already attempted (including the failing one), then re-raises — middlewares stay in the chain so you can fix and retry. Consistent with handler activate. |
teardown(bus) |
Call on_teardown on all middlewares in reverse order. Idempotent. Returns [(middleware, exception), ...] so callers (e.g. EventBus.stop()) can observe incomplete cleanup; pass strict=True to raise the first failure after attempting all. |
Hot Reload¶
Middlewares can be added or removed at runtime via bus.proxy().middleware.
Changes take effect on the next publish — the chain cache is invalidated
automatically.
# Inside a handler or middleware
chain = bus_proxy.middleware
# Hot-add — on_setup is called immediately
await chain.add(AuditMiddleware())
# Hot-remove — on_teardown is called immediately
# Note: in-flight chains may still invoke the middleware after this
await chain.remove(some_mw)
# Clear all
await chain.clear()
Warning:
remove()returns immediately without waiting for in-flight hooks. Middleware authors should makeon_teardownidempotent — seeMiddleware.on_teardowndocs.
Building the Chain¶
On each publish, build_before_publish(), build_on_publish(), and
build_on_publish_error() lazily construct the chain. The same applies to
dispatch and task lifecycle: build_dispatch() (per dispatch),
build_task_start() / build_task_done() (per task). Results are cached
and auto-invalidated when the middleware list changes.
Built-in Middlewares¶
See templates/middlewares/ for detailed docs.
| Middleware | Hook | Description |
|---|---|---|
JSONLLoggingMiddleware |
on_publish |
Append events to JSONL file (zero deps). |
SQLiteLoggingMiddleware |
on_publish |
Log events to SQLite database (requires aiosqlite). |
RateLimitMiddleware |
before_publish |
Sliding-window rate limiting (global or per-event). |
EventTransformMiddleware |
before_publish |
Transform event name, data fields (rename, redact, inject). |
EventBlockMiddleware |
before_publish |
Block events matching predicates (allowlist/blocklist). |
EventForwardMiddleware |
before_publish |
Forward events to another EventBus instance. |
RecursionGuardMiddleware |
before_publish |
Prevent infinite event loops (configurable depth). |
MetricsMiddleware |
before_publish |
Lightweight Prometheus/OTel-style metrics. |
Usage Examples¶
Logging Middleware¶
Log every published event with timing:
import time
import logging
from infinity_bus import Middleware, MiddlewareChain
logger = logging.getLogger(__name__)
class LoggingMiddleware(Middleware):
async def on_setup(self, bus):
logger.info("LoggingMiddleware initialized")
async def on_teardown(self, bus):
logger.info("LoggingMiddleware shutting down")
async def before_publish(self, event_registry, name, source, data, old_event, next):
t0 = time.monotonic()
logger.debug(f"[Publish] {name} from {source}")
try:
await next(event_registry, name, source, data, old_event)
finally:
elapsed = time.monotonic() - t0
logger.debug(f"[Publish] {name} completed in {elapsed:.3f}s")
async def on_publish(self, event, next):
logger.debug(f"[Enqueued] {event.name} (id={event.id})")
await next(event)
async def on_publish_error(self, error, name, source, data):
logger.error(f"[Error] {name} from {source}: {error}")
# Register with the bus
chain = MiddlewareChain()
await chain.add(LoggingMiddleware())
bus = EventBus(reg, h_reg, middleware_chain=chain)
Validation Middleware¶
Perform extra data validation before publishing:
class ValidationMiddleware(Middleware):
async def on_setup(self, bus): pass
async def on_teardown(self, bus): pass
async def before_publish(self, event_registry, name, source, data, old_event, next):
if name == "order.create" and isinstance(data, dict):
if data.get("amount", 0) <= 0:
raise ValueError("Order amount must be positive")
await next(event_registry, name, source, data, old_event)
async def on_publish(self, event, next):
await next(event)
Short-Circuit Middleware¶
Block events under certain conditions by not calling next:
class RateLimitMiddleware(Middleware):
def __init__(self, max_per_second: int = 100):
self._max = max_per_second
self._count = 0
self._window_start = time.monotonic()
async def on_setup(self, bus): pass
async def on_teardown(self, bus): pass
async def before_publish(self, event_registry, name, source, data, old_event, next):
now = time.monotonic()
if now - self._window_start > 1.0:
self._window_start = now
self._count = 0
self._count += 1
if self._count > self._max:
logger.warning(f"Rate limit exceeded, dropping {name}")
return # Don't call next — event is dropped
await next(event_registry, name, source, data, old_event)
async def on_publish(self, event, next):
await next(event)
Multi-Middleware Onion Order¶
chain = MiddlewareChain()
await chain.add(LoggingMiddleware()) # outermost
await chain.add(ValidationMiddleware()) # middle
await chain.add(RateLimitMiddleware()) # innermost
# Execution order (on_publish chain nested inside before_publish chain):
# Logging.before enter → Validation.before enter → RateLimit.before enter
# → Core publish logic (validate → construct → enqueue)
# → Logging.on enter → Validation.on enter → RateLimit.on enter
# → no-op (on_publish chain endpoint)
# ← RateLimit.on exit ← Validation.on exit ← Logging.on exit
# ← RateLimit.before exit ← Validation.before exit ← Logging.before exit
Runtime Hot Reload¶
The middleware chain supports dynamic add/remove at runtime. Changes take effect immediately and can be triggered from within handlers or middleware via the bus proxy:
class HotReloadHandler(EventHandler):
def __init__(self):
super().__init__(subscriptions=["admin.toggle"])
async def handle(self, payload, bus_proxy, raw_event):
chain = bus_proxy.middleware
# Hot-add — on_setup is called immediately
await chain.add(AuditMiddleware())
# Hot-remove — on_teardown is called immediately
# Note: in-flight chains may still invoke the middleware after this
await chain.remove(some_mw)
# Hot-clear all
await chain.clear()
Note: Add/remove operations return immediately without waiting for in-flight hooks. After
on_teardownis called on a removed middleware, in-flightbefore_publish/on_publishmay still execute. Middleware authors should makeon_teardownidempotent.
Custom Middleware Example¶
from infinity_bus import (
BeforePublishNext, Event, EventBus, EventRegistry,
Middleware, OnPublishNext,
)
class TimingMiddleware(Middleware):
"""Logs publish duration per event type."""
async def on_setup(self, bus: EventBus) -> None:
self._bus = bus
async def on_teardown(self, bus: EventBus) -> None:
pass
async def before_publish(
self,
event_registry: EventRegistry,
name: str,
source: str,
data,
old_event: Event | None,
next: BeforePublishNext,
) -> None:
import time
t0 = time.perf_counter()
await next(event_registry, name, source, data, old_event)
elapsed = time.perf_counter() - t0
logger.info("Event %s took %.4fs", name, elapsed)
async def on_publish(
self, event: Event, next: OnPublishNext
) -> None:
await next(event)
# Usage
chain = MiddlewareChain()
await chain.add(TimingMiddleware())
bus = EventBus(events, handlers, middleware_chain=chain)