FastAPI background poll of a Sume job: use next_poll_after_seconds

Poll a Sume job from an asyncio task in a FastAPI 0.142 app, wait for the server-provided interval, stop at terminal, and never resubmit after a timeout.

4 min readSume
All posts

Poll a Sume job from FastAPI with a plain asyncio coroutine that sleeps for next_poll_after_seconds between reads and stops when terminal is true. Keep the webhook as the main path and use this loop as the fallback for a delivery that never arrives. FastAPI 0.142 added OpenTelemetry in the same week, so a loop like this is also a good place to attach a job id to a span.

What the job record tells you

The job envelope has status (queued, processing, completed, failed, canceled), a boolean terminal, result_ready, and next_poll_after_seconds. Wait at least that long. The SDK helper for the same job type, waitForJob, treats its 2 second interval as a floor and lets the server value win when it is longer.

Fields to read in a poll loop, from the Sume docs read 2026-10-08
FieldUse
terminalStop polling when true
result_readyFetch the result only when true
next_poll_after_secondsSleep this long before the next read
statusfailed and canceled are terminal without a result

A loop that does not resubmit

The function below reads GET /v1/jobs/{id} with the x-api-key header and returns the last record. It uses only the standard library, run in a thread so the event loop stays free. A timeout returns the last record; it does not cancel the job, and it does not submit a second one.

import asyncio, json, os, time, urllib.request

def read(job_id: str) -> dict:
    req = urllib.request.Request(
        f"https://api.sume.com/v1/jobs/{job_id}",
        headers={"x-api-key": os.environ["SUME_API_KEY"]},
    )
    with urllib.request.urlopen(req, timeout=15) as r:
        body = json.load(r)
    return body.get("data", body)

async def wait(job_id: str, deadline_s: float = 600) -> dict:
    end = time.monotonic() + deadline_s
    while True:
        job = await asyncio.to_thread(read, job_id)
        if job.get("terminal") or time.monotonic() > end:
            return job
        await asyncio.sleep(max(2, job.get("next_poll_after_seconds") or 2))

async def main() -> None:
    print(await wait("job_REPLACE_ME"))

if __name__ == "__main__":
    asyncio.run(main())

Where to run it in FastAPI

Start the loop with asyncio.create_task from a route, and keep a reference to the task so it is not garbage collected. For anything longer than a request, store the job id in your database and let a worker poll.

  • A job is readable only by the member whose key created it, so poll with the same key.
  • On 429 rate_limited, read retry-after and back off.
  • Fetch GET /v1/jobs/{id}/result only when result_ready is true; before that it answers 409 job_not_completed.

Why the interval comes from the server

A fixed 1 second loop wastes status reads, and status reads have their own rate limits. The hint lets Sume slow every client down at once when the queue is long.

Failure cases to handle

Three outcomes need different handling. A failed job carries an error and no result, so show the error and stop. A canceled job is terminal with no result. A deadline that expires in your loop is not a Sume state at all: the job is still queued or processing, so store the id and look again later.

If the read itself fails with 401, check that the same key that created the job is the one you send. A job is readable only by the member whose key created it, which is a common surprise when a worker uses a different key from the web app.

If you poll more than a handful of jobs, move to mode: "webhook". Polling costs status reads that count against rate limits, and a webhook costs one request per job. Keep this loop as the fallback for the case where a delivery never arrives, because Sume gives up after 10 attempts.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume