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)
}
package apiclient
import (
"context"
"sync"
"time"
)
type Result struct {
URL string
Body []byte
Err error
}
// FetchAll fans out over urls with a fixed worker pool, all sharing one limiter.
func FetchAll(client *Client, urls []string, workers int) []Result {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
jobs := make(chan string)
results := make(chan Result, len(urls))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for url := range jobs {
body, err := client.Get(ctx, url)
results <- Result{URL: url, Body: body, Err: err}
}
}()
}
go func() {
for _, u := range urls {
select {
case jobs <- u:
case <-ctx.Done():
}
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
out := make([]Result, 0, len(urls))
for r := range results {
out = append(out, r)
}
return out
}
package main
import (
"fmt"
"example.com/apiclient"
)
func main() {
// 5 requests/sec sustained, bursts of up to 10.
client := apiclient.New(5, 10)
urls := []string{
"https://api.example.com/v1/users/1",
"https://api.example.com/v1/users/2",
"https://api.example.com/v1/users/3",
"https://api.example.com/v1/users/4",
}
for _, r := range apiclient.FetchAll(client, urls, 8) {
if r.Err != nil {
fmt.Printf("%s -> error: %v\n", r.URL, r.Err)
continue
}
fmt.Printf("%s -> %d bytes\n", r.URL, len(r.Body))
}
}
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
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
Share this code
Here's the card — post it anywhere.