# Go Concurrency Patterns Used in angela angela fetches data from PyPI and OSV.dev in parallel. These patterns show up in production codebases at places like Uber and Cloudflare, but they're not complicated — they just require knowing which tool to reach for. --- ## The problem angela needs to query PyPI for every dependency in your `pyproject.toml` or `requirements.txt`. A typical project has 20-50 dependencies. Doing those requests one at a time would take 10-25 seconds. Doing them all at once would hammer PyPI with 50 simultaneous connections. The answer is **bounded concurrency** — run up to N requests in parallel, queue the rest. --- ## errgroup.SetLimit The `golang.org/x/sync/errgroup` package is the go-to for bounded concurrent work in Go. Here's how angela uses it in `internal/pypi/client.go`: ```go g, ctx := errgroup.WithContext(ctx) g.SetLimit(c.maxWorkers) // max 10 concurrent goroutines for _, name := range names { g.Go(func() error { versions, err := c.FetchVersions(ctx, name) mu.Lock() results = append(results, FetchResult{ Name: name, Versions: versions, Err: err, }) mu.Unlock() return nil }) } _ = g.Wait() ``` A few things to notice: - **`SetLimit(10)`** caps concurrent HTTP requests. PyPI recommends 5-10. - **Always returns nil** — individual failures get collected in results, not propagated. One package failing shouldn't kill the others. - **Mutex on the shared slice** — `results` gets appended to from multiple goroutines, so it needs a lock. ### Why not channels? Channels would work, but errgroup gets you the same thing with half the code: ```go // Channel-based worker pool: ~30 lines jobs := make(chan string, len(names)) results := make(chan FetchResult, len(names)) for range maxWorkers { go func() { for name := range jobs { // ... fetch ... results <- result } }() } // ... send jobs, collect results, close channels ... // errgroup: ~15 lines g.SetLimit(maxWorkers) for _, name := range names { g.Go(func() error { /* ... */ }) } g.Wait() ``` errgroup handles context cancellation for free and there's no channel lifecycle to think about. --- ## Panic recovery Every goroutine angela launches wraps itself in a recover: ```go g.Go(func() (err error) { defer func() { if r := recover(); r != nil { err = fmt.Errorf("panic fetching %s: %v", name, r) } }() versions, fetchErr := c.FetchVersions(ctx, name) // ... return nil }) ``` An unrecovered panic in a goroutine kills the entire process. For a CLI tool, that means the user gets a stack trace dump instead of an actual error message. The `defer recover()` turns panics into errors that flow through the normal path. One subtle thing — the named return `(err error)` is what makes this work. Without it, the deferred function has no way to set the return value. --- ## Context cancellation Every HTTP request uses context: ```go req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) ``` This does a lot of work for you: - Ctrl+C cancels pending requests - If errgroup's context gets cancelled, remaining work stops - HTTP timeouts are enforced at the transport level The context flows from `cobra.Command.Context()` → `runUpdate()` → `FetchAllVersions()` → `FetchVersions()` → the HTTP call itself. Standard Go pattern — context as the first parameter, threaded through everything. --- ## Retry with exponential backoff `internal/pypi/client.go` retries transient failures: ```go for attempt := range maxRetries { if attempt > 0 { delay := time.Duration(1<= 500 { lastErr = fmt.Errorf("server error: %d", resp.StatusCode) continue } return resp, nil } ``` The `1<