The lifespan

acemq.lifespan is where everything happens. It opens the connection, applies the topology, subscribes the registered consumers, hands control back to the application, and on the way out drains the consumers and releases the connection.

app = FastAPI(lifespan=acemq.lifespan)

What it does, in order

Startup Shutdown
1. acemq_amqp.connect(...), under acemq.connection_timeout 1. Every consumer is closed and its handlers allowed to finish
2. Publish and consume interceptors are registered 2. The connection is released
3. acemq.topology is applied, when anything was declared
4. Every @acemq.consumer is subscribed
5. app.state.acemq is set

Startup is all-or-nothing. A consumer that cannot subscribe — a queue the login has no permission on, a broker that refuses a declaration — unwinds what has already been done and lets the exception out, so the process refuses to start rather than serving routes with no consumers behind them. A half-started application is worse than one that did not start: from outside they look the same.

Keeping the lifespan you already have

FastAPI takes one lifespan. compose_lifespans nests several:

from acemq_fastapi import compose_lifespans

@asynccontextmanager
async def database(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
    pool = await open_pool()
    try:
        yield {"pool": pool}
    finally:
        await pool.close()

app = FastAPI(lifespan=compose_lifespans(database, acemq.lifespan))

Left to right is outside in. database opens first and closes last; acemq.lifespan opens last and closes first.

Put AceMQ last. Consumers should start after the things their handlers use and stop before those go away. A handler reaching for a connection pool that has already been closed is the bug this ordering exists to prevent, and it only shows up during a deployment, in a log nobody is reading.

Whatever each lifespan yields as a mapping is merged and reaches the request state, in order, so a later one can override an earlier one's key. acemq.lifespan yields {"acemq": acemq}.

Write the cleanup in a try/finally rather than on a line after the yield. When a later lifespan fails on the way up, the exception is thrown into the earlier ones at their yield — a bare line after it never runs. acemq.lifespan is written this way for exactly that reason, and there is a test that would fail if it stopped being.

Except for what needs the connection

Put AceMQ last is about the things handlers use. Something that uses the connection — an OutboxRelay, a Requester, a Scheduler, a stream reader — has the opposite requirement, because acemq.connection raises until the lifespan has opened it. Those go to the right:

@asynccontextmanager
async def relay(app: FastAPI) -> AsyncIterator[dict[str, Any]]:
    running = OutboxRelay(acemq.connection, store)
    running.start()
    try:
        yield {"relay": running}
    finally:
        await running.close()

app = FastAPI(lifespan=compose_lifespans(database, acemq.lifespan, relay))

Outside in: database, then AceMQ, then relay. The relay is built after the connection is open and closed before the connection goes away, and the handlers still have the pool either side of themselves.

The two rules collide when a handler needs one of those objects — a handler that asks another service a question needs a Requester, and nothing can be on both sides of the same yield. Patterns has the way through, which is acemq.consumer.auto_start=false and start_consumer.

Without an ASGI application at all

A worker process that consumes and serves no HTTP wants the same start-up, and should not have to construct a FastAPI application to get it:

async def main() -> None:
    await acemq.start()
    try:
        await stop_signal.wait()
    finally:
        await acemq.drain()
        await acemq.aclose()

start() takes an optional app; without one, nothing is put on app.state and the request dependencies are simply not available — which is correct, because there are no requests.

The connection is yours if you want it

AceMQ(connection_factory=...) replaces the one thing this package does that acemq-amqp already does better:

async def open_it(settings: AceMQSettings) -> Connection:
    return await acemq_amqp.connect(settings.broker_url, codec=my_codec, heartbeat=15)

acemq = AceMQ(connection_factory=open_it)

Everything else keeps working against it, because the registry, the dependencies and the health check take the connection rather than creating one. It is the same shape as the Spring starter's @ConditionalOnMissingBean AceMq: define your own and the rest of the wiring still applies.

Two applications in one process

Each gets its own. The dependencies find the integration through request.app.state.acemq, not through a module-level global, so a main application and a mounted admin one — or two applications in one test session — reach their own connection rather than whichever was built last.