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.

5 min readSume
All posts

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.

Outcomes by where a task was when abort_all ran (read 2026-10-13)
Task stateJob exists at Sume?What to do
Waiting for a semaphore permitNoNothing
Submit sent, no reply yetMaybeRe-send with the same Idempotency-Key to find out
Polling a jobYesPoll 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

All Developers posts

Written by Sume