A once-a-minute cron tick as your outbound rate limiter, in Node
Draining a fixed number of queued jobs per minute through a public HTTPS endpoint: sizing the tick, the 10-message consume cap, and guarding against overlapping runs.
A partner API that allows 100 requests a minute is a scheduling problem disguised as a networking one. The cheapest correct answer is a token bucket where the clock is your scheduler and the tokens are however many messages one tick is allowed to drain: a minute passes, your endpoint gets hit, it pulls exactly 100 jobs from an Infrai queue, sends them, and stops. No sleep loops, no distributed semaphore, no Redis.
The queue absorbs whatever your app produces. The tick decides what escapes.
Sizing a tick
Pick the number from the limit you’re respecting, not from your backlog. If the partner allows 100 calls per minute and you want 20% of headroom for retries, your tick budget is 80. That budget is spent in consume calls, and each one returns at most 10 messages — the API rejects anything larger:
{
"ok": false,
"error": {
"code": "INVALID_ARGUMENT",
"http_status": 400,
"message": "queue.consume failed for 'partner-sync': max_messages must be <= 10",
"retryable": false
}
}
So a budget of 80 means up to eight round trips per tick. Those calls are free, though rate-limited, which is what makes the pattern cheap to run at one-minute granularity.
Create the queue and start feeding it from your app:
curl -sS -X POST "https://api.infrai.cc/v1/queue/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"name":"partner-sync","type":"standard","dlq":"partner-sync-dead"}'
curl -sS -X POST "https://api.infrai.cc/v1/queue/publish" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"queue":"partner-sync","body":{"customer_id":"cus_31","op":"sync_contact"}}'
The endpoint the scheduler hits
Nothing exotic — a plain Node 22 HTTP server with a shared secret, a deadline, and a budget. It answers quickly with what it did, because a scheduler that can’t see the result of a tick is a scheduler you’ll stop trusting:
import { createServer } from "node:http";
import process from "node:process";
const BASE = "https://api.infrai.cc";
const QUEUE = "partner-sync";
const KEY = process.env.INFRAI_API_KEY;
const TICK_SECRET = process.env.TICK_SECRET;
if (!KEY || !TICK_SECRET) throw new Error("set INFRAI_API_KEY and TICK_SECRET");
const H = { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" };
const BUDGET = 80; // messages per minute we're allowed to send
const DEADLINE_MS = 50_000; // finish before the next tick arrives
async function drain() {
const stopAt = Date.now() + DEADLINE_MS;
let sent = 0;
let failed = 0;
while (sent + failed < BUDGET && Date.now() < stopAt) {
const res = await fetch(`${BASE}/v1/queue/consume`, {
method: "POST",
headers: H,
body: JSON.stringify({ queue: QUEUE, max_messages: 10 }),
});
const out = await res.json();
if (!out.ok) throw new Error(`consume: ${out.error.code} ${out.error.message}`);
if (!out.data.items.length) break;
for (const msg of out.data.items) {
try {
await callPartner(msg.payload);
sent++;
await fetch(`${BASE}/v1/queue/ack`, {
method: "POST",
headers: H,
body: JSON.stringify({ queue: QUEUE, receipt_handle: msg.message_id }),
});
} catch (err) {
failed++;
console.warn(`delivery ${msg.delivery_count} for ${msg.payload.customer_id}: ${err.message}`);
}
}
}
return { sent, failed };
}
async function callPartner(job) {
const res = await fetch("https://partner.example.com/v2/contacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(job),
signal: AbortSignal.timeout(8000),
});
if (!res.ok) throw new Error(`partner answered ${res.status}`);
}
createServer(async (req, res) => {
if (req.url !== "/tick" || req.headers["x-tick-secret"] !== TICK_SECRET) {
res.writeHead(404).end();
return;
}
try {
const result = await drain();
res.writeHead(200, { "Content-Type": "application/json" }).end(JSON.stringify(result));
} catch (err) {
res.writeHead(500, { "Content-Type": "application/json" }).end(JSON.stringify({ error: err.message }));
}
}).listen(8080);
Messages you don’t ack aren’t lost — they go invisible for the visibility timeout (300 seconds by default) and reappear for a later tick. That’s the property that makes a failed send cost you nothing but a delay.
Firing it every minute
Any scheduler works, because the endpoint is just HTTPS. From a crontab:
* * * * * curl -sS --max-time 55 -X POST "https://api.yourapp.com/tick" \
-H "X-Tick-Secret: ${TICK_SECRET}" >> /var/log/partner-tick.log 2>&1
The endpoint has to be publicly reachable over TLS for a hosted scheduler to call it. If it can’t be — private subnet, no ingress — run the same drain() function on a timer inside a long-lived worker instead and skip the HTTP layer entirely. The queue doesn’t care which one is calling it.
Overlap, and why it mostly takes care of itself
Ticks overlap when one run takes longer than a minute. Two workers then drain the same queue at once, and your carefully-sized 80 becomes 160. The visibility timeout stops the same message being processed twice, but it doesn’t stop the rate doubling.
Two defences, both cheap. The DEADLINE_MS above ends a tick before the next one starts. And a per-tick check of the queue depth tells you when the backlog is winning:
curl -sS "https://api.infrai.cc/v1/queue/stats/partner-sync" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
If available_count climbs steadily across ticks, your producer is outrunning your rate limit and no amount of tuning the drain loop will fix that — you need a higher limit from the partner, or fewer jobs.
| Approach | Rate control | Public endpoint needed | What you operate |
|---|---|---|---|
| Cron tick + Infrai queue | Tick budget, exact | Yes, unless you run a resident worker | Nothing |
| BullMQ rate limiter | Per-queue limiter in the library | No | Redis |
| QStash flow control | Per-destination, managed | Yes | Nothing |
| Amazon SQS + Lambda | Reserved concurrency, indirect | No | AWS wiring |
BullMQ’s limiter is the better fit if you’re already on Redis and want per-queue throttling without an HTTP hop. QStash is worth a look when the thing you’re rate-limiting is literally outbound HTTP and you’d rather not run the drain loop at all.
What this costs, and where it falls short
Publishing is metered at $0.00002 per message (verified 2026-07-26); consume, ack and stats are free but rate-limited, so a tick every 60 seconds is comfortably inside normal use. New accounts start with $2 free. Check the current rate rather than trusting the number — it has trended down:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.capabilities[] | select(.id == "queue.publish") | {unit: .billing.unit, price: .billing.price_usd}'
Two honest limits. Standard queues give no ordering guarantee, so if the partner requires per-customer sequencing you’ll need to shard by customer and drain each shard serially — FIFO queues exist in the API but aren’t usable today, publishing to one fails. And the delivery budget before a message dead-letters is fixed at three, which is low for a flaky partner; if you need more attempts, re-publish from your handler with a counter in the payload rather than relying on the queue to count for you.
The wider reason to run this here rather than assembling it: the queue, the alert email when the backlog crosses a threshold, the stored response bodies and the error capture are all one key and one bill, instead of four vendors and four invoices for one background job.