Python 3.15 TaskGroup.cancel: stop at the first Sume job done

Python 3.15 adds TaskGroup.cancel. Watch several Sume jobs and stop the other watchers when the first one completes, without cancelling the paid jobs.

5 min readSume
All posts

To stop watching a group of Sume jobs as soon as one completes, call tg.cancel() inside an asyncio.TaskGroup on Python 3.15, which adds that method. Each watcher polls GET /v1/jobs/{id}/status; the first to see completed records itself and cancels the group, so the other watchers stop polling. On Python 3.14 the same code fails with AttributeError: 'TaskGroup' object has no attribute 'cancel'.

Two things are easy to confuse here. Cancelling the task group stops your polling tasks. It does not cancel the Sume jobs, which keep running and keep billing. That is usually what you want when you submitted several variants and need whichever finishes first, but you should decide on the leftovers deliberately.

What changed in Python 3.15

The Python 3.15 What's New page describes TaskGroup.cancel as allowing early termination of a task group when the goal of the tasks has been achieved or their services are no longer needed. The page was read while 3.15 was still a release candidate, so check the final release before pinning it in production.

Facts used here, read 2026-10-02
FactValueSource
TaskGroup.cancelNew in Python 3.15 (documented at 3.15.0rc2)docs.python.org What's new
Job statusesqueued, processing, completed, failed, canceleddocs.sume.com Jobs and results
Cancel a jobOnly before generation starts; then 409 job_generation_already_starteddocs.sume.com Jobs and results
Queue headroomPlan concurrency, plus queue capacitydocs.sume.com Generation admission

The code

Standard library only, no top-level await. get is blocking urllib, so it runs in a thread through asyncio.to_thread.

import asyncio, json, os, urllib.request

BASE, KEY = os.environ["SUME_BASE_URL"], os.environ["SUME_API_KEY"]

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

async def watch(tg, job_id, winner):
    while True:
        s = await asyncio.to_thread(get, f"/v1/jobs/{job_id}/status")
        if s["terminal"]:
            if s["sume_status"] == "completed" and not winner:
                winner.append(job_id)
                tg.cancel()  # 3.15: stops the sibling watchers
            return
        await asyncio.sleep(max(2, s.get("next_poll_after_seconds") or 2))

async def first_completed(job_ids):
    winner = []
    async with asyncio.TaskGroup() as tg:
        for job_id in job_ids:
            tg.create_task(watch(tg, job_id, winner))
    return winner[0] if winner else None

print(asyncio.run(first_completed(["job_1", "job_2"])))

What to do with the other jobs

After the group exits you hold one finished job and the ids of the rest. Three options:

  • Keep them: read their results later with the same status loop. The spend is already reserved.
  • Cancel queued ones with POST /v1/jobs/{id}/cancel. This only succeeds before generation starts; a started job answers 409 job_generation_already_started and runs to the end.
  • Do not submit again. A new paid submit for the same intent bills a second job.

Limits

I ran this on Python 3.15.0rc2 against a local mock that returns the documented status shape, and confirmed the failure on 3.14.7. Cancelling a task group interrupts a thread-based urllib call only after the thread returns, so the last in-flight request still completes. With many jobs, a status call per job per interval adds up against your read budget; the list endpoint with a status filter is cheaper for large fleets.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume