DEV Community

PDFops
PDFops

Posted on Originally published at pdfops.dev

A 429 from /api/fill-form, and what your retry loop should actually do

A batch job fills two hundred PDFs overnight, call forty hits a 429, and the generic retry wrapper you copied from another project kicks in: wait a second, retry, wait two seconds, retry, wait four. PDFops caps are monthly, not per-minute, so that loop is backing off toward a wall it cannot back off past, and it will spend the rest of the night finding that out one doubling at a time.

What the cap actually tracks

Every PDFops endpoint meters against a monthly quota, not a request rate: 100 requests a month per IP with no key, 250 a month on a free key, more on indie and pro. There is no per-second or per-minute throttle to wait out. When the count for the current UTC month is spent, every call fails with 429 rate_limited until the first of next month, no matter how far apart the calls are spaced.

Retry-After tells you the truth, if you read it

The 429 response carries a Retry-After header, and on this API it is not a short number. It is seconds until the next UTC month starts, which can be anywhere from one second to beyond a million. A retry wrapper tuned for typical API throttling treats any Retry-After under some sane ceiling as obey-and-retry and anything past it as give-up-now, and a monthly cap blows past that ceiling on the first day of the month. Reading the header before choosing to retry turns a silent all-night retry storm into one log line: quota resets in 1,180,000s, bailing.

Validation failures do not cost you anything

The flip side is worth knowing before you build defensive retry logic you do not need: a 400 for a malformed field list or an unknown field name never touches the quota, and neither does a 500 on PDFops' side. Only a 2xx response counts against the monthly budget. That means a batch job can fail fast and loud on a bad template without worrying it is burning through the same number that gates working calls, and it means the only response code worth backing off for is 429, because every other failure is either yours to fix in the request or ours to fix on our side.

Check /api/usage before the batch starts, not after it 429s

A key's current count, limit, and reset time are all sitting behind one GET call: /api/usage with the same X-API-Key header, returning tier, limit, used, remaining, and resets_at. A batch job that reads remaining before it starts and compares it against the number of calls the batch is about to make can stop itself at item one hundred and ninety instead of discovering the wall at item two hundred with ten PDFs half-written to a destination that now has a gap in it.

The script

The pattern below checks the budget up front, aborts the batch cleanly if it will not fit, and treats a mid-run 429 as a stop signal rather than something to wait out.

const API_KEY = process.env.PDFOPS_API_KEY;
const BASE = "https://pdfops.dev";

async function checkBudget(pendingCalls) {
  const res = await fetch(`${BASE}/api/usage`, {
    headers: { "X-API-Key": API_KEY },
  });
  if (!res.ok) throw new Error(`usage check failed: ${res.status}`);
  const { remaining, resets_at } = await res.json();
  if (remaining < pendingCalls) {
    throw new Error(
      `only ${remaining} calls left this period (resets ${resets_at}), ` +
      `batch needs ${pendingCalls} — aborting before it starts`,
    );
  }
  return remaining;
}

async function fillOne(templateBuf, fields) {
  const form = new FormData();
  form.append("pdf", new Blob([templateBuf]), "template.pdf");
  form.append("fields", JSON.stringify(fields));
  form.append("flatten", "true");
  const res = await fetch(`${BASE}/api/fill-form`, {
    method: "POST",
    headers: { "X-API-Key": API_KEY },
    body: form,
  });
  if (res.status === 429) {
    const retryAfter = res.headers.get("retry-after");
    const body = await res.json();
    throw new Error(
      `quota hit mid-batch (${body.details}); retry-after=${retryAfter}s — ` +
      `stopping the run rather than sleeping through it`,
    );
  }
  if (res.status >= 500) {
    // transient on our side — worth one short retry, not a long backoff
    await new Promise((r) => setTimeout(r, 1000 + Math.random() * 500));
    const retry = await fetch(`${BASE}/api/fill-form`, {
      method: "POST",
      headers: { "X-API-Key": API_KEY },
      body: form,
    });
    if (!retry.ok) throw new Error(`fill-form failed twice: ${retry.status}`);
    return Buffer.from(await retry.arrayBuffer());
  }
  if (!res.ok) {
    const body = await res.json();
    throw new Error(`fill-form rejected the request: ${body.error} — ${body.details}`);
  }
  return Buffer.from(await res.arrayBuffer());
}

async function runBatch(jobs) {
  await checkBudget(jobs.length);
  const results = [];
  for (const job of jobs) {
    results.push(await fillOne(job.template, job.fields));
  }
  return results;
}
Enter fullscreen mode Exit fullscreen mode

checkBudget only has to run once, before the loop starts. Inside the loop, a 429 is treated as fatal rather than something to sleep through, because the quota that just ran out will not come back until next month no matter how long the loop waits. A 500 gets exactly one short, jittered retry, since that failure mode is a transient server issue rather than a budget problem.

Try it

A free key from /docs/signup includes 250 calls a month, enough to run this batch pattern against a real template and watch remaining count down in /api/usage before wiring the budget check into a production job.

Top comments (1)

Collapse
 
challan116ux profile image
challan116-ux •

The distinction this draws — a quota wall versus a rate throttle — is one most retry wrappers get wrong by construction, because they assume every 429 is a short wait wearing a different number. Reading Retry-After before deciding whether to retry at all turns a 429 with a month-long reset into what it actually is: a non-retryable error for this run, closer to a 400 than to a pause. The pre-flight usage check is the other half of the lesson — comparing the batch size against the remaining budget before item one is far cheaper than discovering the gap at item one hundred and ninety, with a partial output set nobody can trust.