p-retry npm: retry a paid API POST and stop on errors
p-retry reruns an async function with exponential backoff. Throw AbortError on answers a resend can't fix, and keep one Idempotency-Key per job.

p-retry is an npm package that calls an async function again when its promise rejects, with exponential backoff: await pRetry(run, { retries: 5 }). To stop early, throw AbortError from inside the function; p-retry then rejects at once without another attempt. For a paid API, throw AbortError on answers a resend cannot fix (400, 401, 402, 403, a 409 conflict), let network errors, 429 and 5xx retry, and create the Idempotency-Key outside the function so every attempt sends the same one.
p-retry facts come from its README. The example API is Sume's Format run create, from Create a run, Errors and spend and Errors and rate limits, all read on 2026-09-29. It is a plain HTTPS call with fetch; there is no Sume plugin for p-retry.
What does p-retry do by default?
It retries any rejection up to 10 times, starting 1 second after the first failure and doubling each time, with no upper bound on the gap. At those defaults the tenth retry waits 512 seconds, so set retries and maxTimeout for anything a user is waiting on. p-retry does not retry most TypeErrors, except network errors, which is what fetch throws when the connection fails.
| Option | Default | What it does |
|---|---|---|
retries | 10 | Maximum number of retries |
factor | 2 | Exponential factor |
minTimeout | 1000 | Milliseconds before the first retry; 0 retries at once |
maxTimeout | Infinity | Longest gap between two retries, in milliseconds |
randomize | false | Multiplies each wait by a factor between 1 and 2 |
maxRetryTime | Infinity | Total time the retried operation may run |
Why must the Idempotency-Key live outside the function?
p-retry calls your function again from the top. If the function builds the key with crypto.randomUUID(), each attempt sends a new key, and a request that reached the server before the connection dropped starts a second paid run. Build the key once, outside the function, from the thing being made (an order id and a version), so every attempt carries the same one. Sume's docs say not to retry unsafe submit requests without an Idempotency-Key; with the same key and the same body, a resend returns the original run instead of a second charge. Idempotency keys for AI video APIs covers every replay case.
How do I stop p-retry on errors that won't succeed?
Read the error envelope and throw AbortError when a resend can't help. Sume's error body carries retryable and next_action, and the docs say a 4xx at create means nothing ran and nothing was charged, so fix the call rather than retrying it. onFailedAttempt can return a promise, which the README uses to add a delay; here it waits out retry-after on a 429.
import pRetry, { AbortError } from "p-retry";
const key = "order-8823-clip-v1"; // fixed for every attempt
async function createRun() {
const res = await fetch("https://api.sume.com/v1/formats/acme/product-clip/runs", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SUME_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify({ input: { product_url: "https://example.com/p/8823" } }),
});
const body = await res.json().catch(() => ({}));
if (res.ok) return body.data; // 202 new run, 200 replay
const err = body.error ?? {};
const msg = `${res.status} ${err.code}`;
if (err.code === "idempotency_key_in_use" || res.status === 429 || res.status >= 500) {
if (err.retryable === false || err.next_action === "fix_input") throw new AbortError(msg);
throw Object.assign(new Error(msg), { wait: Number(res.headers.get("retry-after")) || 0 });
}
throw new AbortError(msg); // 400, 401, 402, 403, 404, 409 conflict
}
const run = await pRetry(createRun, {
retries: 4, maxTimeout: 30_000, randomize: true,
onFailedAttempt: ({ error }) => new Promise((r) => setTimeout(r, (error.wait ?? 0) * 1000)),
});Which Sume answers should abort the retry?
These come from the Format run create table. The API key stays in a server-side environment variable; Sume's docs keep keys out of frontend JavaScript.
| Answer | p-retry action | Why |
|---|---|---|
400 invalid_request | AbortError | The body is invalid |
402 insufficient_credits | AbortError | Retrying without a top-up returns the same answer |
403 insufficient_scope | AbortError | Mint a new key with the scopes |
409 idempotency_conflict | AbortError | Key reused with a different body |
429 rate_limited | Retry after retry-after | The key's write budget is spent |
502 attachment_fetch_failed | AbortError | Despite the 5xx, next_action is fix_input |
503 studio_agent_upstream_unavailable | Retry | Retry later with the same Idempotency-Key |
Does p-retry wait for the job to finish?
No, and it shouldn't. The create answers 202 with a run id and a status_url as soon as the run exists; generation continues after that. Wrap only the create in p-retry, then poll the status URL on its own schedule. To cap how many runs you start at once, pair it with p-limit. Axios retry covers the same rules for Axios, and fetch timeout and retry adds a per-attempt timeout.
Sources
Related posts
More in Integrations
- PHP cURL POST JSON with a Bearer token
json_encode the body, pass the string to CURLOPT_POSTFIELDS, set Content-Type and Authorization headers, then check the status: cURL won't fail on a 4xx.
- Polly retry policy for an HttpClient POST to a paid API
A Polly retry for a paid POST: handle only transient failures, back off exponentially with jitter, honor Retry-After, and resend one idempotency key.
- Power Automate HTTP Webhook action: wait for a callback
The HTTP Webhook action sends a subscribe request with the flow's callback URL, then pauses until something POSTs to it. How to use it with a slow API.
- Spring Boot RestTemplate POST JSON with a Bearer token
Set JSON content type and setBearerAuth on HttpHeaders, wrap them in an HttpEntity, call postForEntity, and catch the 4xx exception to read the body.
Written by Sume