Skip to main content

metal-rabbit

Async RabbitMQ client library for FastAPI. Uses a single connect_robust connection with an async channel pool per process, decorator-based consumer API, and typed topology configuration via Pydantic.

Installation

pip install metal-rabbit

For FastAPI integration (dependency and correlation-id middleware):

pip install metal-rabbit[fastapi]

For OpenTelemetry metrics on consumers:

pip install metal-rabbit[otel]

Neither extra is needed for publishing and consuming — see Instrumentation.

Requires Python 3.14+.

Configuration

Settings are loaded from environment variables (or a .env file via pydantic-settings):

Variable Default Description
RABBITMQ_HOST — Broker hostname (required)
RABBITMQ_PORT 5672 Broker port
RABBITMQ_USER — Username (required)
RABBITMQ_PASSWORD — Password (required)
RABBITMQ_VHOST / Virtual host
RABBITMQ_HEARTBEAT 600 Heartbeat interval in seconds
RABBITMQ_BLOCKED_CONNECTION_TIMEOUT 300 Blocked connection timeout in seconds
RABBITMQ_CHANNEL_POOL_SIZE 10 Max channels in the pool
RABBITMQ_POOL_ACQUIRE_TIMEOUT 5 Seconds to wait for a free channel

.env example:

RABBITMQ_HOST=localhost
RABBITMQ_PORT=5672
RABBITMQ_USER=guest
RABBITMQ_PASSWORD=guest
RABBITMQ_VHOST=/

Quick start with FastAPI

from contextlib import asynccontextmanager
from fastapi import Depends, FastAPI
from metal_rabbit.client import MetalRabbitClient
from metal_rabbit.fastapi import get_metal_rabbit_client
from metal_rabbit.producer import ExchangeType, MetalRabbitConfig
from metal_rabbit.settings import MetalRabbitSettings

settings = MetalRabbitSettings()
client = MetalRabbitClient(settings=settings)

ORDERS_CONFIG = MetalRabbitConfig(
    exchange_name="orders",
    queue_name="orders.created",
    routing_key="orders.created",
    exchange_type=ExchangeType.DIRECT,
)


@client.consumer(ORDERS_CONFIG)
async def handle_order(payload: dict) -> None:
    print(f"order received: {payload}")


@asynccontextmanager
async def lifespan(app: FastAPI):
    await client.start(app)   # connects, starts consumer tasks, registers on app.state
    yield
    await client.close()      # graceful shutdown


app = FastAPI(lifespan=lifespan)


@app.post("/orders", status_code=202)
async def create_order(
    body: dict,
    rabbit_client: MetalRabbitClient = Depends(get_metal_rabbit_client),
) -> dict:
    await rabbit_client.publish(queue_name=ORDERS_CONFIG.queue_name, message=body)
    return {"status": "queued"}

Core concepts

MetalRabbitClient

The central object. Manages a single connect_robust connection (auto-reconnect) and an async channel pool. All methods are coroutines.

from metal_rabbit.client import MetalRabbitClient
from metal_rabbit.settings import MetalRabbitSettings

client = MetalRabbitClient(settings=MetalRabbitSettings())

You can also pass a URL string directly:

client = MetalRabbitClient(url="amqp://guest:guest@localhost/")

MetalRabbitConfig

Pydantic model that describes a topology (exchange + queue + binding). Fields are validated at construction time — empty or blank strings are rejected.

from metal_rabbit.producer import ExchangeType, MetalRabbitConfig

config = MetalRabbitConfig(
    exchange_name="events",
    queue_name="events.user_signed_up",
    routing_key="events.user_signed_up",
    exchange_type=ExchangeType.TOPIC,   # DIRECT | FANOUT | TOPIC | HEADERS
    durable=True,
    persistent=True,
)

ExchangeType

Enum for all supported RabbitMQ exchange types. Always use this instead of raw strings.

from metal_rabbit.producer import ExchangeType

ExchangeType.DIRECT   # "direct"
ExchangeType.FANOUT   # "fanout"
ExchangeType.TOPIC    # "topic"
ExchangeType.HEADERS  # "headers"

Publishing messages

Simple publish — directly to a queue (default exchange, no topology setup required):

await client.publish(queue_name="jobs", message={"job_id": "abc"})

Accepts dict, list, str, or bytes. Dicts and lists are serialized as JSON with content-type: application/json.

Producer — publish through a named exchange with full topology:

from metal_rabbit.producer import MetalRabbitProducer

producer = MetalRabbitProducer(client=client, config=config)
await producer.setup()                          # declares exchange, queue, and binding
await producer.publish({"event": "user.created", "id": 1})

Consuming messages

Decorator API (recommended with FastAPI):

@client.consumer(config)
async def handle_event(payload: dict) -> None:
    ...

await client.start()  # spawns one asyncio Task per registered consumer

Sync handlers are also accepted — the decorator detects coroutines automatically.

Class-based — subclass MetalRabbitConsumer for more control:

from metal_rabbit.consumer import MetalRabbitConsumer
import aio_pika

class OrderConsumer(MetalRabbitConsumer):
    config = MetalRabbitConfig(
        exchange_name="orders",
        queue_name="orders.created",
        routing_key="orders.created",
    )

    async def _on_message(self, message: aio_pika.IncomingMessage, payload: dict) -> None:
        print(payload)

consumer = OrderConsumer(client)
await consumer.start()
# ...
await consumer.stop()

Messages that fail to deserialize as JSON are nack'd and discarded. Unhandled exceptions in the callback trigger a nack (no requeue).

Instrumentation

