Pluggable web frameworks and ASGI¶
Basics¶
Faust and an ASGI server can share a single process, so an HTTP endpoint can produce to a Kafka topic directly:
import faust
from fastapi import FastAPI
from faust.contrib.asgi import faust_lifespan
faust_app = faust.App("hello", broker="kafka://localhost:9092")
greetings = faust_app.topic("greetings", value_type=str)
api = FastAPI(lifespan=faust_lifespan(faust_app))
@faust_app.agent(greetings)
async def print_greetings(stream):
async for greeting in stream:
print(greeting)
@api.post("/greet")
async def greet(text: str):
await greetings.send(value=text)
return {"ok": True}
Run it with:
$ uvicorn myapp:api
Install the optional dependencies with:
$ pip install "faust-streaming[fastapi]"
Nothing in faust.contrib.asgi imports https://pypi.org/project/fastapi/ or
https://pypi.org/project/starlette/, so it works equally well with FastAPI, Starlette, Quart,
Litestar, Django ASGI, or any other ASGI callable. Use
faust-streaming[asgi] when your selected framework is already installed
and you only need the ASGI server integration.
Django and applications without a lifespan hook¶
Django exposes a standard ASGI callable through
django.core.asgi.get_asgi_application(), but it does not accept the
constructor-level lifespan handler used in the FastAPI example. Wrap that
callable in FaustLifespanMiddleware in the project’s asgi.py:
import os
from django.core.asgi import get_asgi_application
from faust.contrib.asgi import FaustLifespanMiddleware
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myproject.settings")
django_application = get_asgi_application()
from myproject.faust import app as faust_app
application = FaustLifespanMiddleware(django_application, faust_app)
Run the combined application with any ASGI server:
$ uvicorn myproject.asgi:application
The middleware implements the standard ASGI lifespan protocol. It starts
Faust before replying with lifespan.startup.complete and stops Faust
before replying with lifespan.shutdown.complete. HTTP and WebSocket
scopes pass to Django unchanged, and Faust never imports Django.
The wrapper owns the lifespan scope instead of forwarding it to the inner
application. Use faust_lifespan() or faust_app_running() when the
selected framework already has startup and shutdown work that must be
composed with Faust.
One process, one event loop¶
This is the whole reason the module exists.
A Faust app resolves its event loop the first time something asks for it, and
everything it builds afterwards – producers, consumers, timers, table
managers – belongs to that loop. An ASGI server such as https://pypi.org/project/uvicorn/
creates its own loop with asyncio.run(). If the app has already
resolved a different loop by then, every one of those objects is attached to a
loop that will never run, and you get:
AssertionError: Please create objects with the same loop as running with
RuntimeError: Task ... got Future ... attached to a different loop
faust_lifespan() and faust_app_running() bind the app to the loop
that is actually running before starting it, so this cannot happen.
Do not touch app.loop at import time
Declare the app, its topics, agents and tables at module scope as usual – that is supported and is what the examples do. What you must not do is ask the app for its event loop before the server starts. In practice that means avoiding, at module scope:
app.loopapp.web,app.transport,app.producerpassing your own loop to
faust.App(loop=...)
If the app is already bound to another loop,
bind_to_running_loop() raises LoopMismatch with a message
naming the likely cause, rather than letting it fail later and less
legibly.
Composing with your own lifespan¶
When you have setup of your own, use faust_app_running() – it is the
context manager faust_lifespan() is built from:
from contextlib import asynccontextmanager
from faust.contrib.asgi import faust_app_running
@asynccontextmanager
async def lifespan(api: FastAPI):
async with faust_app_running(faust_app):
ml_models["answer"] = load_model()
yield
ml_models.clear()
api = FastAPI(lifespan=lifespan)
The app is started with maybe_start(), so this composes safely with an app
that is already running, and will not stop one it did not start.
Running under faust worker¶
The other direction: keep faust worker as your entry point and let it serve
your ASGI application on its own loop.
from faust.contrib.asgi import serve_asgi
api = FastAPI()
serve_asgi(faust_app, api)
$ faust -A myapp worker -l info
serve_asgi() makes the ASGI application the worker’s web application. It
replaces the legacy aiohttp server, starts only after table recovery has
finished, and obeys web_enabled and --without-web. By default it
binds to web_bind and web_port; extra keyword arguments
are passed through to uvicorn.Config.
faust -A looks for app
The -A option imports the module and looks for an attribute named
app. Bind that name to the Faust app, not to the FastAPI
object:
app = faust_app = faust.App("myapp", ...)
api = FastAPI()
Naming the FastAPI object app – the FastAPI convention – is what
makes faust -A fail to find your app.
Replacing faust.web¶
Faust keeps its aiohttp-based faust.web stack as the compatibility
default, but applications are no longer required to use it. Registering a web
server service replaces that stack completely:
from mode import Service
@faust_app.web_server
class MyFrameworkServer(Service):
async def on_start(self):
# Start Sanic, Tornado, or another framework here.
...
async def on_stop(self):
...
Faust only owns the service lifecycle. The service owns its framework,
application object, routes, and HTTP server. It starts after table recovery,
uses the same event loop as the worker, and is disabled by the same
web_enabled switch as the compatibility server.
serve_asgi() is the ready-made implementation of this hook for ASGI.
Because the framework now owns routing, @app.page, @app.table_route,
and Faust’s built-in aiohttp endpoints are not mounted when a custom server is
registered. Define their replacements in your chosen framework. The new
performance payload is framework-neutral, so it can be exposed directly:
from faust.sensors.metrics import performance_metrics
@api.get("/performance")
async def performance():
return performance_metrics(faust_app)
OpenTelemetry¶
$ pip install "faust-streaming[opentelemetry]"
With the instrumentation packages installed, most of a distributed trace works
already: https://pypi.org/project/opentelemetry-instrumentation-aiokafka/ wraps the same
AIOKafkaProducer.send and AIOKafkaConsumer.getmany calls that Faust’s
driver uses, so an HTTP request that produces to Kafka carries its trace
context onto the wire in a traceparent header.
Two things Faust adds:
Your FastAPI application is instrumented automatically.
faust_lifespan() and serve_asgi() attach
FastAPIInstrumentor when OpenTelemetry is installed and the application
has configured a real TracerProvider. Until an SDK is configured the
OpenTelemetry API is a no-op, so nothing is enabled behind your back. Pass
opentelemetry=False to opt out, or True to force it. Applications
already instrumented (for example under opentelemetry-instrument) are left
alone rather than double-wrapped.
The consumer-to-agent hop is bridged. Faust’s consumer runs in its own
thread, and contextvars do not cross threads – so the receive span
created inside getmany is closed before your agent ever runs, leaving the
agent’s work unparented. Register the sensor to close that gap:
from faust.contrib.opentelemetry import setup_opentelemetry
setup_opentelemetry(app)
It extracts the trace context from each message’s headers and opens a
{topic} process span that stays current for as long as the stream is
processing the event, so everything the agent does nests underneath it. The
resulting trace reads:
FastAPI server span
└─ {topic} send (PRODUCER, from opentelemetry-instrumentation-aiokafka)
└─ {topic} process (CONSUMER, from faust.contrib.opentelemetry)
└─ ...whatever your agent does
Do not run this alongside the opentracing sensor
faust.sensors.distributed_tracing.TracingSensor also injects trace
headers into Kafka messages. Running both puts two traceparent headers
on the wire. setup_opentelemetry() warns if it sees one registered.
Note also that the OpenTracing bridge was deprecated upstream in March
2026; new work should target OpenTelemetry directly.
Examples¶
Two complete examples ship with Faust:
examples/fastapi_example.pyA single file –
hello_world.pyplus a FastAPI application.examples/fastapi_project/The same thing as a package, with agents, tables, timers and routers in their own modules.
main.pyis served by uvicorn;worker_main.pyshows the same API served from insidefaust worker.
Caveats¶
A topic must exist when the worker starts for its agent to consume from it. This bites co-hosted apps particularly often, because they typically produce to and consume from the same topic: on a first run against a fresh cluster the topic does not exist yet, the agent subscribes to nothing, and messages produced by your endpoints are written but never processed until the process is restarted. Nothing is lost – the backlog is picked up on the next start – but the first run looks like the agent is broken.
This is not specific to co-hosting;
faust workerbehaves the same way. Create your topics ahead of time (or restart once) when bootstrapping a new environment.producer_threaded=Truespawns a producer with its own thread and loop. It is untested in a co-hosted process and is not supported here yet.uvicorn --reloadand--workersfork or re-exec the process. Faust is not designed to be forked; use a single worker.