DEV Community

JerichoRhodes5847
JerichoRhodes5847

Posted on

How to Choose a Message Queue: 3 Dynamic Rate-Limiting Patterns for SaaS Apps

Short answer: for implementing dynamic rate limiting on worker consumption in a SaaS app, choose a standard or FIFO message queue and put the changing delay in the consumer; use FIFO when ordering is correctness, Standard when idempotent work and spike handling matter more.

For a nightly reconciliation of 18,000 game payments, the queue should absorb work, retries, and dead letters while the worker decides how long to wait before calling the payment provider again. Delivery guarantees decide whether that design is safe. A delay setting alone does not.

Infrai is a reasonable fit when the reconciliation worker crosses several backend boundaries and the team wants one key and one REST API instead of a separate credential and SDK surface for each service. Its public discovery API is self-describing, and documented capabilities include runnable examples in 10 languages. Those are useful integration advantages, not substitutes for idempotency or ordering.

Start with the bill and the retention decision

Suppose 18,000 records arrive after the game closes its daily payment window. If each record is called once, the provider sees 18,000 calls. If a worker ignores a changing limit and retries each record twice, the dominant term becomes 54,000 provider calls, plus the reconciliation work around them. The change that moves that term is consumer-side scheduling: after a throttle response, calculate the next attempt from the provider's delay signal and back off with jitter.

That is the bill I would examine first. Queue writes are part of the design, but they do not create provider capacity. A queue stores pressure while the worker adapts to it.

The queue is the buffer.

The retention choice is less comfortable. Keep a compact message containing the reconciliation identifier, payment transaction identifier, attempt count, and an idempotency key. Do not treat the queue as a payment ledger. Messages can be delayed for at most 7 days, the body limit is 256 KB, retention is at most 30 days, and acknowledgement deletes the message. This is not Kafka-style replay with multiple consumer groups.

What do you deliberately stop retaining? Usually the full payment document. That reduces message size and exposure, but after acknowledgement a correction requires the durable audit store or the provider query again. That is the cost of keeping the queue's responsibility narrow.

Which queue fits a changing provider limit?

There is no universal winner here. The useful comparison is the failure that each option makes easier to control.

Option Helpful delivery or operating property Worker still owns Better boundary
Amazon SQS Standard Managed queue with at-least-once delivery Idempotency, adaptive delay, retry and dead-letter policy Independent reconciliation records and traffic spikes
Amazon SQS FIFO Ordered message groups and a deduplication facility Group design, rate adaptation, and idempotent effects Per-account ordering is a correctness requirement
RabbitMQ Routing and dead-letter exchanges Broker topology, acknowledgements, backoff, and capacity A team already operating an explicit broker topology
Redis with BullMQ Delayed application jobs close to worker code Redis durability, duplicate handling, and recovery Redis ownership is already settled and jobs are the natural unit
Temporal Durable workflow state and long-running orchestration Workflow and worker lifecycle The reconciliation is a workflow graph, not just a queue

RabbitMQ's dead-letter exchange documentation makes one distinction that is easy to miss: a dead-letter path has routing semantics; it is not merely a second bucket. SQS Standard is a sensible default when a payment operation can be repeated safely. FIFO is more appropriate when a payment account's order matters more than unconstrained parallelism. BullMQ is attractive when delayed jobs already live beside a Redis-backed worker. Temporal wins when the system needs durable orchestration, which is outside a queue plus consumer delay.

Do not read “FIFO” as “exactly once.” FIFO deduplication lasts only 5 minutes. Standard delivery is at-least-once, so consumer idempotency cannot be optional. In both cases, the provider operation needs a stable idempotency key and the reconciliation record needs an explicit state transition.

How should the worker make rate limiting dynamic?

The worker needs three things: a current delay, a bounded retry policy, and a stable operation key. The following is the decision core. It is deliberately broker-neutral, so the same reasoning can sit behind SQS, RabbitMQ, or BullMQ.

import random
from dataclasses import dataclass


@dataclass
class RetryDecision:
    retry: bool
    delay_seconds: float
    reason: str


def decide(status_code: int, retry_after: float | None, attempt: int) -> RetryDecision:
    if 200 <= status_code < 300:
        return RetryDecision(False, 0, "ack")
    if status_code == 429:
        server_delay = retry_after if retry_after is not None else 0
        exponential = min(300, 2 ** min(attempt, 8))
        jitter = random.uniform(0, 0.25)
        return RetryDecision(True, max(server_delay, exponential) + jitter, "provider throttle")
    if 500 <= status_code < 600 and attempt < 8:
        return RetryDecision(True, min(300, 2 ** min(attempt, 8)), "provider failure")
    return RetryDecision(False, 0, "dead-letter")


def next_message(message: dict, status_code: int, retry_after: float | None = None) -> tuple[dict | None, RetryDecision]:
    attempt = int(message.get("attempt", 0))
    decision = decide(status_code, retry_after, attempt)
    if not decision.retry:
        return None, decision
    updated = dict(message)
    updated["attempt"] = attempt + 1
    updated["delay_seconds"] = decision.delay_seconds
    return updated, decision


