acemq_fastapi.integration

The object an application holds, and the lifespan that owns the connection.

One AceMQ per application. It is built at import time, collects consumer registrations from decorators, and does nothing at all until its lifespan opens — which is the point: a module that connects to a broker when it is imported is a module that cannot be imported by a test, a migration script or --help.

  1# Copyright 2026 AceMQ.
  2#
  3# Licensed under the Apache License, Version 2.0 (the "License");
  4# you may not use this file except in compliance with the License.
  5# You may obtain a copy of the License at
  6#
  7#     https://www.apache.org/licenses/LICENSE-2.0
  8#
  9# Unless required by applicable law or agreed to in writing, software
 10# distributed under the License is distributed on an "AS IS" BASIS,
 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 12# See the License for the specific language governing permissions and
 13# limitations under the License.
 14
 15"""The object an application holds, and the lifespan that owns the connection.
 16
 17One :class:`AceMQ` per application. It is built at import time, collects consumer
 18registrations from decorators, and does nothing at all until its lifespan opens —
 19which is the point: a module that connects to a broker when it is imported is a
 20module that cannot be imported by a test, a migration script or ``--help``.
 21"""
 22
 23from __future__ import annotations
 24
 25import asyncio
 26import logging
 27from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
 28from contextlib import asynccontextmanager, suppress
 29from dataclasses import dataclass, field
 30from typing import TYPE_CHECKING, Any, TypeVar
 31
 32import acemq_amqp
 33from acemq_amqp import (
 34    AceMQError,
 35    Ack,  # noqa: F401 -- see below
 36    Codec,
 37    Connection,
 38    ConsumeInterceptor,
 39    Consumer,
 40    Handler,
 41    Observer,
 42    Publisher,
 43    PublishInterceptor,
 44    RetryPolicy,
 45)
 46
 47from .settings import AceMQSettings
 48
 49if TYPE_CHECKING:  # pragma: no cover - typing only
 50    from fastapi import FastAPI
 51
 52    from .health import AceMQHealth
 53
 54__all__ = ["AceMQ", "ConsumerRegistration", "DrainReport"]
 55
 56log = logging.getLogger("acemq_fastapi")
 57
 58# ``Ack`` is imported but not used here, and that is on purpose. The library's
 59# ``Handler`` alias is ``Callable[[Message], "Ack | Awaitable[Ack]"]`` — a forward
 60# reference — and anything resolving the annotations of a function in this module
 61# evaluates that string against *this* module's globals. Without the name here,
 62# pdoc renders the registry's signatures as unresolved and a reader of the API
 63# reference sees a handler type that says nothing.
 64
 65#: What builds the connection. Replaceable so a test can hand over a connection
 66#: on a fake transport, and so an application that needs a connection this
 67#: package does not model — an unusual ``transport_options``, a codec built at
 68#: runtime — can build its own and keep everything else.
 69ConnectionFactory = Callable[[AceMQSettings], Awaitable[Connection]]
 70
 71#: Bound to :data:`acemq_amqp.Handler` so the decorator gives back the function it
 72#: was handed, with its own signature: a handler stays as callable, and as typed,
 73#: after decoration as it was before.
 74HandlerT = TypeVar("HandlerT", bound=Handler)
 75
 76
 77@dataclass(frozen=True, slots=True)
 78class ConsumerRegistration:
 79    """One ``@acemq.consumer`` declaration, before anything is running.
 80
 81    ``None`` on the per-consumer settings means "whatever ``acemq.consumer.*``
 82    says", resolved when the lifespan opens rather than when the decorator runs,
 83    so settings read from the environment still reach a consumer registered at
 84    import time.
 85    """
 86
 87    #: What this consumer is called to :meth:`AceMQ.consumers`. Defaults to the
 88    #: queue name, and has to be unique because that is how it is addressed.
 89    name: str
 90    queue: str
 91    handler: Handler
 92    codec: Codec | None = None
 93    retry: RetryPolicy | None = None
 94    prefetch: int | None = None
 95    concurrency: int | None = None
 96    declare: bool | None = None
 97    #: What to call this consumer to the broker. Empty lets the broker name it.
 98    tag: str = ""
 99    args: Mapping[str, Any] = field(default_factory=dict)
