Middleware¶
Middleware wraps the stages of a runtime or a Producer. A runtime runs each middleware around its startup and shutdown, each handler call, and each transaction. A producer runs it around its startup and each send.
potik ships five middleware classes:
| Middleware | Effect | How potik.build_runtime() adds it |
|---|---|---|
ExceptionMiddleware |
Retries failed handlers and writes dead letters | exception_rules= |
ConcurrencyMiddleware |
Limits concurrent handler calls | max_concurrency= |
TopicCheckMiddleware |
Fails the start if a topic is missing | On by default |
TracingMiddleware |
Traces records with OpenTelemetry | middleware= |
MetricsMiddleware |
Reports OpenTelemetry metrics | middleware= |
The examples in this section build on the quickstart's config, router, and topics.
Adding middleware¶
potik.build_runtime() builds the first three from its arguments. Pass any other middleware as middleware=, outermost first:
from potik.middleware import on, dead_letter
from potik.middleware.metrics import MetricsMiddleware
from potik.middleware.tracing import TracingMiddleware
runtime = potik.build_runtime(
config,
[router],
middleware=[TracingMiddleware(), MetricsMiddleware()],
exception_rules=[on(Exception, dead_letter(dead_letters))],
max_concurrency=20,
)
The topic check runs first, at startup. Each record then passes through middleware, the exception rules, and the concurrency limits, in that order, before it reaches its handler.
A Producer takes middleware too:
producer = Producer("broker:9092", middleware=[TracingMiddleware()])
A middleware instance serves one runtime or one producer, once. To use the same middleware in several places, create it in a function:
def observability() -> list[BaseMiddleware]:
return [TracingMiddleware(), MetricsMiddleware()]
async def main() -> None:
async with Producer("broker:9092", middleware=observability()) as producer:
await potik.build_runtime(config, [router], context=producer, middleware=observability()).run()
Order in a core runtime¶
A core Runtime runs only the middleware that you pass. Pass the built-in middleware in this order, which potik.build_runtime() also uses:
TopicCheckMiddleware, so a missing topic fails the start before other middleware open resources.TracingMiddlewareandMetricsMiddleware, so their spans and durations include retries.ExceptionMiddleware.ConcurrencyMiddleware, so a skipped record never waits for a slot, and a retry's backoff holds none.
Writing middleware¶
Subclass BaseMiddleware and override the stages you need:
| Method | Wraps |
|---|---|
lifespan(scope) |
One run, or the time a Producer is open |
handle(record, call_next) |
One handler call |
commit(transaction, call_next) |
One transaction |
send(record, call_next) |
One send through a Producer |
Each method except lifespan calls call_next to run the rest of the chain. This middleware logs slow handler calls:
import logging
import time
from collections.abc import Sequence
from potik.core import BaseMiddleware, ConsumerRecord, ProducerRecord, RecordHandler
log = logging.getLogger(__name__)
class SlowCallLog(BaseMiddleware):
def __init__(self, threshold: float) -> None:
self._threshold = threshold
async def handle(self, record: ConsumerRecord, call_next: RecordHandler) -> Sequence[ProducerRecord]:
start = time.monotonic()
try:
return await call_next(record)
finally:
if (elapsed := time.monotonic() - start) > self._threshold:
log.warning("%s offset %d took %.1fs", record.topic(), record.offset(), elapsed)
Middleware sees raw records, so it works with any handler. Pass a middleware its resources, such as a database pool, in __init__. Only handle may recover from a failure, by retrying, skipping the record, or replacing its outputs. The API reference describes each stage.