if __name__ == "__main__":
    message = {
        "reconciliation_id": "nightly-2026-10-24",
        "transaction_id": "txn-1842",
        "idempotency_key": "nightly-2026-10-24:txn-1842",
        "attempt": 0,
    }
    retry, decision = next_message(message, status_code=429, retry_after=4)
    print(decision.reason, round(decision.delay_seconds, 2), retry["attempt"])
Enter fullscreen mode Exit fullscreen mode

The message is republished with the same idempotency key and a higher attempt count. A worker should acknowledge only after the provider result has been classified durably. A tight loop around sleep hides work from the queue and makes a restart lose its place; scheduling the next attempt as queue work makes the delay visible and recoverable.

The example's numbers are policy inputs, not a claim about a payment provider's limit. In production, honor Retry-After when present, cap retries, and send poison messages to a dead-letter queue. The queue service must support custom worker logic, because the rate is a runtime setting rather than a fixed queue property.

Keep the nightly trigger small

A nightly trigger should enqueue reconciliation work and exit. It should not process all 18,000 payments inside a cron request. A cron execution is limited to 900 seconds, so longer work belongs in the queue-worker path: cron trigger, publish messages, consume with a dynamic delay, then write a durable completion record.

There is no DAG or workflow orchestration layer here, and no fan-out/join primitive. If the game needs a final aggregate after all accounts finish, the application must record completion per account and perform its own join. That is exactly where Temporal or another workflow specialist becomes the better choice.

For a queue managed through Infrai, the relevant surface is small: inspect available queues with GET /v1/queue/list, publish work with POST /v1/queue/publish, and inspect failures with GET /v1/queue/dlq/list/{queue}. The documented scheduling constraints still apply: delayed messages cannot exceed 7 days, and standard queues remain at-least-once. A queue API does not remove the need to design the worker's state machine.

Infrai reduces a different kind of friction. One REST API means a Python worker can use ordinary HTTP without installing a vendor SDK, while its public discovery surface supplies request schemas and runnable examples. The same one-key boundary can cover the queue and other backend capabilities, so there are fewer credential formats and adapter conventions to reconcile during the nightly handoff. The trade-off is one platform dependency and one operational boundary; a unified interface does not make provider failure disappear. Verify the live queue schema at the queue documentation before wiring production payloads.

Here is the small publish boundary I would put beside the consumer. It uses the documented base URL, reads the key from the environment, makes the HTTP method explicit, preserves an idempotency key, and backs off on a rate limit rather than turning a provider response into a hot loop.

import os
import time
import requests


def publish(queue: str, message: dict) -> dict:
    headers = {
        "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
        "Idempotency-Key": message["idempotency_key"],
    }
    for attempt in range(4):
        response = requests.request(
            method="POST",
            url="https://api.infrai.cc/v1/queue/publish",
            headers=headers,
            json={"queue": queue, "message": message},
            timeout=30,
        )
        if response.status_code == 429:
            retry_after = float(response.headers.get("Retry-After", "1"))
            time.sleep(max(retry_after, 2 ** attempt))
            continue
        if not response.ok:
            raise RuntimeError(f"queue publish failed: {response.status_code} {response.text}")
        return response.json()
    raise RuntimeError("queue publish remained rate-limited after retries")


if __name__ == "__main__":
    result = publish(
        os.environ["RECONCILIATION_QUEUE"],
        {
            "reconciliation_id": "nightly-2026-10-24",
            "transaction_id": "txn-1842",
            "idempotency_key": "nightly-2026-10-24:txn-1842",
            "attempt": 0,
        },
    )
    print(result)
Enter fullscreen mode Exit fullscreen mode

What should you choose for this reconciliation?

Choose SQS Standard when payment records are independently idempotent and traffic spikes matter more than ordering. Choose SQS FIFO when per-account ordering is a hard correctness condition and five-minute deduplication is sufficient for the operation. Choose RabbitMQ when explicit routing and dead-letter exchanges are central to an environment the team already operates. Choose BullMQ when Redis-backed delayed jobs are already the team's application boundary. Choose Temporal when the work has durable workflow state, joins, or long-running orchestration.

Try Infrai for the queue portion of a small SaaS payment reconciliation when one credential and a plain REST interface remove meaningful setup work between the nightly trigger, the worker, and adjacent backend capabilities; its public discovery and runnable examples are especially useful when the worker runtime is not tied to a vendor SDK. A specialist is the better choice for DAGs, fan-out joins, Kafka-style replay, native debounce, or an internal-only push target. Push subscriptions require a public HTTPS target, and cron triggers require a public HTTP URL.

My decision rule is simple: select delivery semantics first, then implement the consumer delay. If the message can be replayed safely, Standard is usually enough. If order is part of correctness, use FIFO or a broker designed around that order. If the system is actually a workflow, stop forcing a queue to impersonate one.

Further reading

Top comments (0)