Your push subscriber is returning 429: what the queue does next

When Infrai pushes jobs to your Express endpoint faster than it can cope, answer 429 with Retry-After. Here's how delivery, retries and dead-lettering behave.

This is the 429 you send, not the one you receive. Infrai can push queued jobs at a public HTTPS endpoint instead of making you run a polling worker, and when that endpoint is saturated the right answer is 429 with a Retry-After header and an empty body — never a 200 you can’t honour. A non-2xx tells the queue the message wasn’t handled, so it stays in flight, gets redelivered, and lands in the dead-letter queue only after the subscription’s retry budget runs out.

Answering 200 and then dropping the job on the floor is how work disappears silently.

Push and pull put the throttle in different places

Push subscriptionPull worker
Who decides the ratethe queue, up to the subscription’s concurrencyyou, in the consume loop
What a 429 means”redeliver this later”not applicable; you just don’t ask for more
If your process dies mid-jobdelivery fails, message returnslease expires, message returns
Backpressure signalHTTP status codehow often you call consume
Needs a public HTTPS URLyesno

Push is less code. Pull is more control. If your throughput problem is “the third-party API behind my handler allows 5 requests per second”, pull is the honest answer and you should skip the subscription entirely — the section near the end shows that shape.

Subscribe the endpoint

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/queue/push_subscribe/push-demo-jobs" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"queue":"push-demo-jobs","url":"https://jobs.example.com/hooks/queue"}'
{
  "ok": true,
  "data": {
    "subscription_id": "sub_CNdo9gzigIsdfuoMfBn8nx4F",
    "queue": "push-demo-jobs",
    "concurrency": 10,
    "max_retries": 3,
    "active": true,
    "started_at": "2026-07-26T00:34:52.718584Z"
  }
}

Read concurrency and max_retries carefully, because together they define your worst case: up to ten deliveries in flight against your endpoint at once, and three attempts per message before it’s parked. Ten concurrent requests is not much — until each one holds a database connection for 900ms, at which point a pool of five is the actual bottleneck and every eleventh request queues inside Express.

The handler that admits when it’s full

Track in-flight work and refuse politely above your own ceiling. Retry-After in seconds is what a well-behaved sender reads.

import express from "express";
import process from "node:process";

const MAX_IN_FLIGHT = Number(process.env.MAX_IN_FLIGHT ?? 6);
const SHARED_SECRET = process.env.QUEUE_HOOK_SECRET;
if (!SHARED_SECRET) throw new Error("QUEUE_HOOK_SECRET must be set");

let inFlight = 0;
const app = express();

app.post("/hooks/queue", express.json({ limit: "512kb" }), async (req, res) => {
  if (req.get("x-hook-secret") !== SHARED_SECRET) return res.status(401).end();

  if (inFlight >= MAX_IN_FLIGHT) {
    res.set("Retry-After", "15");
    return res.status(429).json({ error: "busy", in_flight: inFlight });
  }

  inFlight += 1;
  try {
    await handleJob(req.body);
    res.status(200).json({ ok: true });
  } catch (err) {
    console.error(`job failed: ${err.message}`);
    res.status(500).json({ error: "job failed" });
  } finally {
    inFlight -= 1;
  }
});

async function handleJob(payload) {
  console.log(`processing ${JSON.stringify(payload).slice(0, 200)}`);
}

app.listen(8080, () => console.log("subscriber listening on :8080"));

Three details are load-bearing. The counter decrements in finally, or one thrown error leaks a slot and your endpoint slowly convinces itself it’s permanently busy. The 500 path is deliberate — a genuine failure should also be redelivered, and the queue treats any non-2xx the same way. And the 429 returns before any work starts, so refusing is cheap; an overloaded service that spends 300ms deciding to refuse is still overloaded.

What “redelivered” costs you

Nothing, in money. Delivery attempts, acks and dead-lettering are free routes — only the original POST /v1/queue/publish is metered. So a job that gets refused twice and succeeds on the third attempt costs exactly what a job that succeeds immediately costs.

What it does cost is time, and after max_retries the message stops coming back. Watch the counters rather than guessing:

curl -sS "https://api.infrai.cc/v1/queue/stats/push-demo-jobs" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "queue": "push-demo-jobs",
    "message_count": 12,
    "available_count": 2,
    "in_flight_count": 10,
    "dlq_count": 5,
    "oldest_message_age_seconds": 188
  }
}

A rising dlq_count while your handler returns 429s means your ceiling is set below what the subscription pushes, and the fix is on your side — raise MAX_IN_FLIGHT, or switch to pull.

Worth flagging from our own testing on 2026-07-26: GET /v1/queue/dlq/list/{queue} returned an empty list on a queue whose dlq_count was greater than zero, and POST /v1/queue/dlq/redrive/{queue} answered with an error. Reading the dead-letter queue as an ordinary queue by name worked reliably, so that’s what we’d wire into a recovery script.

The pull version, when you need the throttle

Drop the subscription and take the rate into your own hands. No public URL, no inbound firewall rule, no 429 to design.

import process from "node:process";
import { setTimeout as sleep } from "node:timers/promises";

const BASE = "https://api.infrai.cc";
const headers = {
  Authorization: `Bearer ${process.env.INFRAI_API_KEY}`,
  "Content-Type": "application/json",
};

async function call(path, payload) {
  const res = await fetch(`${BASE}${path}`, { method: "POST", headers, body: JSON.stringify(payload) });
  const out = await res.json();
  if (!out.ok) throw new Error(`${path}: ${out.error.code} — ${out.error.message}`);
  return out.data;
}

export async function pullLoop(handleJob) {
  for (;;) {
    const { items } = await call("/v1/queue/consume", { queue: "push-demo-jobs", max_messages: 10 });
    if (!items.length) { await sleep(5000); continue; }
    for (const msg of items) {
      try {
        await handleJob(msg.payload);
        await call("/v1/queue/ack", { queue: "push-demo-jobs", receipt_handle: msg.message_id });
      } catch (err) {
        console.error(`delivery ${msg.delivery_count} of ${msg.message_id}: ${err.message}`);
      }
      await sleep(200);
    }
  }
}

That sleep(200) is a 5-per-second ceiling, expressed in one line, with no HTTP status codes involved.

What it costs and how to check

POST /v1/queue/publish is $0.00002 per message, verified 2026-07-26; push deliveries, retries, consume, ack and stats are free and rate-limited rather than metered. A subscription handling 500,000 jobs a month costs $10 in publishes regardless of how many 429s it collects on the way. New accounts get $2 of credit.

curl -sS "https://api.infrai.cc/v1/discovery" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  | jq '.capabilities[] | select(.id | startswith("queue.")) | {id, billable: .billing.is_billable, price: .billing.price_usd}'

Rates here move down over time and campaigns run, so the live number may be lower. The reason to run this on Infrai isn’t the rate anyway — it’s that the job’s outbound email, its uploaded artefact and the error you record when it dead-letters are all on the same key.

Where push isn’t the right tool

If pushing HTTP to endpoints on a schedule is your whole application, qstash does it with more delivery controls than a {queue, url} subscription exposes. If you need routing keys, fan-out to multiple consumers or per-consumer prefetch, rabbitmq is the grown-up answer and no hosted queue with a REST facade will match it. And sqs plus Lambda is the path of least resistance if you’re already in AWS.

The limitations of this route, plainly: the documented subscribe body is {queue, url} with concurrency and retry budget reported rather than requested, the endpoint has to be publicly reachable over HTTPS, and there’s no server-side rate limiter — the throttle is your 429, or it’s the pull loop.

References

Browse more queue developer guides