Backpressure
When a downstream returns 429, 503, or 529, your work didn't fail — it was rate-limited. SimpleQ models this as defer: the job is held, redelivered after a delay, and no attempt is burned. The maxAttempts budget is spent on real failures only, so a job can be deferred indefinitely against a sustained rate limit and still complete the moment capacity returns.
See ack mode for how defer fits alongside ack and nack.
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. Two platform bounds apply: each queue has a maxDefers budget (default 50 holds per job), and every job has a 24-hour delivery lifetime — a retryAfter that would push redelivery past it dead-letters the job immediately with retry_after_exceeds_lifetime instead of holding it. The defer call itself always succeeds (2xx); the job resolves to held or dead-lettered.
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 job 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
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.
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 redelivery — deferred jobs respect the queue's
concurrencyandrateLimitMaxwhen they come back, so a burst of recovered jobs doesn't immediately re-saturate the downstream. - 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.