100
101
102@dataclass(frozen=True, slots=True)
103class DrainReport:
104    """What a shutdown got through.
105
106    Returned by :meth:`AceMQ.drain` and logged by the lifespan. ``finished``
107    false means the deadline expired: the handlers still running were cancelled
108    and their messages left unsettled, so the broker will hand them to whoever
109    replaces this process. Nothing is lost; some work may be done twice.
110    """
111
112    finished: bool
113    #: Seconds spent draining.
114    waited: float
115    #: How many consumers were asked to stop.
116    consumers: int
117    #: How many messages were still in a handler when the deadline expired.
118    abandoned: int = 0
119
120
121class AceMQ:
122    """The connection, the consumers and the settings, for one application.
123
124    ::
125
126        acemq = AceMQ()
127
128        @acemq.consumer("orders")
129        async def handle(message: Message) -> Ack:
130            ...
131            return accept()
132
133        app = FastAPI(lifespan=acemq.lifespan)
134
135    :param settings: what to connect to and how. Read from the environment when
136        not given
137    :param connection_factory: what opens the connection. Replaceable for tests,
138        and for an application that builds its own
139    :param on_publish: interceptors wrapping every publish, outermost first
140    :param on_consume: interceptors wrapping every handler
141    :param observer: where the library's numbers go
142    """
143
144    def __init__(
145        self,
146        settings: AceMQSettings | None = None,
147        *,
148        connection_factory: ConnectionFactory | None = None,
149        on_publish: tuple[PublishInterceptor, ...] = (),
150        on_consume: tuple[ConsumeInterceptor, ...] = (),
151        observer: Observer | None = None,
152    ) -> None:
153        self._settings = settings if settings is not None else AceMQSettings()
154        self._connect: ConnectionFactory = connection_factory or self._open
155        self._on_publish = tuple(on_publish)
156        self._on_consume = tuple(on_consume)
157        self._observer = observer
158        self._registrations: dict[str, ConsumerRegistration] = {}
159        self._consumers: dict[str, Consumer] = {}
160        self._publishers: dict[tuple[Any, ...], Publisher] = {}
161        self._connection: Connection | None = None
162        self._lock = asyncio.Lock()
163
164    # ------------------------------------------------------------------ state
165
166    @property
167    def settings(self) -> AceMQSettings:
168        """What this integration was configured with."""
169        return self._settings
170
171    @property
172    def started(self) -> bool:
173        """Whether the connection is open."""
174        return self._connection is not None
175
176    @property
177    def connection(self) -> Connection:
178        """The open connection.
179
180        :raises AceMQError: before the lifespan has opened it, because the
181            alternative is a ``None`` that turns into an ``AttributeError``
182            three frames away from the mistake
183        """
184        if self._connection is None:
185            raise AceMQError(
186                "acemq-fastapi: there is no connection yet. It is opened by the lifespan, "
187                "so this is either a call made before the application started or an "
188                "application built without lifespan=acemq.lifespan"
189            )
190        return self._connection
191
192    @property
193    def registrations(self) -> tuple[ConsumerRegistration, ...]:
194        """Every ``@acemq.consumer`` declaration, in the order they were made."""
195        return tuple(self._registrations.values())
196
197    @property
198    def consumers(self) -> Mapping[str, Consumer]:
199        """The running consumers by name, for scaling, pausing and counters."""
200        return dict(self._consumers)
201
202    # -------------------------------------------------------------- consumers
203
204    def consumer(
205        self,
206        queue: str,
207        *,
208        name: str | None = None,
209        codec: Codec | None = None,
210        retry: RetryPolicy | None = None,
211        prefetch: int | None = None,
212        concurrency: int | None = None,
213        declare: bool | None = None,
214        tag: str = "",
215        args: Mapping[str, Any] | None = None,
216    ) -> Callable[[HandlerT], HandlerT]:
217        """Registers a handler for a queue. The Python analogue of ``@AceListener``.
218
219        The decorated function is returned unchanged, so it stays an ordinary
220        function that a test can call with a :class:`~acemq_amqp.Message` and
221        assert on, without a broker anywhere near it.
222
223        Nothing subscribes here. The registration is held until the lifespan
224        opens, which is what puts the handler on the application's own event loop
225        rather than on whichever loop happened to import the module.
226
227        :param queue: what to read
228        :param name: what to call this consumer in :attr:`consumers`. Defaults to
229            the queue
230        :param codec: a codec other than the connection's
231        :param retry: a policy other than ``acemq.consumer.retry``
232        :param prefetch: how many unacknowledged messages to hold
233        :param concurrency: how many messages to work on at once
234        :param declare: declare the queues a failure needs before subscribing
235        :param tag: what to call this consumer to the broker
236        :param args: broker-specific consumer arguments
237        :returns: the decorator
238        :raises AceMQError: when the name is already taken, or the lifespan has
239            already opened
240        """
241
242        def register(handler: HandlerT) -> HandlerT:
243            self.add_consumer(
244                queue,
245                handler,
246                name=name,
247                codec=codec,
248                retry=retry,
249                prefetch=prefetch,
250                concurrency=concurrency,
251                declare=declare,
252                tag=tag,
253                args=args,
254            )
255            return handler
256
257        return register
258
259    def add_consumer(
260        self,
261        queue: str,
262        handler: Handler,
263        *,
264        name: str | None = None,
265        codec: Codec | None = None,
266        retry: RetryPolicy | None = None,
267        prefetch: int | None = None,
268        concurrency: int | None = None,
269        declare: bool | None = None,
270        tag: str = "",
271        args: Mapping[str, Any] | None = None,
272    ) -> ConsumerRegistration:
273        """Registers a handler without the decorator.
274
275        What a factory function or a plugin uses. Same arguments as
276        :meth:`consumer`, and the same restriction: before the lifespan opens.
277        """
278        if self._connection is not None:
279            raise AceMQError(
280                f"acemq-fastapi: cannot register a consumer for {queue!r} now — the "
281                "lifespan has already opened. Register consumers while the module is "
282                "being imported, which is what the decorator is for"
283            )
284        chosen = name or queue
285        if chosen in self._registrations:
286            raise AceMQError(
287                f"acemq-fastapi: there is already a consumer called {chosen!r}. Two "
288                "consumers on one queue is a reasonable thing to want; give one of them "
289                "a name=... so they can be told apart"
290            )
291        registration = ConsumerRegistration(
292            name=chosen,
293            queue=queue,
294            handler=handler,
295            codec=codec,
296            retry=retry,
297            prefetch=prefetch,
298            concurrency=concurrency,
299            declare=declare,
300            tag=tag,
301            args=dict(args or {}),
302        )
303        self._registrations[chosen] = registration
304        return registration
305
306    async def start_consumer(self, name: str) -> Consumer:
307        """Subscribes one registered consumer that is not running.
308
309        What :attr:`consumers` is for in the other direction: a consumer stopped
310        for back pressure, or one left registered by
311        ``acemq.consumer.auto_start=false``, is started again by name.
312        """
313        registration = self._registrations.get(name)
314        if registration is None:
315            known = ", ".join(sorted(self._registrations)) or "none"
316            raise AceMQError(
317                f"acemq-fastapi: no consumer is registered as {name!r}; registered: {known}"
318            )
319        if name in self._consumers:
320            return self._consumers[name]
321        consumer = await self._subscribe(registration)
322        self._consumers[name] = consumer
323        return consumer
324
325    async def stop_consumer(self, name: str) -> None:
326        """Drains and closes one consumer, leaving it registered.
327
328        It finishes what its handlers are holding, exactly as a shutdown does.
329        """
330        consumer = self._consumers.pop(name, None)
331        if consumer is not None:
332            await consumer.close()
333
334    # ------------------------------------------------------------- publishing
335
336    def publisher(
337        self,
338        exchange: str = "",
339        routing_key: str = "",
340        *,
341        codec: Codec | None = None,
342        persistent: bool = True,
343        mandatory: bool = False,
344    ) -> Publisher:
345        """A publisher for a destination, built once and reused.
346
347        Publishers are cheap to keep and safe to share, and building one per
348        request is a small waste repeated a lot. This caches by every argument
349        that changes what a publisher does, so two routes asking for the same
350        destination get the same object and a route asking for a different one
351        does not quietly get somebody else's.
352
353        :raises AceMQError: before the lifespan has opened the connection
354        """
355        key = (exchange, routing_key, id(codec) if codec else None, persistent, mandatory)
356        publisher = self._publishers.get(key)
357        if publisher is None:
358            publisher = self.connection.publisher(
359                exchange,
360                routing_key,
361                codec=codec,
362                persistent=persistent,
363                mandatory=mandatory,
364            )
365            self._publishers[key] = publisher
366        return publisher
367
368    # ----------------------------------------------------------------- health
369
370    def health_check(self) -> AceMQHealth:
371        """This connection as a :class:`~acemq_amqp.HealthCheck`.
372
373        Pass it to :func:`acemq_amqp.aggregate_health` beside an application's
374        own checks, or mount :meth:`health_router` and let it answer on its own.
375        """
376        from .health import AceMQHealth
377
378        return AceMQHealth(self)
379
380    async def health(self) -> acemq_amqp.HealthReport:
381        """What a readiness probe should see. See :class:`~acemq_fastapi.AceMQHealth`."""
382        return await self.health_check().check()
383
384    def health_router(self, *, path: str | None = None, tags: list[Any] | None = None) -> Any:
385        """An ``APIRouter`` with one route reporting on the connection.
386
387        ``app.include_router(acemq.health_router())`` and nothing else. Mounting
388        it is explicit rather than automatic because a route that appears in an
389        application's OpenAPI document without anybody adding it is a route
390        somebody has to go looking for.
391        """
392        from .health import health_router
393
394        return health_router(self, path=path, tags=tags)
395
396    # --------------------------------------------------------------- lifespan
397
398    @asynccontextmanager
399    async def lifespan(self, app: FastAPI | None = None) -> AsyncIterator[Mapping[str, Any]]:
400        """Opens the connection and the consumers, and drains them on the way out.
401
402        ``FastAPI(lifespan=acemq.lifespan)``. To keep an application's own
403        lifespan as well, see :func:`acemq_fastapi.compose_lifespans` — this one
404        belongs innermost, so the consumers start after whatever they depend on
405        and drain before it goes away.
406
407        The ``app`` is optional so the same context manager can be used in a test
408        or a worker with no ASGI application at all. When one is given it gets
409        ``app.state.acemq``, which is how the request dependencies find this
410        object without a module-level global.
411        """
412        started = await self.start(app)
413        try:
414            yield {"acemq": self}
415        finally:
416            report = await self.drain()
417            if not report.finished:
418                log.warning(
419                    "acemq-fastapi: the drain did not finish in %s; %d message(s) were "
420                    "left unsettled and the broker will redeliver them",
421                    self._settings.consumer.shutdown_timeout,
422                    report.abandoned,
423                )
424            await self.aclose()
425            log.info(
426                "acemq-fastapi: closed after %.3fs, %d consumer(s) drained",
427                report.waited,
428                started,
429            )
430
431    async def start(self, app: FastAPI | None = None) -> int:
432        """Connects, applies the topology and subscribes the consumers.
433
434        Called by :meth:`lifespan`. Public because a worker process that serves no
435        HTTP at all still wants the same start-up, and should not have to
436        construct an ASGI application to get it.
437
438        :returns: how many consumers were started
439        """
440        async with self._lock:
441            if self._connection is not None:
442                raise AceMQError("acemq-fastapi: this integration is already started")
443            timeout = self._settings.connection_timeout.total_seconds()
444            try:
445                connection = await asyncio.wait_for(self._connect(self._settings), timeout)
446            except asyncio.TimeoutError as expired:
447                raise AceMQError(
448                    f"acemq-fastapi: the broker at {self._settings.broker_url} did not "
449                    f"answer within {timeout:g}s"
450                ) from expired
451            self._connection = connection
452
453        try:
454            for publishing in self._on_publish:
455                connection.intercept_publish(publishing)
456            for consuming in self._on_consume:
457                connection.intercept_consume(consuming)
458
459            topology = self._settings.build_topology()
460            if topology is not None:
461                await connection.declare(topology)
462
463            if self._settings.consumer.auto_start:
464                for registration in self._registrations.values():
465                    self._consumers[registration.name] = await self._subscribe(registration)
466        except BaseException:
467            # A half-started application is worse than one that refused to start:
468            # the routes would be up, the consumers would not, and nothing outside
469            # could tell. Undo what was done and let the failure out.
470            await self.drain()
471            await self.aclose()
472            raise
473
474        if app is not None:
475            app.state.acemq = self
476        log.info(
477            "acemq-fastapi: connected to %s, %d consumer(s) running",
478            self._settings.broker_url,
479            len(self._consumers),
480        )
481        return len(self._consumers)
482
483    async def drain(self) -> DrainReport:
484        """Stops every consumer, finishing the handlers already running.
485
486        What this finishes, and what it gives back, is written down in the README
487        under *What a drain finishes*. In short: a handler in flight runs to
488        completion; a delivery fetched but not yet started is handed back to the
489        broker; a retry waiting out a short backoff in this process is waited out
490        in full; a publish waiting for its confirm is not waited for.
491
492        Bounded by ``acemq.consumer.shutdown_timeout``. When that expires the
493        handlers still running are cancelled and their messages left unsettled —
494        the broker redelivers them, so the work may be done twice and nothing is
495        dropped.
496        """
497        if self._connection is None or not self._consumers:
498            self._consumers.clear()
499            return DrainReport(finished=True, waited=0.0, consumers=0)
500
501        consumers = tuple(self._consumers.values())
502        self._consumers.clear()
503        deadline = self._settings.consumer.shutdown_timeout.total_seconds()
504        loop = asyncio.get_running_loop()
505        started = loop.time()
506
507        closing = [asyncio.create_task(consumer.close()) for consumer in consumers]
508        done, pending = await asyncio.wait(closing, timeout=deadline)
509        waited = loop.time() - started
510
511        if not pending:
512            for task in done:
513                if task.cancelled():
514                    # Its workers were already gone — cancelled from outside, or
515                    # killed by something the library logged. Nothing here can
516                    # finish a handler that is not running any more.
517                    log.warning(
518                        "acemq-fastapi: a consumer's workers had already stopped; "
519                        "whatever they were holding is back with the broker"
520                    )
521                elif task.exception() is not None:
522                    log.warning(
523                        "acemq-fastapi: a consumer failed to close: %s", task.exception()
524                    )
525            return DrainReport(finished=True, waited=waited, consumers=len(consumers))
526
527        # Out of time. Cancelling is the only lever there is, and it is pulled
528        # last rather than first: it aborts the handlers still running and leaves
529        # their deliveries unsettled for the broker to hand on. That is the price
530        # of a deadline, and it is cheaper than being SIGKILLed with the same
531        # messages unsettled and the socket gone.
532        abandoned = sum(consumer.in_flight for consumer in consumers)
533        for task in pending:
534            task.cancel()
535        await asyncio.gather(*pending, return_exceptions=True)
536        return DrainReport(
537            finished=False,
538            waited=loop.time() - started,
539            consumers=len(consumers),
540            abandoned=abandoned,
541        )
542
543    async def aclose(self) -> None:
544        """Releases the connection. Idempotent, and safe after a failed start."""
545        connection = self._connection
546        self._connection = None
547        self._publishers.clear()
548        self._consumers.clear()
549        if connection is not None:
550            with suppress(Exception):
551                await connection.close()
552
553    # ---------------------------------------------------------------- private
554
555    async def _subscribe(self, registration: ConsumerRegistration) -> Consumer:
556        defaults = self._settings.consumer
557        return await self.connection.consume(
558            registration.queue,
559            registration.handler,
560            codec=registration.codec,
561            retry=registration.retry or self._settings.build_retry(),
562            prefetch=registration.prefetch
563            if registration.prefetch is not None
564            else defaults.prefetch,
565            concurrency=registration.concurrency
566            if registration.concurrency is not None
567            else defaults.concurrency,
568            tag=registration.tag,
569            args=registration.args,
570            declare=(
571                registration.declare if registration.declare is not None else defaults.declare
572            ),
573        )
574
575    async def _open(self, settings: AceMQSettings) -> Connection:
576        """The default factory: :func:`acemq_amqp.connect` with the settings applied."""
577        return await acemq_amqp.connect(
578            settings.broker_url,
579            codec=settings.build_codec(),
580            origin=settings.client_name,
581            retry=settings.build_retry(),
582            prefetch=settings.prefetch,
583            max_outstanding_publishes=settings.max_outstanding_publishes,
584            confirm_timeout=settings.confirm_timeout,
585            security=settings.build_security(),
586            observer=self._observer,
587        )
class AceMQ:
122class AceMQ:
123    """The connection, the consumers and the settings, for one application.
124
125    ::
126
127        acemq = AceMQ()
128
129        @acemq.consumer("orders")
130        async def handle(message: Message) -> Ack:
131            ...
132            return accept()
133
134        app = FastAPI(lifespan=acemq.lifespan)
135
136    :param settings: what to connect to and how. Read from the environment when
137        not given
138    :param connection_factory: what opens the connection. Replaceable for tests,
139        and for an application that builds its own
140    :param on_publish: interceptors wrapping every publish, outermost first
141    :param on_consume: interceptors wrapping every handler
142    :param observer: where the library's numbers go
143    """
144
145    def __init__(
146        self,
147        settings: AceMQSettings | None = None,
148        *,
149        connection_factory: ConnectionFactory | None = None,
150        on_publish: tuple[PublishInterceptor, ...] = (),
151        on_consume: tuple[ConsumeInterceptor, ...] = (),
152        observer: Observer | None = None,
153    ) -> None:
154        self._settings = settings if settings is not None else AceMQSettings()
155        self._connect: ConnectionFactory = connection_factory or self._open
156        self._on_publish = tuple(on_publish)
157        self._on_consume = tuple(on_consume)
158        self._observer = observer
159        self._registrations: dict[str, ConsumerRegistration] = {}
160        self._consumers: dict[str, Consumer] = {}
161        self._publishers: dict[tuple[Any, ...], Publisher] = {}
162        self._connection: Connection | None = None
163        self._lock = asyncio.Lock()
164
165    # ------------------------------------------------------------------ state
166
167    @property
168    def settings(self) -> AceMQSettings:
169        """What this integration was configured with."""
170        return self._settings
171
172    @property
173    def started(self) -> bool:
174        """Whether the connection is open."""
175        return self._connection is not None
176
177    @property
178    def connection(self) -> Connection:
179        """The open connection.
180
181        :raises AceMQError: before the lifespan has opened it, because the
182            alternative is a ``None`` that turns into an ``AttributeError``
183            three frames away from the mistake
184        """
185        if self._connection is None:
186            raise AceMQError(
187                "acemq-fastapi: there is no connection yet. It is opened by the lifespan, "
188                "so this is either a call made before the application started or an "
189                "application built without lifespan=acemq.lifespan"
190            )
191        return self._connection
192
193    @property
194    def registrations(self) -> tuple[ConsumerRegistration, ...]:
195        """Every ``@acemq.consumer`` declaration, in the order they were made."""
196        return tuple(self._registrations.values())
197
198    @property
199    def consumers(self) -> Mapping[str, Consumer]:
200        """The running consumers by name, for scaling, pausing and counters."""
201        return dict(self._consumers)
202
203    # -------------------------------------------------------------- consumers
204
205    def consumer(
206        self,
207        queue: str,
208        *,
209        name: str | None = None,
210        codec: Codec | None = None,
211        retry: RetryPolicy | None = None,
212        prefetch: int | None = None,
213        concurrency: int | None = None,
214        declare: bool | None = None,
215        tag: str = "",
216        args: Mapping[str, Any] | None = None,
217    ) -> Callable[[HandlerT], HandlerT]:
218        """Registers a handler for a queue. The Python analogue of ``@AceListener``.
219
220        The decorated function is returned unchanged, so it stays an ordinary
221        function that a test can call with a :class:`~acemq_amqp.Message` and
222        assert on, without a broker anywhere near it.
223
224        Nothing subscribes here. The registration is held until the lifespan
225        opens, which is what puts the handler on the application's own event loop
226        rather than on whichever loop happened to import the module.
227
228        :param queue: what to read
229        :param name: what to call this consumer in :attr:`consumers`. Defaults to
230            the queue
231        :param codec: a codec other than the connection's
232        :param retry: a policy other than ``acemq.consumer.retry``
233        :param prefetch: how many unacknowledged messages to hold
234        :param concurrency: how many messages to work on at once
235        :param declare: declare the queues a failure needs before subscribing
236        :param tag: what to call this consumer to the broker
237        :param args: broker-specific consumer arguments
238        :returns: the decorator
239        :raises AceMQError: when the name is already taken, or the lifespan has
240            already opened
241        """
242
243        def register(handler: HandlerT) -> HandlerT:
244            self.add_consumer(
245                queue,
246                handler,
247                name=name,
248                codec=codec,
249                retry=retry,
250                prefetch=prefetch,
251                concurrency=concurrency,
252                declare=declare,
253                tag=tag,
254                args=args,
255            )
256            return handler
257
258        return register
259
260    def add_consumer(
261        self,
262        queue: str,
263        handler: Handler,
264        *,
265        name: str | None = None,
266        codec: Codec | None = None,
267        retry: RetryPolicy | None = None,
268        prefetch: int | None = None,
269        concurrency: int | None = None,
270        declare: bool | None = None,
271        tag: str = "",
272        args: Mapping[str, Any] | None = None,
273    ) -> ConsumerRegistration:
274        """Registers a handler without the decorator.
275
276        What a factory function or a plugin uses. Same arguments as
277        :meth:`consumer`, and the same restriction: before the lifespan opens.
278        """
279        if self._connection is not None:
280            raise AceMQError(
281                f"acemq-fastapi: cannot register a consumer for {queue!r} now — the "
282                "lifespan has already opened. Register consumers while the module is "
283                "being imported, which is what the decorator is for"
284            )
285        chosen = name or queue
286        if chosen in self._registrations:
287            raise AceMQError(
288                f"acemq-fastapi: there is already a consumer called {chosen!r}. Two "
289                "consumers on one queue is a reasonable thing to want; give one of them "
290                "a name=... so they can be told apart"
291            )
292        registration = ConsumerRegistration(
293            name=chosen,
294            queue=queue,
295            handler=handler,
296            codec=codec,
297            retry=retry,
298            prefetch=prefetch,
299            concurrency=concurrency,
300            declare=declare,
301            tag=tag,
302            args=dict(args or {}),
303        )
304        self._registrations[chosen] = registration
305        return registration
306
307    async def start_consumer(self, name: str) -> Consumer:
308        """Subscribes one registered consumer that is not running.
309
310        What :attr:`consumers` is for in the other direction: a consumer stopped
311        for back pressure, or one left registered by
312        ``acemq.consumer.auto_start=false``, is started again by name.
313        """
314        registration = self._registrations.get(name)
315        if registration is None:
316            known = ", ".join(sorted(self._registrations)) or "none"
317            raise AceMQError(
318                f"acemq-fastapi: no consumer is registered as {name!r}; registered: {known}"
319            )
320        if name in self._consumers:
321            return self._consumers[name]
322        consumer = await self._subscribe(registration)
323        self._consumers[name] = consumer
324        return consumer
325
326    async def stop_consumer(self, name: str) -> None:
327        """Drains and closes one consumer, leaving it registered.
328
329        It finishes what its handlers are holding, exactly as a shutdown does.
330        """
331        consumer = self._consumers.pop(name, None)
332        if consumer is not None:
333            await consumer.close()
334
335    # ------------------------------------------------------------- publishing
336
337    def publisher(
338        self,
339        exchange: str = "",
340        routing_key: str = "",
341        *,
342        codec: Codec | None = None,
343        persistent: bool = True,
344        mandatory: bool = False,
345    ) -> Publisher:
346        """A publisher for a destination, built once and reused.
347
348        Publishers are cheap to keep and safe to share, and building one per
349        request is a small waste repeated a lot. This caches by every argument
350        that changes what a publisher does, so two routes asking for the same
351        destination get the same object and a route asking for a different one
352        does not quietly get somebody else's.
353
354        :raises AceMQError: before the lifespan has opened the connection
355        """
356        key = (exchange, routing_key, id(codec) if codec else None, persistent, mandatory)
357        publisher = self._publishers.get(key)
358        if publisher is None:
359            publisher = self.connection.publisher(
360                exchange,
361                routing_key,
362                codec=codec,
363                persistent=persistent,
364                mandatory=mandatory,
365            )
366            self._publishers[key] = publisher
367        return publisher
368
369    # ----------------------------------------------------------------- health
370
371    def health_check(self) -> AceMQHealth:
372        """This connection as a :class:`~acemq_amqp.HealthCheck`.
373
374        Pass it to :func:`acemq_amqp.aggregate_health` beside an application's
375        own checks, or mount :meth:`health_router` and let it answer on its own.
376        """
377        from .health import AceMQHealth
378
379        return AceMQHealth(self)
380
381    async def health(self) -> acemq_amqp.HealthReport:
382        """What a readiness probe should see. See :class:`~acemq_fastapi.AceMQHealth`."""
383        return await self.health_check().check()
384
385    def health_router(self, *, path: str | None = None, tags: list[Any] | None = None) -> Any:
386        """An ``APIRouter`` with one route reporting on the connection.
387
388        ``app.include_router(acemq.health_router())`` and nothing else. Mounting
389        it is explicit rather than automatic because a route that appears in an
390        application's OpenAPI document without anybody adding it is a route
391        somebody has to go looking for.
392        """
393        from .health import health_router
394
395        return health_router(self, path=path, tags=tags)
396
397    # --------------------------------------------------------------- lifespan
398
399    @asynccontextmanager
400    async def lifespan(self, app: FastAPI | None = None) -> AsyncIterator[Mapping[str, Any]]:
401        """Opens the connection and the consumers, and drains them on the way out.
402
403        ``FastAPI(lifespan=acemq.lifespan)``. To keep an application's own
404        lifespan as well, see :func:`acemq_fastapi.compose_lifespans` — this one
405        belongs innermost, so the consumers start after whatever they depend on
406        and drain before it goes away.
407
408        The ``app`` is optional so the same context manager can be used in a test
409        or a worker with no ASGI application at all. When one is given it gets
410        ``app.state.acemq``, which is how the request dependencies find this
411        object without a module-level global.
412        """
413        started = await self.start(app)
414        try:
415            yield {"acemq": self}
416        finally:
417            report = await self.drain()
418            if not report.finished:
419                log.warning(
420                    "acemq-fastapi: the drain did not finish in %s; %d message(s) were "
421                    "left unsettled and the broker will redeliver them",
422                    self._settings.consumer.shutdown_timeout,
423                    report.abandoned,
424                )
425            await self.aclose()
426            log.info(
427                "acemq-fastapi: closed after %.3fs, %d consumer(s) drained",
428                report.waited,
429                started,
430            )
431
432    async def start(self, app: FastAPI | None = None) -> int:
433        """Connects, applies the topology and subscribes the consumers.
434
435        Called by :meth:`lifespan`. Public because a worker process that serves no
436        HTTP at all still wants the same start-up, and should not have to
437        construct an ASGI application to get it.
438
439        :returns: how many consumers were started
440        """
441        async with self._lock:
442            if self._connection is not None:
443                raise AceMQError("acemq-fastapi: this integration is already started")
444            timeout = self._settings.connection_timeout.total_seconds()
445            try:
446                connection = await asyncio.wait_for(self._connect(self._settings), timeout)
447            except asyncio.TimeoutError as expired:
448                raise AceMQError(
449                    f"acemq-fastapi: the broker at {self._settings.broker_url} did not "
450                    f"answer within {timeout:g}s"
451                ) from expired
452            self._connection = connection
453
454        try:
455            for publishing in self._on_publish:
456                connection.intercept_publish(publishing)
457            for consuming in self._on_consume:
458                connection.intercept_consume(consuming)
459
460            topology = self._settings.build_topology()
461            if topology is not None:
462                await connection.declare(topology)
463
464            if self._settings.consumer.auto_start:
465                for registration in self._registrations.values():
466                    self._consumers[registration.name] = await self._subscribe(registration)
467        except BaseException:
468            # A half-started application is worse than one that refused to start:
469            # the routes would be up, the consumers would not, and nothing outside
470            # could tell. Undo what was done and let the failure out.
471            await self.drain()
472            await self.aclose()
473            raise
474
475        if app is not None:
476            app.state.acemq = self
477        log.info(
478            "acemq-fastapi: connected to %s, %d consumer(s) running",
479            self._settings.broker_url,
480            len(self._consumers),
481        )
482        return len(self._consumers)
483
484    async def drain(self) -> DrainReport:
485        """Stops every consumer, finishing the handlers already running.
486
487        What this finishes, and what it gives back, is written down in the README
488        under *What a drain finishes*. In short: a handler in flight runs to
489        completion; a delivery fetched but not yet started is handed back to the
490        broker; a retry waiting out a short backoff in this process is waited out
491        in full; a publish waiting for its confirm is not waited for.
492
493        Bounded by ``acemq.consumer.shutdown_timeout``. When that expires the
494        handlers still running are cancelled and their messages left unsettled —
495        the broker redelivers them, so the work may be done twice and nothing is
496        dropped.
497        """
498        if self._connection is None or not self._consumers:
499            self._consumers.clear()
500            return DrainReport(finished=True, waited=0.0, consumers=0)
501
502        consumers = tuple(self._consumers.values())
503        self._consumers.clear()
504        deadline = self._settings.consumer.shutdown_timeout.total_seconds()
505        loop = asyncio.get_running_loop()
506        started = loop.time()
507
508        closing = [asyncio.create_task(consumer.close()) for consumer in consumers]
509        done, pending = await asyncio.wait(closing, timeout=deadline)
510        waited = loop.time() - started
511
512        if not pending:
513            for task in done:
514                if task.cancelled():
515                    # Its workers were already gone — cancelled from outside, or
516                    # killed by something the library logged. Nothing here can
517                    # finish a handler that is not running any more.
518                    log.warning(
519                        "acemq-fastapi: a consumer's workers had already stopped; "
520                        "whatever they were holding is back with the broker"
521                    )
522                elif task.exception() is not None:
523                    log.warning(
524                        "acemq-fastapi: a consumer failed to close: %s", task.exception()
525                    )
526            return DrainReport(finished=True, waited=waited, consumers=len(consumers))
527
528        # Out of time. Cancelling is the only lever there is, and it is pulled
529        # last rather than first: it aborts the handlers still running and leaves
530        # their deliveries unsettled for the broker to hand on. That is the price
531        # of a deadline, and it is cheaper than being SIGKILLed with the same
532        # messages unsettled and the socket gone.
533        abandoned = sum(consumer.in_flight for consumer in consumers)
534        for task in pending:
535            task.cancel()
536        await asyncio.gather(*pending, return_exceptions=True)
537        return DrainReport(
538            finished=False,
539            waited=loop.time() - started,
540            consumers=len(consumers),
541            abandoned=abandoned,
542        )
543
544    async def aclose(self) -> None:
545        """Releases the connection. Idempotent, and safe after a failed start."""
546        connection = self._connection
547        self._connection = None
548        self._publishers.clear()
549        self._consumers.clear()
550        if connection is not None:
551            with suppress(Exception):
552                await connection.close()
553
554    # ---------------------------------------------------------------- private
555
556    async def _subscribe(self, registration: ConsumerRegistration) -> Consumer:
557        defaults = self._settings.consumer
558        return await self.connection.consume(
559            registration.queue,
560            registration.handler,
561            codec=registration.codec,
562            retry=registration.retry or self._settings.build_retry(),
563            prefetch=registration.prefetch
564            if registration.prefetch is not None
565            else defaults.prefetch,
566            concurrency=registration.concurrency
567            if registration.concurrency is not None
568            else defaults.concurrency,
569            tag=registration.tag,
570            args=registration.args,
571            declare=(
572                registration.declare if registration.declare is not None else defaults.declare
573            ),
574        )
575
576    async def _open(self, settings: AceMQSettings) -> Connection:
577        """The default factory: :func:`acemq_amqp.connect` with the settings applied."""
578        return await acemq_amqp.connect(
579            settings.broker_url,
580            codec=settings.build_codec(),
581            origin=settings.client_name,
582            retry=settings.build_retry(),
583            prefetch=settings.prefetch,
584            max_outstanding_publishes=settings.max_outstanding_publishes,
585            confirm_timeout=settings.confirm_timeout,
586            security=settings.build_security(),
587            observer=self._observer,
588        )

