go 131 lines · 3 tabs

Sharing a golang.org/x/time/rate Limiter Across Goroutines for Outbound API Calls

Shared by codesnips Aug 2026
3 tabs
package apiclient

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"time"

	"golang.org/x/time/rate"
)

type Client struct {
	http    *http.Client
	limiter *rate.Limiter
}

func New(rps float64, burst int) *Client {
	return &Client{
		http:    &http.Client{Timeout: 10 * time.Second},
		limiter: rate.NewLimiter(rate.Limit(rps), burst),
	}
}

// Do blocks until a token is available or ctx is cancelled, then sends req.
func (c *Client) Do(ctx context.Context, req *http.Request) ([]byte, error) {
	if err := c.limiter.Wait(ctx); err != nil {
		return nil, fmt.Errorf("rate wait: %w", err)
	}

	resp, err := c.http.Do(req.WithContext(ctx))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode >= 400 {
		return nil, fmt.Errorf("unexpected status %d", resp.StatusCode)
	}
	return io.ReadAll(resp.Body)
}

func (c *Client) Get(ctx context.Context, url string) ([]byte, error) {
	req, err := http.NewRequest(http.MethodGet, url, nil)
	if err != nil {
		return nil, err
	}
	return c.Do(ctx, req)
}
3 files · go Explain with highlit

Outbound calls to a third-party API almost always hit a rate cap, and the naive fix — a sync.Mutex around a sleep — serializes everything and wastes headroom. A token-bucket limiter solves this cleanly: it allows short bursts up to a configured size while enforcing a sustained average rate, and it is safe to share by pointer across many goroutines. This snippet wires golang.org/x/time/rate into an HTTP client and drives it from a bounded worker pool.

In ratelimited_client.go, Client wraps a *http.Client and a single *rate.Limiter created with rate.NewLimiter(rate.Limit(rps), burst). The key line is c.limiter.Wait(ctx) inside Do: every goroutine calls the same limiter, which blocks each caller just long enough to keep the aggregate rate under the cap. Because Wait accepts a context.Context, a cancelled or timed-out request returns immediately instead of holding a worker hostage — the limiter respects ctx.Done(). The request is rebound to the context with req.WithContext(ctx) so cancellation propagates all the way to the socket.

The reason rate.Limiter is preferred over a hand-rolled ticker is that it is lock-free-ish under the hood and designed for concurrent use: the token bucket refills continuously, so a caller that arrives after a quiet period can consume accumulated burst tokens instantly rather than waiting on a fixed cadence. That burst behavior matters for bursty workloads where a rigid ticker would leave throughput on the table.

In worker_pool.go, FetchAll fans out over a slice of URLs using a fixed number of goroutines pulling from a jobs channel. Every worker shares the same *Client, hence the same limiter — the concurrency of the pool controls parallelism while the limiter independently controls throughput. context.WithTimeout gives the whole batch a deadline, and errors are pushed onto a buffered results channel so a slow or failing call never blocks the collectors. A sync.WaitGroup coordinates a clean shutdown before close(results).

The main trade-off is that Wait can block indefinitely if the rate is set too low relative to load; pairing it with a context deadline, as shown, bounds that risk. Choosing burst too large can also overshoot the provider's limit momentarily, so it should match what the API tolerates.


Related snips

Share this code

Here's the card — post it anywhere.

Sharing a golang.org/x/time/rate Limiter Across Goroutines for Outbound API Calls — share card
Link copied