Every published message carries an AMQP timestamp. That is not for your code — it is what lets RabbitMQ answer queue_head_message_timestamp, the age of the oldest message still waiting. Without it that metric is NaN, and a dashboard cannot tell a queue that is merely busy from one that stopped being drained.

Anything beyond the timestamp is opt-in. The client takes an instrumentation object, applied in the two places worth wrapping: outgoing payloads (annotate) and the execution of a consumer handler (consuming). Passing nothing keeps the library exactly as quiet as before.

from metal_rabbit.client import MetalRabbitClient
from metal_rabbit.instrumentation import (
    CorrelationInstrumentation,
    MetricsInstrumentation,  # needs the [otel] extra
    compose,
)

client = MetalRabbitClient(
    instrumentation=compose(
        CorrelationInstrumentation(),
        MetricsInstrumentation(),
    )
)
Instrumentation What it does Needs
NullInstrumentation nothing; the default —
CorrelationInstrumentation injects the correlation id into published payloads, binds it from consumed ones —
MetricsInstrumentation counts handled messages by outcome and records the handler's duration metal-rabbit[otel]
CompositeInstrumentation / compose(...) runs several in order —

Write your own by matching the Instrumentation protocol — two methods, no base class to inherit:

class TenantInstrumentation:
    def annotate(self, payload, *, queue):
        return payload

    @asynccontextmanager
    async def consuming(self, payload, *, queue, task):
        yield

Correlation id

One id follows a request across services: an HTTP request mints it, publishing carries it in the payload, consuming binds it back into the context, and the id ends up on every log line along the way.

from metal_rabbit.correlation import get_correlation_id, set_correlation_id
from metal_rabbit.fastapi import CorrelationIdMiddleware   # needs the [fastapi] extra

app.add_middleware(CorrelationIdMiddleware)   # honours X-Request-ID, echoes it back
logger.info("processing", correlation_id=get_correlation_id())

The payload field is request_id and the header is X-Request-ID; both are exported as CORRELATION_FIELD and CORRELATION_HEADER. With no id in context, get_correlation_id() returns "-" rather than None, so log lines keep their shape.

An incoming id is only adopted if it matches [A-Za-z0-9._-]{1,64}; anything else is replaced by a fresh one. The id arrives from outside — an HTTP header, or the payload of a message someone else published — and it ends up on every log line, so it is not a string to take on trust.

annotate does not touch the dict you hand it: it returns a copy carrying the id. A payload that already has a request_id keeps it, which is what lets an id survive a hop.

Metrics

MetricsInstrumentation records two instruments, both labelled with messaging.destination.name, task and outcome (ok or error):

Instrument Type
messaging.consumed.messages counter
messaging.consumed.duration histogram, seconds

The outcome starts as error and only becomes ok after the handler returns, so an exception is counted as a failure and still propagates.

Wire the duration view. OTel's default histogram buckets start at 0, 5, 10, 25 — sized for milliseconds, while the instrument reports seconds as the convention asks. A handler taking single-digit milliseconds lands entirely in the first bucket and histogram_quantile interpolates inside it: measured, a p95 of 2.6 ms worth of work read 4.75 s. The library ships the boundaries it expects:

from metal_rabbit.instrumentation import duration_view

MeterProvider(resource=resource, views=[duration_view()], metric_readers=[reader])

Pass meter= to the instrumentation if you keep your own provider instead of the global one.

Low-level topology management

await client.declare_exchange(exchange_name="events", exchange_type="topic")
await client.declare_queue("events.user_signed_up")
await client.bind_queue(
    queue_name="events.user_signed_up",
    exchange_name="events",
    routing_key="events.user_signed_up",
)

ensure_direct_topology(config) does all three in one call and caches the result so subsequent calls are no-ops.

FastAPI dependency

get_metal_rabbit_client is a FastAPI dependency that reads the client from app.state.rabbitmq_client (set automatically by await client.start(app)):

from metal_rabbit.fastapi import get_metal_rabbit_client

@app.get("/healthcheck")
async def healthcheck(client: MetalRabbitClient = Depends(get_metal_rabbit_client)):
    await client.ping()
    return {"status": "ok"}

Development

# Install with dev dependencies
make dev

# Run unit tests
make test

# Run e2e tests (requires RabbitMQ)
docker compose up -d
make test-e2e

# Lint
make lint

# Auto-fix lint/format issues
make fix

License

MIT

Metadata

Release files for metal-rabbit 0.4.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for metal-rabbit 0.4.0
File Size Uploaded
metal_rabbit-0.4.0.tar.gz 13.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for metal-rabbit 0.4.0
File Interpreter ABI Platform
metal_rabbit-0.4.0-py3-none-any.whl Python 3 none any Details

Total release size: 26.6 kB

Release files / metal_rabbit-0.4.0.tar.gz

Download URL metal_rabbit-0.4.0.tar.gz
Size 13.8 kB
Tags Source
SHA-256 checksum
How to use checksums
922a5cf10004efccd8580c73c6f8777e1bba1f8d003daeea9c3185fd74e45344
BLAKE2b-256 checksum
How to use checksums
810093530241c0dd77e89f3820a7c4421f30ffc00078db66735e6c63f1fd115a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.

Transparency log

Release files / metal_rabbit-0.4.0-py3-none-any.whl

Download URL metal_rabbit-0.4.0-py3-none-any.whl
Size 12.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3d473328657812987df270894f13f2d21c39e3043f1a00699d826d072b43a15a
BLAKE2b-256 checksum
How to use checksums
55a296ea9d576ff540c7f9ba13a7adbd9ce38ad4ac97fbbde4eb367b0dec59af
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Jul 31, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.4.0 This release

2 release files

0.3.1

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page