User closed the tab mid-render: cancel the Sume job or let it finish?
Cancel works only before generation starts; after that you get 409 job_generation_already_started and the job bills. A tested tab-close handler.

When a user closes the tab while a video renders, send one cancel request and treat the answer as information, not as a failure: it succeeds only before generation work starts, and after that Sume returns 409 job_generation_already_started with details.cancelable: false and the job runs to completion. Sume jobs and results says the same, and adds that cancelling a job that is already canceled is idempotent. So the right design is cancel-if-possible, then keep the job id so the finished clip is waiting when the user returns.
Whatever you choose, write it down as a product decision, because support will be asked about it. Do not assume that dropping the connection stops anything. A browser disconnect, a closed SSE stream or a client timeout ends your wait and nothing else.
Which policy should the tab-close handler use?
There are two honest policies. The first is to cancel on leave, which saves money when the job is still queued and does nothing harmful when it is not. The second is to never cancel and let every job finish, which suits a product where the result is saved to a library anyway. A mixed policy is common: cancel when the user explicitly presses Stop, keep the job when they merely navigate away. The code below implements the decision as a pure function so you can test it without a network. The cancel argument is any function that returns a status and a body, which lets the unit test fake every answer the API can give, including a transient 503 where the right move is to try again shortly rather than to guess.
What does the handler do with each answer?
def on_leave(cancel, job_id, user_pressed_stop):
"""cancel(job_id) -> (http_status, body). Returns the action taken."""
if not user_pressed_stop:
return "keep"
status, body = cancel(job_id)
if status == 200:
return "canceled"
err = (body or {}).get("error", {})
if status == 409 and err.get("code") == "job_generation_already_started":
return "running-keep-and-save"
if status == 409 and err.get("code") == "job_not_cancelable":
return "terminal-fetch-result"
return "retry-later"
def fake(status, code=None):
body = {"error": {"code": code}} if code else {}
return lambda job_id: (status, body)
assert on_leave(fake(200), "j1", True) == "canceled"
assert on_leave(fake(200), "j1", False) == "keep"
started = fake(409, "job_generation_already_started")
assert on_leave(started, "j1", True) == "running-keep-and-save"
assert on_leave(fake(503), "j1", True) == "retry-later"
print("tab-close policy ok")Why does the 409 branch keep the job?
The running-keep-and-save branch matters most. It means: stop showing a spinner, record that the job id belongs to this user, and let a webhook or a later poll attach the artifact to their library. Sume bills the job, so throwing the id away turns a wanted clip into a pure loss. The terminal-fetch-result branch is the opposite case, where the job already finished and the only useful step is a GET result_url.
Keep the cancel call itself safe to repeat. A repeated cancel of a canceled job is idempotent, so a flaky network that retries it does no harm, and you can reuse the same retry wrapper as your other idempotent calls.
| Answer | Meaning | Your next step |
|---|---|---|
| 200 canceled | Stopped before generation | Free the user's slot |
| 409 job_generation_already_started | Generation began, runs to completion | Save the id and fetch later |
| 409 job_not_cancelable | Already terminal | Fetch the result or the error |
| Repeat cancel on a canceled job | Idempotent | Treat as success |
What do I tell the user?
One boundary to respect: a client timeout is not a cancel and a cancel is not a refund promise. The billing rules for failed and canceled work live in Sume errors and credits, and you should read them before you describe the outcome to a user. In your own interface, say what happened in plain words, such as This render had already started, so it will finish and appear in your library, rather than showing a raw error code. Log the job id and the branch taken, so a later complaint of the form I closed the page and was still charged can be answered from a record: the job had already started when the cancel arrived, and the clip is in the library. If you offer a Stop button, put the same message next to it before the click, so people learn that stopping late does not recover the spend.
Sources
Related posts
More in Developers
- Verify a Sume TTS transcript_receipt SHA-256 yourself in Python
Recompute submitted_transcript_sha256 from your script with NFC and LF canonicalization and compare it to the transcript_receipt on a finished Sume TTS job.
- 4K vertical Short in Timeline: the 2160 cap and 1214x2160
Timeline output width and height top out at 2160 and must be even, so 2160x3840 is refused. What the largest 9:16 frame is and whether a Short needs it.
- Voice one script in six languages: Sume TTS loop and 409 guard
MAI-Voice-2.1 sells one voice across 23 languages. On Sume, set language per line, handle the 409 voice-language guard and price a six-language batch.
- waitForJob on a failed Sume image job: read the record, skip /result
waitForJob resolves for failed and canceled jobs instead of throwing, and /result returns 409 job_not_completed for them. How to branch on job.status.
Written by Sume