Backpressure
When a downstream returns 429, 503, or 529, your work didn't fail — it was rate-limited. SimpleQ models this as defer: delivery pauses, resumes after the delay, and no attempt is burned. The maxAttempts budget is spent on real failures only, so work can be deferred indefinitely against a sustained rate limit and still complete the moment capacity returns.
A defer holds the whole queue, not just the job that reported it. A 429 is a fact about the downstream, and a SimpleQ queue points at one webhook URL — so the queue is the downstream. Holding only the reporting job would send every other job on the queue into the same rate limit, and you'd pay for each one.
See ack mode for how defer fits alongside ack and nack.
What happens during a hold
Nothing is delivered until the Retry-After you passed has elapsed. Jobs wait where they are — they are never POSTed, so a held job uses no attempt, no defer budget, and costs nothing.
When the wait is up, SimpleQ delivers one job as a probe and keeps the rest held:
- The probe succeeds — the queue resumes at its normal
concurrencyandrateLimitMax. - The probe reports backpressure again — the queue holds again for the new
Retry-After, and the cycle repeats.
That's the only way SimpleQ can learn your downstream recovered, and it means a queue never floods a provider that is still busy. In ack mode the probe is judged on your ack, not on the 200 — the 200 only says your handler received the job, so the queue stays held until the work itself reports back.
Retry-After is honored exactly as given. There is no ceiling and no minimum: if your downstream asks for two hours, SimpleQ waits two hours.
What it costs
Two different things get called an "attempt", so worth being exact.
The probe is a real delivery. It is dispatched, so it is billed like any other delivery, and if it comes back rate-limited it spends one of that job's maxDefers. It is not free.
What it isn't is a retry. Backpressure never counts against maxAttempts — that budget is for real failures. And the jobs held behind the probe are never sent at all, so they are not billed and spend nothing.
That's the whole saving. A rate limit used to cost one delivery per job per round, because every job in the queue walked into the same wall. Now it costs one probe per round, however many jobs are waiting.
Overlapping defers
Several in-flight jobs usually hit the same rate limit at once, so several defers arrive together. SimpleQ keeps whichever release time is farthest away.
This compares release times, not Retry-After values. A defer that arrives later asking for less time can still win: a 2-second defer arriving at T+4 releases at T+6 and pushes out a 5-second defer that arrived at T+0. Whichever answer is most cautious is the one that holds.
Trigger from ack mode
In ack mode, your worker reports a defer with a callback:
POST /v1/jobs/:id/defer
{ "retryAfter": 30, "reason": "anthropic 429" }retryAfter is in seconds (any value ≥ 0, no fixed ceiling). This matches the wire format every major provider hands you: Anthropic and OpenAI return Retry-After: <seconds>, Gemini returns retryDelay: "<n>s". Pass the value through.
One platform bound applies: every job has a 24-hour delivery lifetime. A job still waiting when its lifetime ends is dead-lettered, and a retryAfter that would push the reporting job's own redelivery past that dead-letters it immediately with retry_after_exceeds_lifetime. The queue is still held either way — the downstream's signal is true whether or not the job that discovered it survives to benefit. The defer call itself always succeeds (2xx).
Holding a single job
Pass scope: 'job' to hold only the reporting job and leave the rest of the queue delivering:
POST /v1/jobs/:id/defer
{ "retryAfter": 30, "scope": "job" }Reach for this when the wait is about this job rather than the downstream — a per-job resource that isn't ready yet. If the downstream rate-limited you, the default is what you want.
import { SimpleQ, retryAfterSeconds } from '@simpleq/sdk';
import { simpleqWebhookHandler } from '@simpleq/sdk/express';
const simpleq = new SimpleQ({ apiKey: process.env.SIMPLEQ_API_KEY });
app.post(
'/webhook',
// The middleware verifies the signature; send the 200 yourself, then run the
// work out of band — ack mode.
simpleqWebhookHandler(process.env.SQ_SIGNING_SECRET, async (job, { res }) => {
res.status(200).end();
try {
await callAnthropic(job.payload);
await simpleq.ack(job.id);
} catch (err) {
if (err.status === 429 || err.status === 503 || err.status === 529) {
await simpleq.defer(job.id, { retryAfter: retryAfterSeconds(err) ?? 10 });
} else if (err.status >= 400 && err.status < 500) {
await simpleq.nack(job.id, { retryable: false });
} else {
await simpleq.nack(job.id, { retryable: true });
}
}
}),
);@app.post("/webhook")
async def webhook(
request: Request,
background_tasks: BackgroundTasks,
x_simpleq_signature: str | None = Header(default=None),
):
raw = await request.body() # verify before parsing — see https://docs.simpleq.io/concepts/signature-verification
if not verify_signature(raw, x_simpleq_signature):
return Response(status_code=401)
# 200 immediately, then process out of band
# BackgroundTasks runs in-process — if this worker crashes mid-job the
# work is lost, but ackTimeout + ackTimeoutAction: "retry" redelivers it.
background_tasks.add_task(process_job, json.loads(raw))
return Response(status_code=200)
async def process_job(job: dict) -> None:
try:
await call_anthropic(job["payload"])
await callback(job["id"], "ack")
except APIStatusError as err:
status = err.status_code
if status in (429, 503, 529):
# parse_retry_after falls back to 10s when there's no header (e.g. 529 overloaded).
await callback(job["id"], "defer", {"retryAfter": parse_retry_after(err.response.headers.get("retry-after"))})
elif 400 <= status < 500:
await callback(job["id"], "nack", {"retryable": False})
else:
await callback(job["id"], "nack", {"retryable": True})A complete handler, with verifySignature and callback defined, lives in the generic ack worker example.
Trigger from standard mode
In standard mode, your synchronous webhook handler signals backpressure by returning a 429, 503, or 529 with a Retry-After header. SimpleQ reads the header and holds the job for that many seconds — no attempt burned, same effect as the ack-mode /defer call.
import { SimpleQBackpressure } from '@simpleq/sdk';
import { simpleqWebhookHandler } from '@simpleq/sdk/express';
app.post(
'/webhook',
simpleqWebhookHandler(process.env.SQ_SIGNING_SECRET, async (job) => {
try {
await doTheWork(job.payload); // resolve → 200
} catch (err) {
if (err.status === 429 || err.status === 503 || err.status === 529) {
// Relays the provider's status and Retry-After header — SimpleQ honors both.
// 10s fallback covers responses without a Retry-After (e.g. 529 overloaded).
throw SimpleQBackpressure.from(err, { fallback: 10 });
}
throw err; // → 500, retried with backoff
}
}),
);@app.post("/webhook")
async def webhook(request: Request, x_simpleq_signature: str | None = Header(default=None)):
# Verify over the raw bytes BEFORE parsing — see https://docs.simpleq.io/concepts/signature-verification
raw = await request.body()
if not verify_signature(raw, x_simpleq_signature):
return Response(status_code=401)
job = json.loads(raw)
try:
await do_the_work(job["payload"])
return Response(status_code=200)
except APIStatusError as err:
if err.status_code in (429, 503, 529):
# Relay the downstream Retry-After (seconds); 10s fallback when absent (e.g. 529).
retry_after = err.response.headers.get("retry-after", "10")
return Response(status_code=err.status_code, headers={"retry-after": str(retry_after)})
return Response(status_code=500)If the response omits Retry-After, SimpleQ falls back to a 60-second hold.
529 (provider overloaded)
529 means "the upstream is over capacity" — not your fault, never burns a retry attempt, and usually no trustworthy Retry-After. That's why the worker examples above fold 529 into the same defer branch as 429/503: retryAfterSeconds(err) returns undefined when there's no header, so the ?? 10 fallback applies a small fixed delay (5–10 seconds is a good default).
Because defers don't burn attempts, your queue rides out the outage and resumes the moment capacity returns. If you want to cap how long that takes — say, fail over to a different region or model after N attempts — count 529s in your worker and switch to nack when you hit the limit. The mechanism is yours to compose.
Sizing maxAttempts and maxDefers
Because deferred jobs don't count, maxAttempts is a budget for real failures only — 5xx, network drops, worker crashes. A small maxAttempts (3–4) covers a worker that's genuinely broken; the defer mechanism handles backpressure indefinitely without ever touching that budget. The split lets you size each one for what it actually means.
maxDefers (default 50) bounds how many times one job can be held before it dead-letters with defer_cap_exhausted. Only jobs that were actually delivered spend it — during a hold, the probe is the only job going out, so it's the only one whose budget moves. That makes maxDefers a ceiling on how long the queue keeps testing a downstream before declaring it gone, rather than a per-job stopwatch.
Let SimpleQ own the backoff
When you relay Retry-After to /defer (or return it from a standard-mode webhook), SimpleQ becomes the single source of truth for retry timing:
- One backoff strategy across all jobs and queues, not whatever each SDK ships with this month.
- Rate-aware recovery — the queue reopens through a single probe and then resumes under its
concurrencyandrateLimitMax, so a backlog can't stampede a downstream that just came back. - Visibility — every defer is recorded in the job's history, so you can see what backed off and why.
To get this, set maxRetries: 0 (or the equivalent) when constructing your provider SDK client. The provider's Retry-After then surfaces to your handler, you pass it to SimpleQ, and the queue handles the rest.