go 129 lines · 3 tabs

Fan-In Parallel API Calls With a Bounded Worker Pool and Context Cancellation

Shared by codesnips Aug 2026
3 tabs
package quotes

import (
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"time"
)

type Quote struct {
	Vendor string
	Price  float64
	Err    error
}

type PriceClient struct {
	HTTP    *http.Client
	BaseURL string
}

func NewPriceClient(base string) *PriceClient {
	return &PriceClient{
		HTTP:    &http.Client{Timeout: 5 * time.Second},
		BaseURL: base,
	}
}

func (c *PriceClient) Fetch(ctx context.Context, vendor, symbol string) Quote {
	url := fmt.Sprintf("%s/%s/quote?symbol=%s", c.BaseURL, vendor, symbol)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return Quote{Vendor: vendor, Err: err}
	}

	resp, err := c.HTTP.Do(req)
	if err != nil {
		return Quote{Vendor: vendor, Err: err}
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return Quote{Vendor: vendor, Err: fmt.Errorf("%s: status %d", vendor, resp.StatusCode)}
	}

	var body struct {
		Price float64 `json:"price"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
		return Quote{Vendor: vendor, Err: err}
	}
	return Quote{Vendor: vendor, Price: body.Price}
}
3 files · go Explain with highlit

This snippet demonstrates the fan-in concurrency pattern in Go: several API calls run in parallel and their results are collected on a single channel by a select loop that also watches for cancellation. It is split across a small client wrapper, a bounded fan-in coordinator, and the calling code that drives it.

In client.go, PriceClient wraps an *http.Client and exposes Fetch, a single-vendor request that honors the passed context.Context. The context is threaded into the request via http.NewRequestWithContext, so when the coordinator cancels, in-flight HTTP calls are torn down instead of leaking. Each result is packaged into a Quote value that carries the vendor name and any per-call error, which is the key to fan-in: failures travel on the same channel as successes rather than aborting the whole batch.

In fanin.go, FetchAll launches one goroutine per vendor but throttles concurrency with a buffered sem channel acting as a counting semaphore — each worker acquires a slot before calling and releases it after. Every worker sends its Quote onto the shared results channel. A separate goroutine closes results once sync.WaitGroup.Wait returns, which is what lets the consumer loop terminate cleanly. The select loop is the heart of the pattern: it reads from results until the channel is drained, but simultaneously watches ctx.Done() so a deadline or upstream cancellation exits promptly with ctx.Err(). The sem size caps outbound load, an important trade-off when calling flaky third parties.

In main.go, context.WithTimeout bounds the entire batch, and FetchAll returns partial results even when some vendors error. The caller inspects each Quote.Err individually, so one slow or failing vendor never sinks the others.

The pattern shines when independent I/O calls can proceed in parallel and the caller wants aggregate results with graceful degradation. Pitfalls to watch: always ensure the channel is closed exactly once, avoid sending on a closed channel after cancellation, and remember that a select on ctx.Done() may abandon still-running goroutines, so the context must actually reach them to prevent leaks.


Related snips

Share this code

Here's the card — post it anywhere.

Fan-In Parallel API Calls With a Bounded Worker Pool and Context Cancellation — share card
Link copied