The connection, the consumers and the settings, for one application.

::

acemq = AceMQ()

@acemq.consumer("orders")
async def handle(message: Message) -> Ack:
    ...
    return accept()

app = FastAPI(lifespan=acemq.lifespan)
Parameters
  • settings: what to connect to and how. Read from the environment when not given
  • connection_factory: what opens the connection. Replaceable for tests, and for an application that builds its own
  • on_publish: interceptors wrapping every publish, outermost first
  • on_consume: interceptors wrapping every handler
  • observer: where the library's numbers go
AceMQ( settings: acemq_fastapi.AceMQSettings | None = None, *, connection_factory: Callable[[acemq_fastapi.AceMQSettings], Awaitable[acemq_amqp.connection.Connection]] | None = None, on_publish: tuple[Callable[[acemq_amqp.interceptors.PublishContext, Callable[[acemq_amqp.interceptors.PublishContext], Awaitable[acemq_amqp.transport.PublishResult]]], Awaitable[acemq_amqp.transport.PublishResult]], ...] = (), on_consume: tuple[Callable[[acemq_amqp.interceptors.ConsumeContext, Callable[[acemq_amqp.interceptors.ConsumeContext], Awaitable[acemq_amqp.ack.Ack]]], Awaitable[acemq_amqp.ack.Ack]], ...] = (), observer: acemq_amqp.telemetry.Observer | None = None)
145    def __init__(
146        self,
147        settings: AceMQSettings | None = None,
148        *,
149        connection_factory: ConnectionFactory | None = None,
150        on_publish: tuple[PublishInterceptor, ...] = (),
151        on_consume: tuple[ConsumeInterceptor, ...] = (),
152        observer: Observer | None = None,
153    ) -> None:
154        self._settings = settings if settings is not None else AceMQSettings()
155        self._connect: ConnectionFactory = connection_factory or self._open
156        self._on_publish = tuple(on_publish)
157        self._on_consume = tuple(on_consume)
158        self._observer = observer
159        self._registrations: dict[str, ConsumerRegistration] = {}
160        self._consumers: dict[str, Consumer] = {}
161        self._publishers: dict[tuple[Any, ...], Publisher] = {}
162        self._connection: Connection | None = None
163        self._lock = asyncio.Lock()
settings: acemq_fastapi.AceMQSettings
167    @property
168    def settings(self) -> AceMQSettings:
169        """What this integration was configured with."""
170        return self._settings

What this integration was configured with.

started: bool
172    @property
173    def started(self) -> bool:
174        """Whether the connection is open."""
175        return self._connection is not None

Whether the connection is open.

connection: acemq_amqp.connection.Connection
177    @property
178    def connection(self) -> Connection:
179        """The open connection.
180
181        :raises AceMQError: before the lifespan has opened it, because the
182            alternative is a ``None`` that turns into an ``AttributeError``
183            three frames away from the mistake
184        """
185        if self._connection is None:
186            raise AceMQError(
187                "acemq-fastapi: there is no connection yet. It is opened by the lifespan, "
188                "so this is either a call made before the application started or an "
189                "application built without lifespan=acemq.lifespan"
190            )
191        return self._connection

The open connection.

Raises
  • AceMQError: before the lifespan has opened it, because the alternative is a None that turns into an AttributeError three frames away from the mistake
registrations: tuple[ConsumerRegistration, ...]
193    @property
194    def registrations(self) -> tuple[ConsumerRegistration, ...]:
195        """Every ``@acemq.consumer`` declaration, in the order they were made."""
196        return tuple(self._registrations.values())

Every @acemq.consumer declaration, in the order they were made.

consumers: Mapping[str, acemq_amqp.connection.Consumer]
198    @property
199    def consumers(self) -> Mapping[str, Consumer]:
200        """The running consumers by name, for scaling, pausing and counters."""
201        return dict(self._consumers)

