Go WaitGroup fan-out for Wan 3.0 submits: no mutex, clean -race

Submit 12 Wan 3.0 clips from Go with sync.WaitGroup and a 4-slot channel. Each goroutine owns one slice index, so no mutex. Runs clean under go run -race.

5 min readSume
All posts

The usual Go fan-out advice is to guard a results map with a mutex. You do not need one here. Allocate the results slice up front, give each goroutine exactly one index, and only that goroutine ever writes to it. The race detector has nothing to flag, and wg.Wait() is the only synchronization left.

The program below posts 12 five-second Wan 3.0 clips at 480p to POST /v1/videos. A buffered channel of 4 keeps at most four requests in flight. Every shot gets its own Idempotency-Key, so rerunning the whole program after a crash replays the original jobs instead of creating duplicates. The sample ran against a local stand-in server that answers 202, with go run -race, on Go 1.27.1; it was not pointed at the live API.

The code (29 lines, Go 1.22 or later)

Go 1.22 made the loop variable i per-iteration, which is why the goroutine can read it directly. On older Go versions, pass i in as an argument. Set SUME_BASE=https://api.sume.com and SUME_API_KEY first, then go run fan.go.

package main
import ("bytes"; "fmt"; "net/http"; "os"; "sync")
func submit(base, key string, i int) int {
	body := fmt.Sprintf(`{"model":"wan-3.0","prompt":"shot %d","duration":5,"resolution":"480p"}`, i)
	req, _ := http.NewRequest("POST", base+"/v1/videos", bytes.NewBufferString(body))
	req.Header.Set("x-api-key", key)
	req.Header.Set("content-type", "application/json")
	req.Header.Set("Idempotency-Key", fmt.Sprintf("trailer-v3-shot-%02d", i))
	res, err := http.DefaultClient.Do(req)
	if err != nil { return 0 }
	defer res.Body.Close()
	return res.StatusCode
}
func main() {
	base, key := os.Getenv("SUME_BASE"), os.Getenv("SUME_API_KEY")
	codes := make([]int, 12)
	sem := make(chan struct{}, 4) // in-flight budget
	var wg sync.WaitGroup
	for i := range codes {
		wg.Add(1); sem <- struct{}{}
		go func() {
			defer wg.Done()
			defer func() { <-sem }()
			codes[i] = submit(base, key, i)
		}()
	}
	wg.Wait()
	fmt.Println(codes)
}

Where the 4 comes from

The channel width is your pacing choice, not a Sume limit. Sume admits a paid job as queued even when every processing seat is busy, and only answers 429 queue_full when the accepted capacity is used up. A sensible width is the in-flight budget from the docs: max(0, concurrency_limit - active - queued), capped at queue_capacity_remaining. For an idle Pro workspace that is 4.

Do not size the channel from wave_size_hint. The docs call it a submission-wave hint only: max(1, floor(queue_capacity_remaining * 0.75)), which is 18 on an idle Pro workspace.

Generation concurrency by plan, from Sume's generation admission docs (read 2026-10-07)
PlanProcessingQueue (default)Accepted
Free156
Pro42024
Startup84048
Scale20100120

What WaitGroup does not wait for

wg.Wait() returns when every submit call has an answer. The videos are not done at that point; each 202 response carries an id and a polling_url, and the job is pending until it is picked up. Poll GET /v1/videos/{id} afterwards (statuses pending, in_progress, completed, failed, cancelled), or pass a callback_url and verify the signed webhook instead.

The result slice holds HTTP status codes, with 0 for a transport error. A 429 or 503 in that slice is the cue to resubmit that shot with the same key, not a new one.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume