A relay service between incoming HTTP requests and a backend.
Accepts a request → enqueues it in RabbitMQ → forwards it to the backend under a rate limit → on failure, retries with increasing backoff (retry ladder) → once attempts are exhausted, moves the task to a dead queue.
HTTP client → POST /queue/tasks → tasks.main (RabbitMQ)
│
▼
rate limit (10 / 3 sec)
│
▼
backend
success │ │ failure
│ ▼
│ retry ladder
│ (instant → 3s → 15s → 60s → 120s → 240s)
│ │
▼ ▼ (attempts exhausted)
done tasks.failed
- Ingest.
POST /queue/tasksaccepts JSON, publishes it to thetasks.mainqueue, and immediately responds202 Accepted— without waiting for the task to actually reach the backend. - Processing. The consumer reads from
tasks.main, waits for a free slot in the rate limiter, and sends the request to the backend (backendBaseUrl+backendPath). - Failure. Any error (network issue, timeout, non-2xx response)
triggers a retry. The task is republished with an incremented attempt
counter, either straight back to
tasks.mainor into one of the delay queues. - Delay without a scheduler. Delay queues have no consumers — a
message just sits there for
x-message-ttlmilliseconds, after which RabbitMQ itself ("dead letters") moves it back totasks.main. No cron job or external scheduler is needed — it's a built-in broker mechanism. - Final failure. Once all attempts are exhausted, the task moves to
tasks.failedand is no longer touched automatically.
| Level | Attempts | Delay before attempt | Queue |
|---|---|---|---|
| 1 | 2 | instant | tasks.main |
| 2 | 5 | 3 sec | tasks.retry.3s |
| 3 | 5 | 15 sec | tasks.retry.15s |
| 4 | 5 | 60 sec | tasks.retry.60s |
| 5 | 5 | 120 sec | tasks.retry.120s |
| 6 | 5 | 240 sec | tasks.retry.240s |
27 attempts total per task, after which it moves to tasks.failed.
The ladder is declared as data in one place, so levels are easy to add or adjust:
final RetryStage[] retryStages = [
{attempts: 2, delaySec: 0, queueName: ()},
{attempts: 5, delaySec: 3, queueName: "tasks.retry.3s"},
{attempts: 5, delaySec: 15, queueName: "tasks.retry.15s"},
{attempts: 5, delaySec: 60, queueName: "tasks.retry.60s"},
{attempts: 5, delaySec: 120, queueName: "tasks.retry.120s"},
{attempts: 5, delaySec: 240, queueName: "tasks.retry.240s"}
];No more than 10 requests to the backend per 3 seconds (fixed window) — protects the backend from being overwhelmed, especially right after it recovers from downtime, when a backlog of delayed retries can hit it at once.
- Implemented in the consumer's code (
RateLimiter), not on the broker side — RabbitMQ has no built-in time-based rate limiting, onlyprefetch(a cap on how many unacknowledged messages a consumer can hold). prefetchCount(default 20) — how many messages the consumer holds at once; acts as a buffer above the rate limit so it doesn't sit idle waiting for new messages from the broker.- While no slot is free, the consumer doesn't acknowledge (
ack) the message — RabbitMQ won't hand it more messages beyondprefetchCount, so the backlog piles up in the broker instead of hammering the backend.
⚠️ The limiter lives in process memory and is only accurate with a single replica of the service. When scaling to multiple replicas, either enablex-single-active-consumer(already set ontasks.main) or move the rate limiter to shared external storage (e.g. Redis) to keep instances in sync.
| Queue | Consumed by | Purpose |
|---|---|---|
tasks.main |
this service | working queue, actual processing happens here |
tasks.retry.3s |
nobody (TTL only) | delays a task by 3 sec |
tasks.retry.15s |
nobody (TTL only) | delays a task by 15 sec |
tasks.retry.60s |
nobody (TTL only) | delays a task by 60 sec |
tasks.retry.120s |
nobody (TTL only) | delays a task by 120 sec |
tasks.retry.240s |
nobody (TTL only) | delays a task by 240 sec |
tasks.failed |
nobody (yet) | tasks that exhausted all attempts |
The entire topology is created automatically on startup (setupTopology())
— nothing needs to be set up manually in RabbitMQ.
All parameters are configurable and can be overridden at runtime:
| Parameter | Default | Description |
|---|---|---|
inboundPort |
8080 |
inbound HTTP port |
rabbitHost |
localhost |
RabbitMQ host |
rabbitPort |
5672 |
RabbitMQ port |
rabbitUser |
guest |
RabbitMQ username |
rabbitPassword |
guest |
RabbitMQ password |
backendBaseUrl |
http://localhost:8090 |
backend base URL |
backendPath |
/process |
backend endpoint path |
backendTimeout |
5 |
backend request timeout, sec |
rateLimitMaxRequests |
10 |
requests allowed per window |
rateLimitWindowSec |
3 |
rate limit window size, sec |
prefetchCount |
20 |
messages buffered per consumer |
bal runPOST /queue/tasks
Content-Type: application/json
{ ...arbitrary JSON... }
Responds with 202 Accepted right after the task is queued — not after it's
actually processed by the backend.
- Scaling: when adding a second replica, keep in mind
x-single-active-consumer(already enabled ontasks.main) and that the rate limiter isn't distributed yet — see the warning above. - RabbitMQ cluster: queue arguments (TTL, DLX) are immutable after
creation — changing them requires deleting the old queue in the broker
before redeploying, otherwise
queueDeclarefails withPRECONDITION_FAILED.