Middleware¶
BaseMiddleware (base class)¶
BaseMiddleware
¶
Base middleware with lifecycle hooks.
Hooks: - on_receive(msg): notification when message is received (before processing) - consume_scope(call_next, msg): wrap handler execution - after_processed(msg, exc): post-processing notification - publish_scope(call_next, envelope): wrap outgoing publish
All hooks have no-op defaults. Subclasses override what they need. Both sync and async variants provided.
Source code in src/rabbitkit/middleware/base.py
Methods:¶
on_receive(message: RabbitMessage) -> None
¶
Called when a message is received, before processing.
H7 — two things every on_receive override must account for:
- Runs in a fixed, flat pre-pass entirely BEFORE consume_scope is entered for ANY middleware on the route. An exception raised here is NOT caught by any middleware's consume_scope — not even RetryMiddleware's — so it is never retry-eligible via the delay-queue mechanism; it settles per the route's AckPolicy using the pipeline's default classifier instead. If your check should be retryable, implement it in consume_scope, not here.
- Runs in REVERSE registration order — the mirror of publish_scope's
outer→inner composition — so a receive-side transform that undoes
a publish-side one (e.g. decompress undoing compress) runs in the
mathematically correct relative order. This does NOT mean any two
on_receive-based middlewares can be listed in either order and
both work — e.g. SigningMiddleware + CompressionMiddleware only
work as
[CompressionMiddleware, SigningMiddleware](compression outer), because the signature covers content_encoding, a field compression itself sets. See HandlerPipeline._run_consume_sync's docstring for the full explanation.
Source code in src/rabbitkit/middleware/base.py
on_receive_async(message: RabbitMessage) -> None
async
¶
consume_scope(call_next: Callable[[RabbitMessage], Any], message: RabbitMessage) -> Any
¶
Wrap handler execution (sync). Must call call_next(message).
consume_scope_async(call_next: Callable[[RabbitMessage], Awaitable[Any]], message: RabbitMessage) -> Any
async
¶
Wrap handler execution (async). Must call await call_next(message).
after_processed(message: RabbitMessage, exc: BaseException | None = None) -> None
¶
after_processed_async(message: RabbitMessage, exc: BaseException | None = None) -> None
async
¶
publish_scope(call_next: Callable[[MessageEnvelope], Any], envelope: MessageEnvelope) -> Any
¶
Wrap outgoing publish (sync). Must call call_next(envelope).
publish_scope_async(call_next: Callable[[MessageEnvelope], Awaitable[Any]], envelope: MessageEnvelope) -> Any
async
¶
Wrap outgoing publish (async). Must call await call_next(envelope).
Source code in src/rabbitkit/middleware/base.py
NoOpMiddleware
¶
Bases: BaseMiddleware
Null Object middleware — zero-overhead pass-through.
Use as a default when no middleware is configured, eliminating
if collector is None: return call_next(...) branches on the hot path.
Source code in src/rabbitkit/middleware/base.py
RetryMiddleware¶
RetryMiddleware
¶
Bases: BaseMiddleware
Routes failed messages to delay queues for retry.
On exception: 1. Classify error (transient/permanent) 2. If transient + retries left → publish to delay queue + ack source 3. If permanent or retries exhausted → tag as terminal + re-raise
Source code in src/rabbitkit/middleware/retry.py
154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 | |
Methods:¶
ensure_publish_fns(publish_fn: Callable[[MessageEnvelope], Any] | None = None, publish_async_fn: Callable[[MessageEnvelope], Any] | None = None) -> None
¶
Fill in whichever publish function is not already set.
Called by the broker at start() for USER-CONSTRUCTED RetryMiddleware
instances found in middlewares=[...]: previously such an instance
with no publish fn silently fell into the ack-without-publish drop
path on every transient failure (now a nack+RuntimeWarning, but still
a hot loop). Injecting the broker's own confirmed-publish fn makes a
manually-added RetryMiddleware(config) behave identically to the
auto-wired one. Explicitly-passed fns are never overwritten.
Source code in src/rabbitkit/middleware/retry.py
consume_scope(call_next: Callable[[RabbitMessage], Any], message: RabbitMessage) -> Any
¶
Sync retry scope.
H8: on a caught, requeued failure, returns REQUEUED_FOR_RETRY
(never None) — see that sentinel's docstring in core/types.py.
_handle_retry_sync either returns normally (requeued: routed to a
delay queue, or nacked for immediate redelivery if that publish
itself failed) or re-raises (terminal: permanent/exhausted) via
_mark_terminal_and_raise, so reaching this return unambiguously
means "requeued" — an outer middleware (e.g. DeduplicationMiddleware)
MUST NOT treat this the same as the handler actually succeeding.
Source code in src/rabbitkit/middleware/retry.py
consume_scope_async(call_next: Callable[[RabbitMessage], Awaitable[Any]], message: RabbitMessage) -> Any
async
¶
Async retry scope. See :meth:consume_scope (H8) for why this
returns REQUEUED_FOR_RETRY rather than None.
Source code in src/rabbitkit/middleware/retry.py
RetryRouter
¶
Declares delay queue topology at startup.
Called by broker.start() for each route that has retry enabled. RetryRouter is the SINGLE OWNER of all retry/DLQ topology for a route.
DLQ routing:
- The source queue is re-declared with x-dead-letter-exchange=""
and x-dead-letter-routing-key=<dlq_name> so that messages
rejected/nacked with requeue=False are automatically routed to
the DLQ by RabbitMQ — no application-level routing needed.
- Use get_source_queue_dlq_arguments() to obtain the extra arguments
that must be added to the source queue declaration.
Source code in src/rabbitkit/middleware/retry.py
507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 | |
Methods:¶
get_dlq_name(source_queue_name: str) -> str
¶
Return the DLQ name for a given source queue.
Always per-queue — shared mode (per_queue=False) is rejected by RetryConfig (H3), so there is no shared-DLQ branch.
Source code in src/rabbitkit/middleware/retry.py
get_source_queue_dlq_arguments(source_queue_name: str) -> dict[str, str]
¶
Return x-dead-letter arguments to add to the source queue declaration.
When these arguments are present on the source queue, RabbitMQ automatically forwards messages that are rejected/nacked with requeue=False to the DLQ — making the DLQ actually reachable.
Source code in src/rabbitkit/middleware/retry.py
get_delay_queue_definitions(source_queue_name: str, source_exchange_name: str, *, source_queue_type: QueueType | None = None) -> list[RabbitQueue]
¶
Generate delay queue definitions for a source queue.
Returns list of RabbitQueue objects for delay queues + DLQ.
The DLQ is now reachable because get_source_queue_dlq_arguments()
wires the source queue's x-dead-letter-exchange to it.
M5: on TTL expiry, a delay queue dead-letters back to the SOURCE
QUEUE via the default exchange (x-dead-letter-exchange="")
with the queue's own name as the routing key — never the source
queue's real exchange. On the default exchange a routing key that
matches a queue's name always delivers directly to that queue,
completely independent of how the queue is actually bound elsewhere.
The previous version dead-lettered to source_exchange_name
using source_queue_name as the routing key — for a source queue
bound to its real exchange via a topic pattern (e.g.
orders.*.created) rather than literally by its own name, that
routing key almost never matches the binding, so the retried
message silently vanished instead of coming back after the delay.
source_exchange_name is intentionally unused now — kept as a
parameter so existing call sites don't need updating.
Source code in src/rabbitkit/middleware/retry.py
546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 | |
CompressionMiddleware¶
CompressionMiddleware
¶
Bases: BaseMiddleware
Envelope/body transformation for compression.
Operates on MessageEnvelope (publish) and RabbitMessage.body (consume). NOT a handler-wrapping middleware — transforms data before/after serialize.
zstd contexts are not thread-safe, so a threading.local() holds a
per-thread ZstdCompressor/ZstdDecompressor (created lazily), giving
concurrent workers isolated contexts without a global lock. The
decompressed size is capped via streaming decompression that aborts as
soon as the running total exceeds max_decompressed_size (zip-bomb guard).
Source code in src/rabbitkit/middleware/compression.py
62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 | |
Methods:¶
compress(data: bytes) -> tuple[bytes, str | None]
¶
Compress data if above threshold.
Returns (compressed_data, content_encoding) or (original_data, None).
Source code in src/rabbitkit/middleware/compression.py
decompress(data: bytes, content_encoding: str | None) -> bytes
¶
Decompress data based on content_encoding header.
Raises ValueError as soon as the running decompressed total exceeds
max_decompressed_size (streaming zip-bomb guard).
Source code in src/rabbitkit/middleware/compression.py
on_receive(message: RabbitMessage) -> None
¶
Decompress incoming message body if content_encoding is set.
on_receive_async(message: RabbitMessage) -> None
async
¶
Async variant — offloads decompression to a worker thread.
Offload whenever there is decompression work to do (content_encoding is set) OR the body is large, to avoid inline-decompressing a small-on-the-wire bomb on the event loop.
Source code in src/rabbitkit/middleware/compression.py
publish_scope(call_next: Any, envelope: MessageEnvelope) -> Any
¶
Compress the envelope body before publishing (sync).
C4: without this override, transform_envelope had no caller —
attaching CompressionMiddleware to a route or a broker's
middlewares=[...] compressed nothing.
Source code in src/rabbitkit/middleware/compression.py
publish_scope_async(call_next: Any, envelope: MessageEnvelope) -> Any
async
¶
Async variant — compress the envelope body before publishing.
Compression runs inline (not offloaded to a worker thread) — unlike
on_receive_async's decompression, the body size here is caller-
controlled, not attacker-controlled, so there is no zip-bomb-style
amplification risk to guard against by offloading.
Source code in src/rabbitkit/middleware/compression.py
transform_envelope(envelope: MessageEnvelope) -> MessageEnvelope
¶
Compress envelope body and set content_encoding header.
Source code in src/rabbitkit/middleware/compression.py
SigningMiddleware¶
SigningMiddleware
¶
Bases: BaseMiddleware
Signs outgoing messages and verifies incoming signatures.
On publish: computes HMAC of body and adds signature to headers. On receive: verifies signature against body, rejects if invalid.
Source code in src/rabbitkit/middleware/signing.py
382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 | |
Methods:¶
publish_scope(call_next: Any, envelope: MessageEnvelope) -> Any
¶
Add HMAC signature to outgoing message headers.
publish_scope_async(call_next: Any, envelope: MessageEnvelope) -> Any
async
¶
Async variant — add HMAC signature.
on_receive(message: RabbitMessage) -> None
¶
Verify signature on incoming message.
Replay protection rules (see module docstring): * Freshness headers present → skew + nonce always enforced. * Headers absent + require_freshness=True → rejected (strict). * Headers absent + require_freshness=False → legacy body-only verify + warn.
Source code in src/rabbitkit/middleware/signing.py
570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 | |
SigningConfig
dataclass
¶
Configuration for message signing.
Attributes:
| Name | Type | Description |
|---|---|---|
secret_key |
str | bytes
|
Shared secret for HMAC computation. |
algorithm |
str
|
Hash algorithm ("hmac-sha256" or "hmac-sha512"). |
header_name |
str
|
AMQP header name for the signature. |
reject_unsigned |
bool
|
If True, reject messages without a signature. |
reject_invalid |
bool
|
If True, reject messages with invalid signatures. |
max_skew |
float
|
Max allowed |now - timestamp| skew in seconds (both past/future). Also the nonce's replay-window TTL (H4: tightened default of 60s — shrink further for high-value/financial traffic; a captured signature is replayable within this window on any process that has not already seen the nonce). |
require_freshness |
bool
|
If True (default), reject messages lacking freshness headers. If False, accept legacy body-only signatures with a warning. |
nonce_cache |
NonceCache | None
|
Pluggable nonce seen-set. |
Source code in src/rabbitkit/middleware/signing.py
InvalidSignatureError
¶
RateLimitMiddleware¶
RateLimitMiddleware
¶
Bases: BaseMiddleware
Limits message processing rate using a token bucket.
Behavior when rate is exceeded (configurable via on_limited): - "wait": Sleep until a token is available (default) - "nack": Reject message with requeue=True (another consumer can try) - "drop": Reject message with requeue=False (message is lost/goes to DLQ)
L5 — per-process scoping: the token bucket lives in this process's
memory. With worker_count > 1 consumer processes/pods sharing a
queue, each gets its own independent bucket — the effective CLUSTER
rate is workers x max_rate, not max_rate. Size max_rate
accordingly, or put a cluster-wide limiter in front (e.g. a
Redis-backed token bucket) if you need a hard global cap. Under
sustained overload, "wait" blocks for up to _wait_deadline (30s by
default) before falling back to a drop (nack, no requeue) — every
nack/drop/wait-timeout is logged at WARNING and, if you pass
metrics_collector, increments rate_limit_dropped_total labeled
by reason so sustained overload is observable instead of silent.
Source code in src/rabbitkit/middleware/rate_limit.py
125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 | |
Methods:¶
consume_scope(call_next: Any, message: RabbitMessage) -> Any
¶
Rate-limit sync message processing.
For on_limited="wait" the loop polls the token bucket until a token
is acquired or wait_deadline (seconds, monotonic) expires. If the
deadline elapses with no token, the message falls back to the configured
drop/nack semantics so the handler is never invoked without a token.
Source code in src/rabbitkit/middleware/rate_limit.py
consume_scope_async(call_next: Any, message: RabbitMessage) -> Any
async
¶
Rate-limit async message processing.
Mirrors the sync logic: the "wait" loop is bounded by
self._wait_deadline and falls back to drop semantics if no token is
acquired in time, so the handler is never called without a token.
Source code in src/rabbitkit/middleware/rate_limit.py
RateLimitConfig
dataclass
¶
Configuration for rate limiting.
Attributes:
| Name | Type | Description |
|---|---|---|
max_rate |
float
|
Maximum messages per second. |
burst |
int
|
Maximum burst size above steady rate. |
on_limited |
str
|
Behavior when rate exceeded: "wait", "nack", or "drop". |
Source code in src/rabbitkit/middleware/rate_limit.py
DeduplicationMiddleware¶
DeduplicationConfig.mark_policy controls when the dedup key is recorded
(accepts a DeduplicationMarkPolicy enum member or its string value):
| Value | Prevents concurrent duplicates | Crash-safe | Behaviour |
|---|---|---|---|
"on_success" (default) |
No | Yes | Key checked (no write) before the handler, stored only after it succeeds. A consumer killed mid-handler leaves no mark, so the redelivery is processed. Concurrent duplicates may both process (at-least-once). Use for most consumers. |
"claim" |
Yes | Yes¹ | Two-state: atomic in-flight claim (TTL = processing_timeout) before the handler, flipped to completed (full ttl) on success. A concurrent copy that sees a live claim is nack-requeued (default on_in_flight="nack_requeue"; "ack_skip" available) so it retries if the claiming consumer dies. A crash lets the claim expire. Use for sensitive side effects. |
"on_start" |
Yes | No — can lose messages | Key stored before the handler runs. If the process crashes after marking but before the handler finishes, the redelivery is skipped as a duplicate and the message is lost. Use only when duplicate execution is worse than message loss. |
¹ Provided processing_timeout comfortably exceeds the worst-case handler
duration — a handler that outlives its claim lets a duplicate start while it
is still running.
Deduplication is not a replacement for idempotent business logic: RabbitMQ is
an at-least-once system, and a handler can still run twice in some failure
scenarios (e.g. side effect completes but the ack fails, or a claim expires
under a slow handler). Keep a business-level guard — unique DB constraint,
payment_id/transaction_id, outbox or processed_events table — for
anything non-idempotent.
DeduplicationMiddleware
¶
Bases: BaseMiddleware
Idempotent consumer middleware backed by Redis SETNX.
Usage::
import redis
mw = DeduplicationMiddleware(
redis_client=redis.Redis(),
config=DeduplicationConfig(key_source="message_id", ttl=86400),
)
To prevent concurrent duplicate processing at the cost of retry safety::
mw = DeduplicationMiddleware(
redis_client=redis.Redis(),
config=DeduplicationConfig(mark_policy="on_start"),
)
Source code in src/rabbitkit/middleware/deduplication.py
131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 | |
Methods:¶
consume_scope(call_next: Callable[[RabbitMessage], Any], message: RabbitMessage) -> Any
¶
Sync: check dedup → skip duplicate → call handler.
Source code in src/rabbitkit/middleware/deduplication.py
consume_scope_async(call_next: Callable[[RabbitMessage], Awaitable[Any]], message: RabbitMessage) -> Any
async
¶
Async: check dedup → skip duplicate → call handler.
Source code in src/rabbitkit/middleware/deduplication.py
CircuitBreakerMiddleware¶
CircuitBreakerMiddleware
¶
Bases: BaseMiddleware
Wraps handler execution and publish operations with circuit breaker.
If circuit breaker is not provided (None), all operations pass through without any wrapping (no-op mode).
Usage::
# any CircuitBreakerProtocol-compatible implementation, e.g. pybreaker
cb = MyCircuitBreaker(name="rabbitmq", fail_max=5, reset_timeout=60)
middleware = CircuitBreakerMiddleware(circuit_breaker=cb)
# Or with separate publish CB:
middleware = CircuitBreakerMiddleware(
circuit_breaker=consume_cb,
publish_circuit_breaker=publish_cb,
)
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
circuit_breaker
|
Any | None
|
Circuit breaker for consume operations (handler execution). Must satisfy CircuitBreakerProtocol. None for no-op. |
None
|
publish_circuit_breaker
|
Any | None
|
Circuit breaker for publish operations. If None, uses circuit_breaker for both. If circuit_breaker is also None, publish operations pass through. |
None
|
async_circuit_breaker
|
Any | None
|
Async circuit breaker for consume operations. Must satisfy AsyncCircuitBreakerProtocol. If None, falls back to wrapping the sync circuit_breaker in async context. |
None
|
async_publish_circuit_breaker
|
Any | None
|
Async circuit breaker for publish operations. |
None
|
Source code in src/rabbitkit/middleware/circuit_breaker.py
29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 | |
Methods:¶
consume_scope(call_next: Callable[[RabbitMessage], Any], message: RabbitMessage) -> Any
¶
Wrap handler execution with circuit breaker (sync).
Source code in src/rabbitkit/middleware/circuit_breaker.py
consume_scope_async(call_next: Callable[[RabbitMessage], Awaitable[Any]], message: RabbitMessage) -> Any
async
¶
Wrap handler execution with circuit breaker (async).
Source code in src/rabbitkit/middleware/circuit_breaker.py
publish_scope(call_next: Callable[[MessageEnvelope], Any], envelope: MessageEnvelope) -> Any
¶
Wrap publish with circuit breaker (sync).
Source code in src/rabbitkit/middleware/circuit_breaker.py
publish_scope_async(call_next: Callable[[MessageEnvelope], Awaitable[Any]], envelope: MessageEnvelope) -> Any
async
¶
Wrap publish with circuit breaker (async).
Source code in src/rabbitkit/middleware/circuit_breaker.py
CircuitBreakerOpenError
¶
TimeoutMiddleware¶
TimeoutMiddleware
¶
Bases: BaseMiddleware
Enforces a maximum processing time per message.
Async: uses asyncio.wait_for() — clean cancellation.
Sync: runs call_next in a daemon threading.Thread and raises
HandlerTimeoutError if the thread is still alive at the deadline.
.. warning::
CPython cannot safely kill a thread, so a sync handler that exceeds the
timeout is abandoned — it keeps running detached until it finishes
naturally. This can leak threads and resources for long-running / blocked
IO handlers. Use an async handler for true cooperative cancellation.
When a sync timeout fires, a CRITICAL log record is emitted and the
optional on_timeout callback (if configured) is invoked so the
abandonment is observable (e.g. increment a metric counter).
Source code in src/rabbitkit/middleware/timeout.py
109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 | |
Methods:¶
consume_scope(call_next: Any, message: RabbitMessage) -> Any
¶
Sync timeout — runs call_next in a thread with timeout.
If the deadline elapses with the thread still alive, the thread is
abandoned (CPython cannot kill it), a CRITICAL log is emitted, the
abandoned_threads counter is incremented, on_timeout (if any)
is invoked, and HandlerTimeoutError is raised.
H9 — settlement is exclusively from the consumer (this) thread, never
the background thread, even under AckPolicy.MANUAL (where the
handler itself calls message.ack()/nack()/reject()):
while the background thread is running, message's settlement
functions are swapped for stand-ins that CAPTURE (rather than
execute) any settlement attempt made from that specific thread —
calling the real pika-backed function from there, while THIS thread
is blocked in thread.join() (i.e. not pumping the connection's
I/O loop), can deadlock: the settlement call would marshal onto this
thread and wait for it to drain the callback, but this thread is
itself waiting on the background thread to finish. If the background
thread finishes within the deadline, any settlement it captured is
replayed for real on this (consumer/owner) thread, safely, after
join() returns. If it does not, the guards stay installed
(deliberately NOT restored — the background thread may still call
ack()/nack()/reject() at any point later) but their thread-identity
check means any FUTURE call specifically from that thread is
discarded, while THIS thread's own subsequent settlement (e.g. via
AckPolicy/RetryMiddleware after HandlerTimeoutError propagates
below) is routed straight to the real fn — safe, since it's a
same-thread call.
Source code in src/rabbitkit/middleware/timeout.py
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | |
consume_scope_async(call_next: Any, message: RabbitMessage) -> Any
async
¶
Async timeout — uses asyncio.wait_for().
Source code in src/rabbitkit/middleware/timeout.py
MetricsMiddleware¶
MetricsMiddleware
¶
Bases: BaseMiddleware
Tracks consume and publish metrics via a pluggable MetricsCollector.
If collector is None, all operations pass through without
any overhead (no-op mode).
Usage::
collector = PrometheusCollector()
middleware = MetricsMiddleware(collector)
Or with a custom collector::
class MyCollector:
def inc_counter(self, name, labels, value=1.0): ...
def observe_histogram(self, name, labels, value): ...
middleware = MetricsMiddleware(MyCollector())
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
collector
|
MetricsCollector | None
|
Any object satisfying the MetricsCollector protocol. None for no-op (passthrough) mode. |
None
|
Source code in src/rabbitkit/middleware/metrics.py
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 | |
Attributes¶
collector: MetricsCollector | None
property
¶
The configured collector (None in no-op mode). Read by
HandlerPipeline/RetryMiddleware to emit settlement/retry
metrics they observe but this middleware itself cannot (M2) --
settlement happens in the pipeline's own ack-orchestration code,
outside this middleware's consume_scope.
Methods:¶
record_settlement(message: RabbitMessage, disposition: str) -> None
¶
Emit the ack/nack/reject counter for a settled message (M2).
consume_scope/consume_scope_async only wrap handler
execution -- final settlement (ack/nack/reject per AckPolicy) is
decided by the pipeline's own ack-orchestration code, which runs
AFTER this middleware's wrapped call returns. HandlerPipeline
calls this directly once a route's message is settled, if a
MetricsMiddleware is present on that route.
Source code in src/rabbitkit/middleware/metrics.py
consume_scope(call_next: Callable[[RabbitMessage], Any], message: RabbitMessage) -> Any
¶
Wrap handler execution with metrics tracking (sync).
Source code in src/rabbitkit/middleware/metrics.py
consume_scope_async(call_next: Callable[[RabbitMessage], Awaitable[Any]], message: RabbitMessage) -> Any
async
¶
Wrap handler execution with metrics tracking (async).
Source code in src/rabbitkit/middleware/metrics.py
publish_scope(call_next: Callable[[MessageEnvelope], Any], envelope: MessageEnvelope) -> Any
¶
Wrap outgoing publish with metrics tracking (sync).
Source code in src/rabbitkit/middleware/metrics.py
publish_scope_async(call_next: Callable[[MessageEnvelope], Awaitable[Any]], envelope: MessageEnvelope) -> Any
async
¶
Wrap outgoing publish with metrics tracking (async).
Source code in src/rabbitkit/middleware/metrics.py
MetricsCollector
¶
Bases: Protocol
Protocol for metrics collection — works with Prometheus, StatsD, etc.
Source code in src/rabbitkit/middleware/metrics.py
PrometheusCollector
¶
Concrete MetricsCollector that wraps the prometheus_client library.
The prometheus_client import is lazy — the library is only required
when this class is instantiated, not when the module is imported.
Usage::
collector = PrometheusCollector()
middleware = MetricsMiddleware(collector)
Source code in src/rabbitkit/middleware/metrics.py
Methods:¶
inc_counter(name: str, labels: dict[str, str], value: float = 1.0) -> None
¶
Increment a Prometheus counter.
Source code in src/rabbitkit/middleware/metrics.py
observe_histogram(name: str, labels: dict[str, str], value: float) -> None
¶
Observe a value on a Prometheus histogram.
Source code in src/rabbitkit/middleware/metrics.py
set_gauge(name: str, labels: dict[str, str], value: float) -> None
¶
Set a Prometheus gauge to an absolute value.
Source code in src/rabbitkit/middleware/metrics.py
metrics_app() -> Callable[[Any, Any, Any], Awaitable[None]]
¶
Return a minimal ASGI app that exposes /metrics in Prometheus text format.
Requires prometheus_client (lazy-imported on first request). Mount it
behind your existing ASGI server (uvicorn, hypercorn) or the dashboard::
from rabbitkit.middleware.metrics import metrics_app
app = metrics_app()
# uvicorn rabbitkit.middleware.metrics:metrics_app (after binding the factory)
For a stdlib one-liner without an ASGI server, see :func:start_metrics_server.
Source code in src/rabbitkit/middleware/metrics.py
start_metrics_server(port: int = 8000, host: str = '127.0.0.1') -> None
¶
Start a background HTTP server exposing /metrics on port.
Thin wrapper around prometheus_client.start_http_server. Call once at
process startup (e.g. in a RabbitApp.on_startup hook). For k8s, scrape
this port with a ServiceMonitor / PodMonitor.
The default host is 127.0.0.1 (loopback only) so the metrics
endpoint is not exposed to the network by default. For k8s / multi-host
scrapers pass host="0.0.0.0" explicitly and restrict access with a
NetworkPolicy (the metrics endpoint is unauthenticated and exposes broker
topology/throughput — never expose it publicly without authn in front).
Requires prometheus_client.
Source code in src/rabbitkit/middleware/metrics.py
ExceptionMiddleware¶
ExceptionMiddleware
¶
Bases: BaseMiddleware
Outermost middleware. Catches exceptions after retry gives up.
Features: - Register exception handlers with fallback values - Terminal exceptions (from retry exhaustion) are re-raised by default - swallow_permanent=True opts in to swallowing permanent/exhausted failures
MANUAL mode restriction: - MAY log the error - MUST NOT auto-publish fallback to result_publisher unless msg.is_settled - MUST NOT settle the message
Source code in src/rabbitkit/middleware/exception.py
Methods:¶
add_handler(exc_type: type[BaseException], handler: Callable[[BaseException], Any]) -> None
¶
Register an exception handler with a fallback return value.
consume_scope(call_next: Callable[[RabbitMessage], Any], message: RabbitMessage) -> Any
¶
Wrap handler — catch exceptions, provide fallback values.
Source code in src/rabbitkit/middleware/exception.py
consume_scope_async(call_next: Callable[[RabbitMessage], Awaitable[Any]], message: RabbitMessage) -> Any
async
¶
Async variant — catch exceptions, provide fallback values.
Source code in src/rabbitkit/middleware/exception.py
OTelTracingMiddleware¶
Native OpenTelemetry tracing (pip install rabbitkit[otel]) — W3C
traceparent propagation over AMQP headers, CONSUMER/PRODUCER spans, no
org-internal packages required.
OTelTracingMiddleware
¶
Bases: BaseMiddleware
Wrap handler execution and publishes in standard OpenTelemetry spans.
- Consume: extracts W3C trace context from message headers and starts a
CONSUMER-kind span as its child. Handler exceptions are recorded on the span (statusERROR) and re-raised. - Publish: starts a
PRODUCER-kind span and injects the current trace context into a COPY of the envelope's headers (envelopes are frozen).
Span names follow the OTel messaging convention: {destination} receive
/ {destination} send.
Source code in src/rabbitkit/middleware/otel.py
39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | |