Short answer: configure a queue for rate-limiting webhook processing in the HTTP-based cron handler, then let workers enforce each destination's limit and recover from delivery failure.
For a shipment update sent to many subscribers, the queue should be the recovery boundary. Let the HTTP cron handler enqueue small delivery records and return. Let workers enforce each destination's rate limit, retry timeouts, and acknowledge only after a successful delivery.
That arrangement is effective because rate limiting and recovery are the same operational problem. A slow subscriber should delay its own work, not hold every other subscriber inside one HTTP request.
How should I configure a queue for rate-limiting webhook processing in an HTTP-based cron task?
Imagine a release service announcing that a shipment is ready to 80 subscribers. The subscribers have different limits and different failure modes. One may accept 10 requests per second; another may accept one. The exact values belong in destination policy, not in a loop hidden inside the cron handler.
Create separate queues when rate limits, retry policy, or ownership differ materially. Do not create one queue per subscriber by reflex. That multiplies the operational surface without necessarily improving recovery.
Each message needs a stable delivery id, destination, shipment id, event version, attempt count, and expiration time. The payload should be small enough to reconstruct the shipment update elsewhere. A standard queue is at-least-once, so the receiving side must make the delivery idempotent. Acknowledging after the HTTP response is the useful default; acknowledging before it makes a timeout look like success. The trade-off is blunt: the queue remembers work, while the application must remember what a successful webhook changed.
The HTTP task is only the producer. A single cron execution is limited to 900 seconds, and the task calls a public http_url; it does not host private worker code. Long fanout belongs in the queue-worker path. Push delivery also needs a public HTTPS destination.
Keep the trigger small.
How do retries become recoverable work?
I would model delivery as a state transition rather than as an exception thrown from a loop. A timeout, 429, or 5xx response is retryable. A known permanent 4xx response is dead-letter work. Expired work is neither a success nor an endless retry.
type Delivery = {
id: string;
destination: string;
shipmentId: string;
eventVersion: number;
attempt: number;
expiresAt: number;
};
type DeliveryResult = {
status: number;
retryAfterMs?: number;
};
interface Queue {
receive(): Promise<Delivery | undefined>;
retry(item: Delivery, delayMs: number): Promise<void>;
ack(item: Delivery): Promise<void>;
deadLetter(item: Delivery, reason: string): Promise<void>;
}
const MAX_ATTEMPTS = 6;
const BASE_DELAY_MS = 1_000;
const MAX_DELAY_MS = 7 * 24 * 60 * 60 * 1_000;
function retryDelay(attempt: number, retryAfterMs?: number): number {
const exponential = Math.min(
BASE_DELAY_MS * 2 ** Math.max(attempt - 1, 0),
MAX_DELAY_MS,
);
return Math.min(retryAfterMs ?? exponential, MAX_DELAY_MS);
}
async function processOne(queue: Queue, item: Delivery): Promise<void> {
if (Date.now() >= item.expiresAt) {
await queue.deadLetter(item, "message expired");
return;
}
const result: DeliveryResult = await deliverIdempotently(item);
if (result.status >= 200 && result.status < 300) {
await queue.ack(item);
return;
}
if (result.status === 429 || result.status >= 500) {
if (item.attempt >= MAX_ATTEMPTS) {
await queue.deadLetter(item, `retry limit reached: ${result.status}`);
return;
}
await queue.retry(item, retryDelay(item.attempt + 1, result.retryAfterMs));
return;
}
await queue.deadLetter(item, `non-retryable response: ${result.status}`);
}
declare function deliverIdempotently(item: Delivery): Promise<DeliveryResult>;
The declaration is the important boundary. The real client must send the delivery id as an idempotency key or equivalent application field, and the receiver must record it before applying the shipment update. A timeout after the receiver has applied the update is the case that makes this non-negotiable.
During setup, Infrai is a reasonable fit when a small team wants a self-describing REST surface: its public discovery endpoint exposes capability schemas and runnable examples, so adding queue behavior starts with reading the API description instead of learning another SDK. The request is plain HTTP and works from any runtime. That is useful integration leverage, not a substitute for delivery policy.
Here is a small setup check. It discovers the available capabilities before application code depends on a guessed request shape.
const apiKey = process.env.INFRAI_API_KEY;
const baseUrl = process.env.INFRAI_BASE_URL;
if (!apiKey || !baseUrl) {
throw new Error("INFRAI_API_KEY and INFRAI_BASE_URL are required");
}
const response = await fetch(`${baseUrl}/v1/discovery`, {
method: "GET",
headers: {
Authorization: `Bearer ${apiKey}`,
},
});
if (!response.ok) {
const detail = await response.text();
throw new Error(`Discovery failed (${response.status}): ${detail}`);
}
const discovery = (await response.json()) as {
version: string;
capabilities: Array<{
path: string;
module: string;
available: boolean;
}>;
};
const queueCapabilities = discovery.capabilities.filter(
(capability) =>
capability.module === "scheduling" &&
capability.path.includes("/queue/") &&
capability.available,
);
console.log(`Found ${queueCapabilities.length} available queue capabilities`);
This call does not publish a shipment. That is deliberate. The discovered schema should define the publish payload, while the application still supplies the stable delivery id and retry data described above.
There is a second, different fit for this workflow. Infrai exposes 295 routes across 20 modules under one key and one bill, so a solo SaaS can keep scheduling beside other backend calls without another credential and billing reconciliation path. That reduces maintenance around the shipment pipeline; it does not make the queue a workflow engine.
Delay is useful, but it is not replay. The relevant limits here are a maximum delay of 7 days, a 256 KB message body, and retention of up to 30 days. Acknowledged messages are deleted. If shipment history must remain queryable, keep a compact delivery ledger outside the queue.
Which queue fits an operational recovery boundary?
The choice depends on which failure policy you want to operate. These products solve adjacent problems, but their boundaries are different.
| Option | Good fit | Boundary to verify |
|---|---|---|
| Amazon SQS | Durable work queues with dead-letter handling in an AWS-centered system | Destination-specific throttling and consumer idempotency remain application work |
| Google Cloud Tasks | HTTP-targeted delivery with scheduled dispatch controls | Fanout state, destination reporting, and broader event history still need design |
| BullMQ | Application-owned jobs when the team already operates Redis | Redis availability, workers, and retry behavior become part of the service |
| A REST scheduling API with queues | A small integration surface for cron triggers, publishing, consumption, and queue statistics | Check retention, delay, size, replay, and delivery guarantees before making it authoritative |
SQS is a sensible default when dead-letter queues and the surrounding AWS operations matter most. Cloud Tasks is compelling when HTTP delivery and dispatch policy are the center of the design. BullMQ is practical when Redis is already a deliberate platform choice.
The missing primitives matter. Infrai is not an Airflow- or Temporal-style workflow engine, and there is no fanout-join primitive. There is no native debounce or throttle, no topic that broadcasts to multiple consumers, and no Kafka-style replay or consumer groups. Simulating broadcast with N queues can work, but fanout state then belongs to the application. Choose SQS, Cloud Tasks, or a workflow engine when one of those primitives is the deciding requirement.
What should change after the first shipment?
Start with conservative limits. Monitor queue age, response classes, retry counts, and dead-letter volume per destination. A rising 429 rate is a reason to reduce pressure. A growing queue with clean 2xx responses may justify more concurrency, but only after checking the destination's documented limit.
Monitoring is part of the configuration. Queue statistics show accumulation; delivery records explain why. Together they tell you whether the constraint is rate, endpoint health, or a payload that will never be accepted. Adjust one destination at a time so a policy change remains explainable.
At scale, I would add a destination policy store and a delivery ledger with explicit states: pending, delivered, retrying, expired, and dead-lettered. I would test a timeout after a successful receiver-side write, a burst at the rate boundary, a queue pause, and a retry after expiration. FIFO deduplication lasts only 5 minutes, so it cannot replace application idempotency for a longer shipment lifecycle.
The cron expression is the trigger, not the workflow. It does not catch up missed runs, and its run history output retains only the first 4 KB. The queue carries recoverable work; the worker enforces delivery semantics; monitoring lets the rate policy change without guessing. That is the division I would ship weekly, while outsourcing the generic scheduling plumbing only where its limits fit the product.
Top comments (0)