Middlewares
A middleware wraps the execution of a handler. Use it to run logic before and after the handler processes the message, or to deal with its exceptions. Middlewares are added to a bus with add_middlewares:
For commands and queries, the middleware stack wraps the single handler once. For events, the stack is applied around each handler independently, so a middleware sees one invocation per event handler.
The same middlewares can be attached to a command pipeline and to a Pump.
Generator middlewares
The simplest form is an async generator that receives the message and yields once, where the handler should run:
import time
async def timing_middleware(message):
start = time.perf_counter()
yield
print(f"Execution time: {time.perf_counter() - start}s")
The yield behaves like a try/finally around the handler call. An exception raised by the handler is thrown at the yield, so you can catch it there:
async def logging_middleware(message):
try:
yield
except Exception:
logger.exception("Failed to handle %r", message)
raise
result = yield receives the handler's return value, but only for inspection: a generator middleware cannot replace it. This keeps the message and the result read-only by default. If the middleware swallows an exception instead of re-raising it, dispatch returns the NotImplemented sentinel.
After an exception, the middleware may yield again to call the handler one more time. This is how RetryMiddleware is written. Yielding again after a successful call raises cq.exceptions.MiddlewareError.
The return annotation for these middlewares is MiddlewareResult[T], where T is the handler's return type:
from cq import MiddlewareResult
from typing import Any
async def timing_middleware(message: Any) -> MiddlewareResult[Any]:
...
yield
Classic middlewares
When you need to replace the return value, or to skip the handler and return something else instead, write a "classic" middleware. It takes call_next as its first argument and returns the value it wants to expose to the caller:
async def cache_middleware(call_next, query):
if (cached := cache.get(query)) is not None:
return cached
result = await call_next(query)
cache.set(query, result)
return result
Both styles can be mixed freely on the same bus. Middleware is the type alias that covers both.
Finding the handler
resolve_handler_source(call_next) returns the handler class behind call_next, so a classic middleware knows which handler is about to run, typically to log or trace it:
from cq import resolve_handler_source
async def logging_middleware(call_next, message):
handler_type = resolve_handler_source(call_next)
logger.info("%s handles %r", handler_type.__name__, message)
return await call_next(message)
The class also gives access to metadata declared on the handler, such as a class attribute or a marker set by your own decorator.
It only works on a bus. On a Pump or a pipeline, there is no handler behind call_next, and it raises an AttributeError.
Class-based middlewares
Both styles can also be written as a class with an async __call__ method, which is convenient when the middleware needs its own dependencies:
from dataclasses import dataclass
@dataclass
class TimingMiddleware:
metrics: MetricsService
async def __call__(self, message):
start = time.perf_counter()
yield
await self.metrics.record(time.perf_counter() - start)
@dataclass
class CacheMiddleware:
cache: Cache
async def __call__(self, call_next, query): ...
If you build these classes through your DI container (for example as parameters of the bus factory), you get the same constructor injection as for handlers.
Execution order
Middlewares nest around the handler like the layers of an onion. Within one call, they run in the order you list them: the first argument is the outermost layer, so it starts first and finishes last.
Each subsequent call wraps whatever is already registered, so the middlewares added last end up outermost:
The rule also covers the command scope that new_command_bus() installs before you add anything. Your own middlewares sit outside of it: they wrap the dispatch of every related event, but they cannot inject a dependency that only exists inside the scope, such as RelatedEvents.
Built-in middlewares
RetryMiddleware
cq.middlewares.retry.RetryMiddleware calls the handler again when it raises one of the configured exception types:
from cq.middlewares.retry import RetryMiddleware
bus.add_middlewares(RetryMiddleware(retry=3, delay=0.5, exceptions=(TimeoutError,)))
retry: total number of attempts, including the first one. Withretry=3, the handler runs at most three times. Must be at least1.delay: seconds to wait between attempts. Defaults to0.exceptions: the exception types that trigger a retry. Defaults to(Exception,).
If every attempt fails, the last exception is re-raised.
CaptureExceptionMiddleware
cq.middlewares.exc.CaptureExceptionMiddleware catches exceptions raised downstream and forwards them to a callback. Use it to log, report, or push errors to an external sink:
from cq.middlewares.exc import CaptureExceptionMiddleware
async def report(exception, message):
sentry_sdk.capture_exception(exception)
bus.add_middlewares(CaptureExceptionMiddleware(report, reraise=True))
on_error: an async callback invoked with the captured exception followed by the arguments the handler received (the message). UseCaptureExceptionMiddleware.sync(...)if your callback is synchronous.exceptions: the exception types to capture. Defaults to(Exception,).reraise: whether to re-raise the exception after the callback returns. Defaults toFalse, in which case the exception is swallowed.
on_error is meant for side effects only and must not raise. If it does, its own exception propagates in place of the original one.
Context manager middlewares
cq.middlewares.contextlib turns an existing context manager into a middleware that wraps every dispatch:
ContextManagerMiddleware(context)for a synchronous context manager;AsyncContextManagerMiddleware(context)for an asynchronous one.
from contextlib import suppress
from cq.middlewares.contextlib import ContextManagerMiddleware
bus.add_middlewares(ContextManagerMiddleware(suppress(LookupError)))
The same context manager instance is entered on every dispatch, so it must be reusable. Context managers built with @contextmanager are single-use and do not fit here.