Poll a Sume Action run in Python: branch on next_action, not status

A small Python loop for a Sume Scheduled run that sleeps on poll_status, retries on retry_later and stops on none, then fetches the result.

5 min readSume
All posts

Poll GET /v1/action-runs/{run_id}/status and branch on next_action: keep waiting on poll_status, try the trigger again later on retry_later, and stop on none. Then call /result once for the full receipt.

The three values are the whole set, per Runs and results. Branching on them is simpler than listing every status, and it keeps working if a status is ever added.

What do the next_action values mean?

The loop treats skipped as terminal for polling and leaves the decision to the caller. retry_later is a signal, not an instruction to sleep here: it means the run never started because another run was active, so the right move is usually to re-call the trigger later, ideally with the same Idempotency-Key only if you want the original skipped receipt back, or a new key if you want a fresh attempt.

Use exponential backoff in production rather than a fixed sleep, as the docs advise. The sample doubles from 2 seconds to a 30 second ceiling. If you hit 429, back off further and see Errors and rate limits.

next_action values, read 2026-10-02
next_actionWhenDo
poll_statusqueued or processingKeep polling with backoff
retry_laterskippedAnother run was active; try the trigger again
nonecompleted, failed or canceledNothing left to poll; read the receipt

What does the loop look like?

This uses only the standard library. Set SUME_API_KEY and pass a run id from the 202 receipt. It backs off from 2 seconds up to 30 and gives up after 20 minutes.

import json, os, time, urllib.request

BASE = "https://api.sume.com/v1/action-runs"
KEY = os.environ["SUME_API_KEY"]

def get(path):
    req = urllib.request.Request(BASE + path,
        headers={"Authorization": "Bearer " + KEY})
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.load(r)["data"]

def wait(run_id, limit=1200):
    delay, start = 2, time.time()
    while time.time() - start < limit:
        s = get(f"/{run_id}/status")
        if s["next_action"] != "poll_status":
            return s
        time.sleep(delay)
        delay = min(delay * 2, 30)
    raise TimeoutError(run_id)

if __name__ == "__main__":
    rid = os.environ["RUN_ID"]
    s = wait(rid)
    if s["status"] == "completed":
        print(get(f"/{rid}/result")["primary_output_url"])
    else:
        print("ended as", s["status"], s["next_action"])

Why not call /result in the loop?

GET /v1/action-runs/{run_id}/result returns the receipt only once the run is terminal. While the run is queued or processing it returns 409 run_not_completed with the current status in details.status. Polling /status is the cheaper call built for loops; fetch /result once at the end.

Cancel is a separate call and is idempotent. POST /v1/action-runs/{run_id}/cancel needs actions:write, and canceling an already-terminal run returns that terminal receipt with 200. After you cancel, trust the response and poll status until it reads canceled; do not wait for a webhook, because a canceled run does not deliver one.

When should I use a webhook instead?

If you start many runs, one loop each is wasteful. Set communication.webhook_url and Sume sends one signed POST when the run completes or fails, carrying the same receipt. Keep the loop as a backup: canceled and skipped runs never deliver a webhook, so for those you must read the status. See Run webhooks.

A final point on the receipt: read output_error when a run is completed but output is empty. A run can finish, bill, and produce real media while failing to project that media into your schema, and the receipt tells you why. The loop above prints only the primary output URL, so add a branch for output_error before you ship it.

Sources

Related posts

More in Agents

All Agents posts

Written by Sume