The running consumers by name, for scaling, pausing and counters.

def consumer( self, queue: str, *, name: str | None = None, codec: acemq_amqp.codec.Codec | None = None, retry: acemq_amqp.retry.RetryPolicy | None = None, prefetch: int | None = None, concurrency: int | None = None, declare: bool | None = None, tag: str = '', args: Mapping[str, typing.Any] | None = None) -> Callable[[~HandlerT], ~HandlerT]:
205    def consumer(
206        self,
207        queue: str,
208        *,
209        name: str | None = None,
210        codec: Codec | None = None,
211        retry: RetryPolicy | None = None,
212        prefetch: int | None = None,
213        concurrency: int | None = None,
214        declare: bool | None = None,
215        tag: str = "",
216        args: Mapping[str, Any] | None = None,
217    ) -> Callable[[HandlerT], HandlerT]:
218        """Registers a handler for a queue. The Python analogue of ``@AceListener``.
219
220        The decorated function is returned unchanged, so it stays an ordinary
221        function that a test can call with a :class:`~acemq_amqp.Message` and
222        assert on, without a broker anywhere near it.
223
224        Nothing subscribes here. The registration is held until the lifespan
225        opens, which is what puts the handler on the application's own event loop
226        rather than on whichever loop happened to import the module.
227
228        :param queue: what to read
229        :param name: what to call this consumer in :attr:`consumers`. Defaults to
230            the queue
231        :param codec: a codec other than the connection's
232        :param retry: a policy other than ``acemq.consumer.retry``
233        :param prefetch: how many unacknowledged messages to hold
234        :param concurrency: how many messages to work on at once
235        :param declare: declare the queues a failure needs before subscribing
236        :param tag: what to call this consumer to the broker
237        :param args: broker-specific consumer arguments
238        :returns: the decorator
239        :raises AceMQError: when the name is already taken, or the lifespan has
240            already opened
241        """
242
243        def register(handler: HandlerT) -> HandlerT:
244            self.add_consumer(
245                queue,
246                handler,
247                name=name,
248                codec=codec,
249                retry=retry,
250                prefetch=prefetch,
251                concurrency=concurrency,
252                declare=declare,
253                tag=tag,
254                args=args,
255            )
256            return handler
257
258        return register

