Node 22.23.3 fetch: retry a Sume submit with one Idempotency-Key

Node 22.23.3 LTS bundles Undici 6.28.1. A fetch submit loop that retries 429 and 5xx with one Idempotency-Key so a retry never bills twice.

5 min readSume
All posts

Short answer

On Node 22.23.3 the built-in fetch is enough: build the Idempotency-Key once per intent, outside the retry loop, then retry only 429 and 5xx with a pause from retry-after. Node's 22.23.3 release notes list Undici 6.28.1 among the updated dependencies and give the release date as 23 September 2026.

The key placement is the whole point. Sume's jobs guide says a retry with the same key returns the original job instead of billing a second one, and its errors page says not to retry unsafe submits without a key. A key generated inside the loop is a new intent on every attempt.

What the 22.23.3 notes say

The notes are a maintenance release. The facts that touch an HTTP client are the Undici bump and the certificate bundle, both of which are inherited by fetch without code changes.

Node.js 22.23.3 'Jod' LTS items (read 2026-10-03)
AreaChange
Undiciupdated to 6.28.1
Root certificatesupdated to NSS 3.125
OpenSSLupdated to 3.5.8
npm and Corepacknpm 10.9.9, Corepack 0.36.0

Which statuses are safe to retry

Not every failure is worth another attempt. A 400 or 402 will fail the same way again, so the loop below returns those bodies to the caller. A 409 idempotency_conflict means the key was reused with a different payload, which is a bug in how the key is derived.

Sume submit responses and what the retry loop does (as of 2026-10-03)
StatusCodeLoop behavior
202job envelopereturn it and start polling
429rate_limited, queue_fullwait retry-after (or backoff) and retry with the same key
503provider_capacity_exceededwait and retry with the same key
400, 402, 409invalid_request, insufficient_credits, idempotency_conflictreturn to the caller; a retry cannot fix it

The submit helper

Save as submit.mjs so top-level await works. AbortSignal.timeout bounds each attempt; the job itself is not cancelled if an attempt times out, which is why the retry reuses the key and gets the original job back.

const url = "https://api.sume.com/v1/image-1.0/generate";
const key = `hero-${new Date().toISOString().slice(0, 10)}-001`;

async function submit(prompt, attempts = 4) {
  for (let i = 0; i < attempts; i++) {
    try {
      const res = await fetch(url, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.SUME_API_KEY}`,
          "Content-Type": "application/json",
          "Idempotency-Key": key,
        },
        body: JSON.stringify({ prompt, mode: "async" }),
        signal: AbortSignal.timeout(20_000),
      });
      if (res.status !== 429 && res.status < 500) return await res.json();
      const wait = Number(res.headers.get("retry-after")) || 2 ** i;
      await new Promise((r) => setTimeout(r, wait * 1000));
    } catch (err) {
      if (i === attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, 2 ** i * 1000));
    }
  }
  throw new Error("Sume submit still failing; poll GET /v1/jobs with the key's job id");
}

console.log(await submit("Product hero shot of a matte black bottle on marble"));

What Sume does and does not do

Sume answers a replayed key with the original job, and answers a changed payload under the same key with 409 idempotency_conflict. It reports which budget a 429 spent in error.details.scope (read or write), and it sends retry-after on 429.

Sume does not retry for you, and it does not infer intent from the prompt. Derive the key from something stable in your own system, such as a content id plus a version, so a crashed worker that restarts produces the same key.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume