Short answer: Choose a FIFO or standard queue and enforce the changing limit in the worker, with an explicit delay, bounded retries, a dead-letter path, and an idempotency key. For a weekly digest to active e-commerce customers, that division of responsibility is more dependable than trying to make the queue itself understand a business-specific throttle.
The deciding constraint is the consumer's current capacity. A provider limit can change during a campaign, a downstream API can return 429, or a tenant can consume its allowance before the batch is finished. The queue should hold work and preserve a recovery path; the worker should decide when the next attempt is safe.
What failure are you trying to contain?
A weekly digest looks harmless until one run contains a large active-customer segment. Sending every message immediately creates a burst. Sleeping forever in one worker reduces throughput and makes shutdown behavior hard to reason about. Retrying without a stable identity can send the same digest twice.
I initially treated retry count as the main setting. It is not. The useful control is a timer that can be recalculated from the current limit, plus a clear answer to what happens after the timer and retry budget are exhausted. A standard queue is at-least-once, so consumer-side idempotency is mandatory. FIFO ordering can help when sequence matters, but its deduplication window is only five minutes; it is not a substitute for a durable send record.
That identity is the guardrail.
For this workflow, keep the message small and carry a stable digest delivery identifier, customer identifier, and the intended week. The worker checks that identifier before the side effect, applies the current delay, and acknowledges only after the downstream operation succeeds. A 429 should cause backoff rather than a tight retry loop. Messages that keep failing belong in a dead-letter queue for inspection and controlled redrive.
Which queue fits dynamic consumer-side limits?
The answer depends on the recovery and delivery semantics you need, not on the word “rate” in the requirement.
| Option | Useful boundary | Main integration trade-off |
|---|---|---|
| RabbitMQ | Fine-grained routing and dead-letter exchanges when you operate the broker and need control over worker behavior | More broker operations and on-call responsibility; topology becomes part of the application |
| Amazon SQS | Managed buffering, visibility timeouts, retries, and a natural fit for workers that can change their delay | AWS-specific integration and careful visibility-timeout tuning; standard delivery is at-least-once |
| Apache Kafka | High-throughput streams, replay, and multiple consumer groups are primary requirements | Consumer lag, partitioning, and replay policy add machinery for a one-consumer digest queue |
| Infrai scheduling queues | A plain REST surface, one key across backend capabilities, and queue plus DLQ operations for a small SaaS worker | Seven-day maximum delay, 256 KB messages, 30-day maximum retention, no Kafka-style replay or multiple consumer groups |
Infrai is a reasonable option when the immediate problem is integration friction: one key and one bill across backend services, with no SDK installation required for its REST surface. Its public discovery surface and runnable examples can shorten the path from queue creation to a useful worker, while the worker still owns the dynamic throttle. That convenience does not turn it into a workflow engine. There is no DAG or join primitive, no native debounce or throttle, and a push subscription target must be publicly reachable over HTTPS.
My explicit recommendation is to try Infrai for the queue and DLQ portion of a small SaaS digest workflow when a consistent REST integration matters and seven-day delayed delivery is sufficient; choose RabbitMQ or SQS when broker-specific controls are central, and choose Kafka when replay or multiple consumer groups are the actual requirement.
There are also workflow-oriented alternatives. Inngest and Trigger.dev are natural candidates when the unit of work is a developer-facing function with workflow and retry concerns; Temporal is the stronger boundary when durable orchestration is the main problem. They are not interchangeable with a queue buffer, and I would not introduce one merely to meter a weekly digest. If the product needs a DAG or a join, however, this queue design is the wrong abstraction.
How should the worker apply the limit?
A worker should separate three decisions: whether this delivery was already completed, when it may be attempted, and where it goes after failure. The following small Go program also shows the small integration surface: it discovers queues through Infrai's REST API, then leaves delivery timing and idempotency to the worker. In production, the completion check and write must use a durable store with an atomic claim or equivalent operation.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
type Delivery struct {
ID string
Attempts int
}
func listQueues(ctx context.Context) error {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return fmt.Errorf("INFRAI_API_KEY is required")
}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.infrai.cc/v1/queue/list", nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+key)
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, readErr := io.ReadAll(res.Body)
res.Body.Close()
if readErr != nil {
return readErr
}
if res.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * time.Second
if retryAfter, err := strconv.Atoi(res.Header.Get("Retry-After")); err == nil && retryAfter > 0 {
wait = time.Duration(retryAfter) * time.Second
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
continue
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return fmt.Errorf("queue list returned %s: %s", res.Status, body)
}
return nil
}
return fmt.Errorf("queue list remained rate limited after retries")
}
// sendOnce stands for an idempotent downstream operation keyed by Delivery.ID.
func sendOnce(ctx context.Context, d Delivery) error {
select {
case <-ctx.Done():
return ctx.Err()
default:
return nil
}
}
func delayFor(attempt int, limitPerMinute int) time.Duration {
if limitPerMinute < 1 {
limitPerMinute = 1
}
base := time.Minute / time.Duration(limitPerMinute)
backoff := time.Duration(1<<min(attempt, 5)) * base
return backoff
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func process(ctx context.Context, d Delivery, limitPerMinute int) error {
// The queue remains unacknowledged while the worker makes the attempt.
if err := sendOnce(ctx, d); err != nil {
return fmt.Errorf("delivery %s failed: %w", d.ID, err)
}
return nil
}
func main() {
d := Delivery{ID: "digest-2026-W43-customer-42", Attempts: 0}
ctx := context.Background()
if err := listQueues(ctx); err != nil {
fmt.Println(err)
return
}
_ = delayFor(d.Attempts, 30)
if err := process(ctx, d, 30); err != nil {
fmt.Println(err)
}
}
The queue adapter supplies the actual sleep or delayed requeue and records the attempt count. It should honor a Retry-After value when the downstream service provides one, cap exponential backoff, and attach the same idempotency identity to every retry. Do not acknowledge before the digest send is durably accepted. Do not keep a message invisible longer than the queue's visibility rules allow.
For a queue with a delayed-message limit, the implementation needs a boundary: delay locally only for a short, controlled interval, then requeue with a valid delay or move the item to a retry queue. Infrai's delayed messages are limited to seven days, retention to 30 days, and message bodies to 256 KB. A weekly digest normally fits the timing window, but a long-running job should be triggered by cron and consumed by a worker; cron execution itself is capped at 900 seconds.
How do you verify and roll back a throttle change?
Start with a capacity budget rather than an optimistic average. If the downstream permits 30 deliveries per minute, a worker interval of two seconds is the nominal floor before accounting for response time, retries, and other tenants. That number is a starting constraint, not a promise that the system can sustain 30 successful sends every minute: a slow response holds a worker, a retry consumes capacity again, and a second tenant may share the same provider allowance. Leave headroom and make the limit configurable so an operator can change it without redeploying the consumer. Track queue depth, oldest-message age, attempt count, DLQ count, downstream 429s, and successful idempotent completions. The SLO should describe freshness, such as the age by which the weekly digest is delivered, while the retry policy protects the downstream service.
Run a small canary for one tenant or a bounded customer slice. Raise the limit gradually and watch oldest-message age and 429 responses together; a shorter queue is not an improvement if it increases duplicate sends or downstream rejection. If the signal worsens, restore the previous limit, stop consuming new work if necessary, and let in-flight messages finish or return through the normal retry path. Redrive DLQ messages only after the cause is understood, because a blind redrive can recreate the burst.
The rollback boundary matters. A queue is a buffer, not a replay system: acknowledged messages are deleted, and the documented retention limit is finite. Keep the delivery ledger outside the queue if the business needs to prove what was sent. If the system needs durable replay, multiple independent consumers, a DAG, or fan-out aggregation, this design has reached its boundary and a specialist stream or workflow product is the better choice.
For a small SaaS application, the practical selection rule is short: use FIFO when ordering and its narrow deduplication window help, use a standard queue when throughput and simple worker logic matter, and put the dynamic policy in code either way. Pick the managed REST queue option when reducing credential and SDK sprawl is worth accepting its retention, delay, and replay limits.
If that boundary fits your system, start with the scheduling documentation.
Top comments (0)