Registers a handler for a queue. The Python analogue of @AceListener.

The decorated function is returned unchanged, so it stays an ordinary function that a test can call with a ~acemq_amqp.Message and assert on, without a broker anywhere near it.

Nothing subscribes here. The registration is held until the lifespan opens, which is what puts the handler on the application's own event loop rather than on whichever loop happened to import the module.

Parameters
  • queue: what to read
  • name: what to call this consumer in consumers. Defaults to the queue
  • codec: a codec other than the connection's
  • retry: a policy other than acemq.consumer.retry
  • prefetch: how many unacknowledged messages to hold
  • concurrency: how many messages to work on at once
  • declare: declare the queues a failure needs before subscribing
  • tag: what to call this consumer to the broker
  • args: broker-specific consumer arguments :returns: the decorator
Raises
  • AceMQError: when the name is already taken, or the lifespan has already opened
def add_consumer( self, queue: str, handler: Callable[acemq_amqp.connection.Message, acemq_amqp.ack.Ack | Awaitable[acemq_amqp.ack.Ack]], *, name: str | None = None, codec: acemq_amqp.codec.Codec | None = None, retry: acemq_amqp.retry.RetryPolicy | None = None, prefetch: int | None = None, concurrency: int | None = None, declare: bool | None = None, tag: str = '', args: Mapping[str, typing.Any] | None = None) -> ConsumerRegistration:
260    def add_consumer(
261        self,
262        queue: str,
263        handler: Handler,
264        *,
265        name: str | None = None,
266        codec: Codec | None = None,
267        retry: RetryPolicy | None = None,
268        prefetch: int | None = None,
269        concurrency: int | None = None,
270        declare: bool | None = None,
271        tag: str = "",
272        args: Mapping[str, Any] | None = None,
273    ) -> ConsumerRegistration:
274        """Registers a handler without the decorator.
275
276        What a factory function or a plugin uses. Same arguments as
277        :meth:`consumer`, and the same restriction: before the lifespan opens.
278        """
279        if self._connection is not None:
280            raise AceMQError(
281                f"acemq-fastapi: cannot register a consumer for {queue!r} now — the "
282                "lifespan has already opened. Register consumers while the module is "
283                "being imported, which is what the decorator is for"
284            )
285        chosen = name or queue
286        if chosen in self._registrations:
287            raise AceMQError(
288                f"acemq-fastapi: there is already a consumer called {chosen!r}. Two "
289                "consumers on one queue is a reasonable thing to want; give one of them "
290                "a name=... so they can be told apart"
291            )
292        registration = ConsumerRegistration(
293            name=chosen,
294            queue=queue,
295            handler=handler,
296            codec=codec,
297            retry=retry,
298            prefetch=prefetch,
299            concurrency=concurrency,
300            declare=declare,
301            tag=tag,
302            args=dict(args or {}),
303        )
304        self._registrations[chosen] = registration
305        return registration

