Node fetch with AbortSignal.timeout: poll a Sume job

A Node recipe: submit a Sume job with fetch, bound each call with AbortSignal.timeout, poll status_url, read the result. A local timeout does not cancel it.

6 min readSume
All posts

Bound every fetch to a Sume job with AbortSignal.timeout(ms), submit with mode: "async" and an Idempotency-Key, then poll status_url until terminal is true. The timeout only aborts your local request: Sume's docs say a client-side timeout does not cancel the job, which keeps running and billing, so store the job id and resume from status_url instead of resubmitting.

The signal behavior is from MDN's AbortSignal.timeout() page; the job contract is from Jobs and results and the API reference. All read 2026-10-02.

What does the Node recipe look like?

Save it as poll.mjs so top-level await works, set SUME_API_KEY, and run node poll.mjs. It uses only the global fetch, so there is no dependency to install. Sume's own TypeScript client is covered in the SDK quickstart.

const BASE = "https://api.sume.com";
const headers = { Authorization: `Bearer ${process.env.SUME_API_KEY}` };
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
const call = (url, init = {}) =>
  fetch(url, { ...init, headers: { ...headers, ...init.headers }, signal: AbortSignal.timeout(30_000) });
async function generate(prompt, key) {
  const r = await call(`${BASE}/v1/images`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "Idempotency-Key": key },
    body: JSON.stringify({ model: "sume/auto", prompt, mode: "async" }),
  });
  if (!r.ok) throw new Error(`submit ${r.status}: ${await r.text()}`);
  const { data: job } = await r.json();
  let delay = job.next_poll_after_seconds ?? 2;
  for (let i = 0; i < 200; i++) {
    await sleep(delay);
    const s = await call(job.status_url);
    if (s.status === 429) { delay = Number(s.headers.get("retry-after") ?? delay * 2); continue; }
    if (!s.ok) throw new Error(`status ${s.status}`);
    const { data: st } = await s.json();
    if (st.terminal) {
      if (st.sume_status !== "completed") throw new Error(await (await call(`${BASE}/v1/jobs/${job.request_id}`)).text());
      const res = await (await call(job.result_url)).json();
      return res.data.result.artifacts.map((a) => a.url);
    }
    delay = st.next_poll_after_seconds ?? Math.min(delay * 2, 30);
  }
  throw new Error(`still running: ${job.status_url}`);
}
console.log(await generate("a red panda astronaut", "panda-order-8823-v1"));

What does AbortSignal.timeout do and not do?

MDN says AbortSignal.timeout() returns a signal that aborts after the given number of milliseconds, with a TimeoutError as its reason. The time is active time, not elapsed time, and the timeout cannot be cancelled once created.

AbortSignal.timeout() behavior from MDN, and the Sume reading, read 2026-10-02.
BehaviorSource saysFor a Sume job
Aborts after time msReason is a TimeoutError DOMException.Catch it in your own wrapper and poll again; it is not a job outcome.
One signal per callThe timeout cannot be cancelled early.Make a fresh signal per request, as the call helper does.
Local onlyIt aborts your request, nothing else.The job keeps running and billing; cancel only through cancel_url.

What should happen on a timeout or a 429?

A timeout on the submit is the case the idempotency key exists for: retry the same request with the same key and you get the original job back. A timeout on a status call does not affect the job, but the recipe does not catch the TimeoutError: wrap generate in your own retry and resume from the stored status_url. A 429 on a status call means the read budget is spent, so the recipe sleeps for the retry-after seconds and continues. The loop is bounded at 200 polls, which is your own deadline and not an API limit.

What would I add for production?

Four additions turn the recipe into a service component.

  • Persist request_id and status_url before the first poll.
  • Derive the idempotency key from your business intent, not a random value per attempt.
  • On a failed job, read GET /v1/jobs/{id} for the public error: /result answers 409 job_not_completed for jobs that did not complete.
  • Prefer a signed webhook for long video jobs, with this loop as the backup.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume