Tokio JoinSet and Semaphore: eight Sume clips, abort on a 402
Run Sume video jobs from Rust with reqwest, a Tokio Semaphore, and a JoinSet that collects clips as they finish and calls abort_all on a 402.

To run several Sume video jobs from async Rust, spawn one task per clip into a tokio::task::JoinSet, cap the in-flight submits with a Semaphore, and read results with join_next() in the order they finish. If any task returns 402 insufficient_credits, call abort_all() so you stop launching and waiting. The code below compiled and ran against a local stand-in for the API on Tokio 1.53.2 with reqwest 0.13.
Tokio 1.53.2 was published on 2026-10-03 as a bug-fix release (the release page lists a file-seek overflow fix and a blocking-pool shutdown fix), and Rust 1.99.0 came out on 2026-10-01. Neither changes the API used here. They are a reason to re-run your build.
How does one clip work in Rust?
POST /v1/videos returns 202 with a polling_url. The task then sleeps and polls until completed, failed, or cancelled, the three terminal states of this surface in the Video Generation guide. Each submit carries an Idempotency-Key built from a run id and the clip index, so a retried task replays the original job and does not start a second one. wan-3.0 accepts 2 to 30 seconds, so duration: 5 at 480p is valid. Set RUN_ID yourself, and keep it stable when you re-run the same batch.
use anyhow::{anyhow, Result};
use reqwest::Client;
use serde_json::{json, Value};
use std::{env, sync::Arc, time::Duration};
use tokio::{sync::Semaphore, task::JoinSet, time::sleep};
const POLL: u64 = 30;
async fn clip(c: Client, key: String, i: usize, prompt: String) -> Result<(usize, String)> {
let r = c.post("https://api.sume.com/v1/videos").bearer_auth(&key)
.header("Idempotency-Key", format!("{}-{i}", env::var("RUN_ID")?))
.json(&json!({"model": "wan-3.0", "prompt": prompt, "duration": 5, "resolution": "480p"}))
.send().await?;
if r.status() == 402 {
return Err(anyhow!("402 insufficient_credits"));
}
let job: Value = r.error_for_status()?.json().await?;
let url = job["polling_url"].as_str().ok_or(anyhow!("no polling_url"))?.to_string();
loop {
sleep(Duration::from_secs(POLL)).await;
let s: Value = c.get(&url).bearer_auth(&key).send().await?.json().await?;
match s["status"].as_str() {
Some("completed") => return Ok((i, s["unsigned_urls"][0].as_str().unwrap_or("").into())),
Some("failed" | "cancelled") => return Err(anyhow!("clip {i}: {}", s["status"])),
_ => {}
}
}
}How do you fan out and stop on a 402?
Spawn all eight tasks. Each one takes a semaphore permit before it submits, so four requests are in flight at most. This is client politeness, not a Sume limit: the generation admission page says full concurrency alone is not an error, and valid jobs are accepted as queued while queue capacity remains. join_next() yields each task as it ends. A 402 means the workspace cannot cover the request, so abort_all() cancels the tasks that have not finished.
#[tokio::main]
async fn main() -> Result<()> {
let key = env::var("SUME_API_KEY")?;
let (c, gate, mut set) = (Client::new(), Arc::new(Semaphore::new(4)), JoinSet::new());
for i in 0..8 {
let (c, key, gate) = (c.clone(), key.clone(), gate.clone());
set.spawn(async move {
let _slot = gate.acquire_owned().await?;
clip(c, key, i, format!("lighthouse shot {i}")).await
});
}
while let Some(done) = set.join_next().await {
match done? {
Ok((i, url)) => println!("clip {i}: {url}"),
Err(e) if e.to_string().starts_with("402") => {
set.abort_all();
return Err(e);
}
Err(e) => eprintln!("{e}"),
}
}
Ok(())
}What does abort_all not undo?
Aborting a task drops your wait. It does not cancel a job Sume already accepted. A clip whose submit returned 202 before the abort keeps rendering, and its credits stay reserved. Read the job again by its id later, or cancel it with POST /v1/jobs/:id/cancel, which only works before generation starts and otherwise returns 409 job_generation_already_started.
Tasks that were still waiting on the semaphore never submitted, so they cost nothing. That is the reason to gate submits instead of spawning all eight with no limit.
| Task state | Job exists at Sume? | What to do |
|---|---|---|
| Waiting for a semaphore permit | No | Nothing |
| Submit sent, no reply yet | Maybe | Re-send with the same Idempotency-Key to find out |
| Polling a job | Yes | Poll again later, or cancel before generation starts |
What should you add before using this in production?
Handle 429 by waiting for retry-after and then retrying the submit with the same key, since the errors page says not to retry unsafe submits without one. Put a per-clip deadline around the poll loop with tokio::time::timeout. For long batches, replace the loop with a callback_url webhook and verify its signature, because a poll task per clip holds a connection and a task for minutes.
Sources
Related posts
More in Developers
- TTS language en-US vs en-GB on Sume: regional tags compare by language
For the voice-language check, Sume compares regional tags by primary language, so en-GB on an en-US voice does not trigger the 409, and fil matches tl.
- Live commerce TTS rejects literal text: use transcript_source
Sume's live-commerce TTS takes only transcript_source (a script revision plus sentence IDs) and rejects literal transcript text, even if correct.
- Video editing API or AI prompt edit: which Sume endpoint to call
Trim, frames, captions and filter are deterministic jobs; Omni edit changes content from a prompt. Pick the cheapest route that does the change you need.
- video_url on /v1/videos fails: edits go to /v1/video-router/generate
Sume's /v1/videos has frame_images and input_references but no edit field. Send video_url to /v1/video-router/generate with gemini-omni-flash-1.1.
Written by Sume