Registers a handler without the decorator.

What a factory function or a plugin uses. Same arguments as consumer(), and the same restriction: before the lifespan opens.

async def start_consumer(self, name: str) -> acemq_amqp.connection.Consumer:
307    async def start_consumer(self, name: str) -> Consumer:
308        """Subscribes one registered consumer that is not running.
309
310        What :attr:`consumers` is for in the other direction: a consumer stopped
311        for back pressure, or one left registered by
312        ``acemq.consumer.auto_start=false``, is started again by name.
313        """
314        registration = self._registrations.get(name)
315        if registration is None:
316            known = ", ".join(sorted(self._registrations)) or "none"
317            raise AceMQError(
318                f"acemq-fastapi: no consumer is registered as {name!r}; registered: {known}"
319            )
320        if name in self._consumers:
321            return self._consumers[name]
322        consumer = await self._subscribe(registration)
323        self._consumers[name] = consumer
324        return consumer

Subscribes one registered consumer that is not running.

What consumers is for in the other direction: a consumer stopped for back pressure, or one left registered by acemq.consumer.auto_start=false, is started again by name.

async def stop_consumer(self, name: str) -> None:
326    async def stop_consumer(self, name: str) -> None:
327        """Drains and closes one consumer, leaving it registered.
328
329        It finishes what its handlers are holding, exactly as a shutdown does.
330        """
331        consumer = self._consumers.pop(name, None)
332        if consumer is not None:
333            await consumer.close()

Drains and closes one consumer, leaving it registered.

It finishes what its handlers are holding, exactly as a shutdown does.

def publisher( self, exchange: str = '', routing_key: str = '', *, codec: acemq_amqp.codec.Codec | None = None, persistent: bool = True, mandatory: bool = False) -> acemq_amqp.connection.Publisher:
337    def publisher(
338        self,
339        exchange: str = "",
340        routing_key: str = "",
341        *,
342        codec: Codec | None = None,
343        persistent: bool = True,
344        mandatory: bool = False,
345    ) -> Publisher:
346        """A publisher for a destination, built once and reused.
347
348        Publishers are cheap to keep and safe to share, and building one per
349        request is a small waste repeated a lot. This caches by every argument
350        that changes what a publisher does, so two routes asking for the same
351        destination get the same object and a route asking for a different one
352        does not quietly get somebody else's.
353
354        :raises AceMQError: before the lifespan has opened the connection
355        """
356        key = (exchange, routing_key, id(codec) if codec else None, persistent, mandatory)
357        publisher = self._publishers.get(key)
358        if publisher is None:
359            publisher = self.connection.publisher(
360                exchange,
361                routing_key,
362                codec=codec,
363                persistent=persistent,
364                mandatory=mandatory,
365            )
366            self._publishers[key] = publisher
367        return publisher

A publisher for a destination, built once and reused.

Publishers are cheap to keep and safe to share, and building one per request is a small waste repeated a lot. This caches by every argument that changes what a publisher does, so two routes asking for the same destination get the same object and a route asking for a different one does not quietly get somebody else's.

Raises
  • AceMQError: before the lifespan has opened the connection
def health_check(self) -> acemq_fastapi.AceMQHealth:
371    def health_check(self) -> AceMQHealth:
372        """This connection as a :class:`~acemq_amqp.HealthCheck`.
373
374        Pass it to :func:`acemq_amqp.aggregate_health` beside an application's
375        own checks, or mount :meth:`health_router` and let it answer on its own.
376        """
377        from .health import AceMQHealth
378
379        return AceMQHealth(self)

This connection as a ~acemq_amqp.HealthCheck.

Pass it to acemq_amqp.aggregate_health() beside an application's own checks, or mount health_router() and let it answer on its own.

async def health(self) -> acemq_amqp.telemetry.HealthReport:
381    async def health(self) -> acemq_amqp.HealthReport:
382        """What a readiness probe should see. See :class:`~acemq_fastapi.AceMQHealth`."""
383        return await self.health_check().check()

What a readiness probe should see. See ~acemq_fastapi.AceMQHealth.

def health_router( self, *, path: str | None = None, tags: list[typing.Any] | None = None) -> Any:
385    def health_router(self, *, path: str | None = None, tags: list[Any] | None = None) -> Any:
386        """An ``APIRouter`` with one route reporting on the connection.
387
388        ``app.include_router(acemq.health_router())`` and nothing else. Mounting
389        it is explicit rather than automatic because a route that appears in an
390        application's OpenAPI document without anybody adding it is a route
391        somebody has to go looking for.
392        """
393        from .health import health_router
394
395        return health_router(self, path=path, tags=tags)

An APIRouter with one route reporting on the connection.

app.include_router(acemq.health_router()) and nothing else. Mounting it is explicit rather than automatic because a route that appears in an application's OpenAPI document without anybody adding it is a route somebody has to go looking for.

@asynccontextmanager
async def lifespan( self, app: fastapi.applications.FastAPI | None = None) -> AsyncIterator[Mapping[str, typing.Any]]:
399    @asynccontextmanager
400    async def lifespan(self, app: FastAPI | None = None) -> AsyncIterator[Mapping[str, Any]]:
401        """Opens the connection and the consumers, and drains them on the way out.
402
403        ``FastAPI(lifespan=acemq.lifespan)``. To keep an application's own
404        lifespan as well, see :func:`acemq_fastapi.compose_lifespans` — this one
405        belongs innermost, so the consumers start after whatever they depend on
406        and drain before it goes away.
407
408        The ``app`` is optional so the same context manager can be used in a test
409        or a worker with no ASGI application at all. When one is given it gets
410        ``app.state.acemq``, which is how the request dependencies find this
411        object without a module-level global.
412        """
413        started = await self.start(app)
414        try:
415            yield {"acemq": self}
416        finally:
417            report = await self.drain()
418            if not report.finished:
419                log.warning(
420                    "acemq-fastapi: the drain did not finish in %s; %d message(s) were "
421                    "left unsettled and the broker will redeliver them",
422                    self._settings.consumer.shutdown_timeout,
423                    report.abandoned,
424                )
425            await self.aclose()
426            log.info(
427                "acemq-fastapi: closed after %.3fs, %d consumer(s) drained",
428                report.waited,
429                started,
430            )

Opens the connection and the consumers, and drains them on the way out.

FastAPI(lifespan=acemq.lifespan). To keep an application's own lifespan as well, see acemq_fastapi.compose_lifespans() — this one belongs innermost, so the consumers start after whatever they depend on and drain before it goes away.

The app is optional so the same context manager can be used in a test or a worker with no ASGI application at all. When one is given it gets app.state.acemq, which is how the request dependencies find this object without a module-level global.

