Health

Two things, meant to be used separately.

A route of its own

app.include_router(acemq.health_router())

One route at acemq.health.path, /health/acemq unless it is set.

{
  "status": "up",
  "healthy": true,
  "detail": "",
  "checked": "2026-09-18T09:41:02.118431+00:00",
  "parts": {
    "consumers": {"orders-main": 0},
    "registered": ["orders-main"],
    "in-flight": 0,
    "round-trip": 0.0013,
    "blocked": false
  }
}

Mounting it is explicit rather than automatic. A route that appears in an application's OpenAPI document without anybody adding it is a route somebody has to go looking for.

A check composed into yours

AceMQHealth satisfies the library's HealthCheck protocol — a name and an async check() — so it drops into aggregate_health beside everything else the service depends on:

from acemq_amqp import aggregate_health

@app.get("/readyz")
async def readyz(acemq: AceMQDep) -> Response:
    report = await aggregate_health(acemq.health_check(), database_check, cache_check)
    return JSONResponse(report_as_json(report), 200 if report.healthy else 503)

The combined status is the worst of them, and the combined report names which part was which.

What each status means

up The broker answered a round trip and every consumer is still reading
degraded It answered, but a consumer has stopped reading. Worth an alert; not worth a restart, because a replacement would almost certainly stall the same way
down The broker did not answer at all

The status code is the part worth reading twice: 200 for up and for degraded and blocked, 503 only for down. A readiness probe on this route takes the instance out of rotation when the broker is unreachable and leaves it in when the broker is merely unhappy. An alert should read the body, not the code.

A blocked connection is up, with the reason

This is the one place the check disagrees with a naive reading of the broker's state, and it is deliberate.

RabbitMQ blocks a connection when it is protecting itself — usually low on disk or memory. It stops reading from the socket, so a health probe that makes a round trip never gets an answer, and the obvious implementation reports down. An orchestrator then restarts the instance into the same blocked broker, having thrown away whatever it was holding, and the broker is no better off for it.

So: blocked reports up, with the reason, and parts.blocked is true. It is the same call the Spring Boot starter's AceMqHealthIndicator makes, for the same reason.

{
  "status": "up",
  "healthy": true,
  "detail": "the broker has blocked this connection; publishing is paused",
  "parts": {"blocked": true, "consumers": {"orders-main": 0}, "in-flight": 0}
}

The detail is a contract, not a phrasing

the broker has blocked this connection; publishing is paused is the same sentence every AceMQ library writes — Go's blockedPrefix, Ruby's Health::BLOCKED, and this one, word for word. One alert rule matches a blocked broker whatever language the service behind it is written in, which is the point of fixing the wording. Anything the broker itself said follows a colon.

Nothing is appended on RabbitMQ. The broker does send a reason — "low on memory" — and aiormq logs it and keeps only a flag, so there is nothing to read back and blocked_reason is null. Reporting the block without the reason is the honest half; a reason invented here would read exactly like one the broker sent. Do not write a rule that expects one to appear.

This package does not paraphrase that sentence: it comes from the connection and is passed through. Before acemq-amqp 0.7.0 this package wrote its own, longer wording, and the changelog says what an alert rule matching the old one has to change to.

blocked: null

null is not false. It means the question could not be asked — a transport with no answer to give, or a connection between reconnections — and a report saying null is more use in an incident than one saying false because nothing looked.

It is the connection's answer, not this package's. Connection.blocked is true, false or null, and the report carries it through untouched; nothing here has a blocked of its own that could overwrite it. There is an integration test whose only job is to fail if blocked reads null against a live broker, because that would mean the library had quietly lost the ability to tell a blocked broker from a wedged one.

Asking used to take a walk down transport → connection → transport → connection looking for a private aiormq event by its name-mangled attribute — there was no supported accessor anywhere above it. 0.7.0 added one, and the walk is gone.

The timeout

acemq.health.timeout is handed to the library's probe as its deadline rather than wrapped around it. The probe has had a deadline of its own since 0.7.0, and a second one outside it would be the cruder of the two: a blocked broker that stops answering is recognised as blocked and reported up, and an outer timeout firing first would turn that into a spurious down. It would also cancel a request the library deliberately abandons — a broker that is not reading cannot be relied on to process a cancellation either.

A blocked broker is not probed at all: the state is read first and the round trip skipped, so the route answers immediately instead of spending the whole deadline arriving at an answer it already had.

Metrics and tracing

They come from the library, not from here:

from acemq_amqp.prometheus import PrometheusObserver

acemq = AceMQ(observer=PrometheusObserver())

What is counted, and under what name, is in the library's observability page. acemq.consume.in_flight and acemq.retried.total are the two an operator of a FastAPI process wants first: the first says whether the loop is saturated, and the second rises before the dead letters move.