async def start(self, app: fastapi.applications.FastAPI | None = None) -> int:
432    async def start(self, app: FastAPI | None = None) -> int:
433        """Connects, applies the topology and subscribes the consumers.
434
435        Called by :meth:`lifespan`. Public because a worker process that serves no
436        HTTP at all still wants the same start-up, and should not have to
437        construct an ASGI application to get it.
438
439        :returns: how many consumers were started
440        """
441        async with self._lock:
442            if self._connection is not None:
443                raise AceMQError("acemq-fastapi: this integration is already started")
444            timeout = self._settings.connection_timeout.total_seconds()
445            try:
446                connection = await asyncio.wait_for(self._connect(self._settings), timeout)
447            except asyncio.TimeoutError as expired:
448                raise AceMQError(
449                    f"acemq-fastapi: the broker at {self._settings.broker_url} did not "
450                    f"answer within {timeout:g}s"
451                ) from expired
452            self._connection = connection
453
454        try:
455            for publishing in self._on_publish:
456                connection.intercept_publish(publishing)
457            for consuming in self._on_consume:
458                connection.intercept_consume(consuming)
459
460            topology = self._settings.build_topology()
461            if topology is not None:
462                await connection.declare(topology)
463
464            if self._settings.consumer.auto_start:
465                for registration in self._registrations.values():
466                    self._consumers[registration.name] = await self._subscribe(registration)
467        except BaseException:
468            # A half-started application is worse than one that refused to start:
469            # the routes would be up, the consumers would not, and nothing outside
470            # could tell. Undo what was done and let the failure out.
471            await self.drain()
472            await self.aclose()
473            raise
474
475        if app is not None:
476            app.state.acemq = self
477        log.info(
478            "acemq-fastapi: connected to %s, %d consumer(s) running",
479            self._settings.broker_url,
480            len(self._consumers),
481        )
482        return len(self._consumers)

Connects, applies the topology and subscribes the consumers.

Called by lifespan(). Public because a worker process that serves no HTTP at all still wants the same start-up, and should not have to construct an ASGI application to get it.

:returns: how many consumers were started

async def drain(self) -> DrainReport:
484    async def drain(self) -> DrainReport:
485        """Stops every consumer, finishing the handlers already running.
486
487        What this finishes, and what it gives back, is written down in the README
488        under *What a drain finishes*. In short: a handler in flight runs to
489        completion; a delivery fetched but not yet started is handed back to the
490        broker; a retry waiting out a short backoff in this process is waited out
491        in full; a publish waiting for its confirm is not waited for.
492
493        Bounded by ``acemq.consumer.shutdown_timeout``. When that expires the
494        handlers still running are cancelled and their messages left unsettled —
495        the broker redelivers them, so the work may be done twice and nothing is
496        dropped.
497        """
498        if self._connection is None or not self._consumers:
499            self._consumers.clear()
500            return DrainReport(finished=True, waited=0.0, consumers=0)
501
502        consumers = tuple(self._consumers.values())
503        self._consumers.clear()
504        deadline = self._settings.consumer.shutdown_timeout.total_seconds()
505        loop = asyncio.get_running_loop()
506        started = loop.time()
507
508        closing = [asyncio.create_task(consumer.close()) for consumer in consumers]
509        done, pending = await asyncio.wait(closing, timeout=deadline)
510        waited = loop.time() - started
511
512        if not pending:
513            for task in done:
514                if task.cancelled():
515                    # Its workers were already gone — cancelled from outside, or
516                    # killed by something the library logged. Nothing here can
517                    # finish a handler that is not running any more.
518                    log.warning(
519                        "acemq-fastapi: a consumer's workers had already stopped; "
520                        "whatever they were holding is back with the broker"
521                    )
522                elif task.exception() is not None:
523                    log.warning(
524                        "acemq-fastapi: a consumer failed to close: %s", task.exception()
525                    )
526            return DrainReport(finished=True, waited=waited, consumers=len(consumers))
527
528        # Out of time. Cancelling is the only lever there is, and it is pulled
529        # last rather than first: it aborts the handlers still running and leaves
530        # their deliveries unsettled for the broker to hand on. That is the price
531        # of a deadline, and it is cheaper than being SIGKILLed with the same
532        # messages unsettled and the socket gone.
533        abandoned = sum(consumer.in_flight for consumer in consumers)
534        for task in pending:
535            task.cancel()
536        await asyncio.gather(*pending, return_exceptions=True)
537        return DrainReport(
538            finished=False,
539            waited=loop.time() - started,
540            consumers=len(consumers),
541            abandoned=abandoned,
542        )

Stops every consumer, finishing the handlers already running.

What this finishes, and what it gives back, is written down in the README under What a drain finishes. In short: a handler in flight runs to completion; a delivery fetched but not yet started is handed back to the broker; a retry waiting out a short backoff in this process is waited out in full; a publish waiting for its confirm is not waited for.

Bounded by acemq.consumer.shutdown_timeout. When that expires the handlers still running are cancelled and their messages left unsettled — the broker redelivers them, so the work may be done twice and nothing is dropped.

async def aclose(self) -> None:
544    async def aclose(self) -> None:
545        """Releases the connection. Idempotent, and safe after a failed start."""
546        connection = self._connection
547        self._connection = None
548        self._publishers.clear()
549        self._consumers.clear()
550        if connection is not None:
551            with suppress(Exception):
552                await connection.close()

Releases the connection. Idempotent, and safe after a failed start.

@dataclass(frozen=True, slots=True)
class ConsumerRegistration:
 78@dataclass(frozen=True, slots=True)
 79class ConsumerRegistration:
 80    """One ``@acemq.consumer`` declaration, before anything is running.
 81
 82    ``None`` on the per-consumer settings means "whatever ``acemq.consumer.*``
 83    says", resolved when the lifespan opens rather than when the decorator runs,
 84    so settings read from the environment still reach a consumer registered at
 85    import time.
 86    """
 87
 88    #: What this consumer is called to :meth:`AceMQ.consumers`. Defaults to the
 89    #: queue name, and has to be unique because that is how it is addressed.
 90    name: str
 91    queue: str
 92    handler: Handler
 93    codec: Codec | None = None
 94    retry: RetryPolicy | None = None
 95    prefetch: int | None = None
 96    concurrency: int | None = None
 97    declare: bool | None = None
 98    #: What to call this consumer to the broker. Empty lets the broker name it.
 99    tag: str = ""
100    args: Mapping[str, Any] = field(default_factory=dict)

One @acemq.consumer declaration, before anything is running.

None on the per-consumer settings means "whatever acemq.consumer.* says", resolved when the lifespan opens rather than when the decorator runs, so settings read from the environment still reach a consumer registered at import time.

ConsumerRegistration( name: str, queue: str, handler: Callable[acemq_amqp.connection.Message, acemq_amqp.ack.Ack | Awaitable[acemq_amqp.ack.Ack]], codec: acemq_amqp.codec.Codec | None = None, retry: acemq_amqp.retry.RetryPolicy | None = None, prefetch: int | None = None, concurrency: int | None = None, declare: bool | None = None, tag: str = '', args: Mapping[str, typing.Any] = <factory>)
name: str
queue: str
handler: Callable[acemq_amqp.connection.Message, acemq_amqp.ack.Ack | Awaitable[acemq_amqp.ack.Ack]]
codec: acemq_amqp.codec.Codec | None
retry: acemq_amqp.retry.RetryPolicy | None
prefetch: int | None
concurrency: int | None
declare: bool | None
tag: str
args: Mapping[str, typing.Any]
@dataclass(frozen=True, slots=True)
class DrainReport:
103@dataclass(frozen=True, slots=True)
104class DrainReport:
105    """What a shutdown got through.
106
107    Returned by :meth:`AceMQ.drain` and logged by the lifespan. ``finished``
108    false means the deadline expired: the handlers still running were cancelled
109    and their messages left unsettled, so the broker will hand them to whoever
110    replaces this process. Nothing is lost; some work may be done twice.
111    """
112
113    finished: bool
114    #: Seconds spent draining.
115    waited: float
116    #: How many consumers were asked to stop.
117    consumers: int
118    #: How many messages were still in a handler when the deadline expired.
119    abandoned: int = 0

What a shutdown got through.

Returned by AceMQ.drain() and logged by the lifespan. finished false means the deadline expired: the handlers still running were cancelled and their messages left unsettled, so the broker will hand them to whoever replaces this process. Nothing is lost; some work may be done twice.

DrainReport(finished: bool, waited: float, consumers: int, abandoned: int = 0)
finished: bool
waited: float
consumers: int
abandoned: int