Go
Completed
-
Section 1: Getting Started
-
Section 2: Core Syntax
-
Section 3: Functions and Methods
-
Section 4: Concurrency
-
Section 5: Packages and Tooling
-
Section 6: More Standard Library
-
Section 7: Building Services
-
Section 8: Advanced Go
-
Section 9: Go in the Cloud-Native Ecosystem
-
Section 10: Data Structures and Algorithms in Go
-
Section 11: Testing and Deployment
-
Section 12: Practical Projects
-
Section 13: More Standard Library Practice
-
Section 14: More Practice Projects
-
Section 15: Design Patterns in Go
-
Section 16: Interview Practice
-
Section 17: Package fmt In Depth
-
Section 18: Package strings and strconv
-
Section 19: Package os and io
-
Section 20: Package time
-
Section 21: Package sort and container
-
Section 22: Package encoding
-
Section 23: Package net/http In Depth
-
Section 24: Package context
-
Section 25: Package regexp and bytes
-
Section 26: Package errors In Depth
-
Section 27: Package crypto and hash
-
Section 28: Package flag and log
-
Section 29: Package sync In Depth
-
Section 30: More Practice Exercises
-
Section 31: Go Modules and Workspaces In Depth
-
Section 32: Generics Deep Dive (Go 1.18+)
-
Section 33: Testing Package In Depth
-
Section 34: More Interview and Whiteboard Practice
-
Section 35: Package math and unicode
-
Section 36: Package path and filepath
-
Section 37: Package database/sql
-
Section 38: Package text/template and html/template
-
Section 39: Package archive and compress
-
Section 40: Lower-Level net Package
-
Section 41: Package runtime and reflect
-
Section 42: Package embed and io/fs
-
Section 43: Go Assembly and CGO Basics
-
Section 44: Building CLIs and TUIs
-
Section 45: Go Performance Tuning
-
Section 46: More Real-World Projects
-
Section 47: Go in Production
-
Section 48: Go Design Patterns
-
Section 49: Go Interfaces Deep Dive
-
Section 50: Final Practice and Review
96: Building a Rate-Limited API Client
A few years ago, I was helping a teammate integrate a legacy financial API that had a very strict limit: exactly five requests per second. He had written a clean, concurrent worker pool to sync thousands of accounts. It worked perfectly in our staging environment because the staging API had no limits. But the moment he flipped the switch to production, the API started throwing 429 Too Many Requests errors like confetti. He tried to fix it by sprinkling time.Sleep(200 * time.Millisecond) throughout the code, but because of the way the workers were scheduled, they still overlapped and triggered the rate limiter. He spent an entire weekend chasing "ghost" bugs that were actually just the API shutting him out.
The lesson here is that rate limiting shouldn't be an afterthought or a series of sleeps scattered in your business logic. It needs to be a first-class citizen of your API client. In Go, the most elegant way to handle this is by using a channel as a "metronome" to coordinate your requests.
Using Tickers as a Request Metronome
If you need a steady, predictable cadence, time.Ticker is your best friend. Instead of telling a goroutine to sleep, you tell it to wait for a signal from the ticker. This ensures that no matter how many workers you have competing for the resource, they only proceed when the ticker "ticks."
type RateLimitedClient struct {
client *http.Client
limiter <-chan time.Time
}
func NewRateLimitedClient(requestsPerSecond int) *RateLimitedClient {
// We create a ticker that sends a signal every 1/N seconds
ticker := time.NewTicker(time.Second / time.Duration(requestsPerSecond))
return &RateLimitedClient{
client: &http.Client{Timeout: 10 * time.Second},
limiter: ticker.C,
}
}
func (c *RateLimitedClient) Do(req *http.Request) (*http.Response, error) {
// This is the magic line. The goroutine blocks here until
// the ticker sends a value over the channel.
<-c.limiter
return c.client.Do(req)
}
I prefer this approach because it decouples the "when" from the "what." Your business logic just calls Do(), and the client handles the timing. If you increase your worker count from 10 to 100, the rate of requests remains exactly the same; the workers just queue up more densely behind the channel read.
Handling Bursts with Buffered Channels
The ticker approach is great for a strict "metronome" feel, but many modern APIs allow for "bursting." For example, an API might let you make 10 requests instantly, as long as you average 2 requests per second over a minute. A strict ticker would waste that capacity.
To handle this, we can use a buffered channel as a "token bucket." You fill the buffer with tokens, and each request consumes one. A separate background goroutine then refills those tokens at a steady rate. This allows your client to handle a sudden spike in traffic without immediately hitting the 429 limit, while still maintaining a long-term average.
type TokenBucketClient struct {
client *http.Client
tokens chan struct{}
}
func NewTokenBucketClient(maxBurst, refillRate int) *TokenBucketClient {
c := &TokenBucketClient{
client: &http.Client{},
tokens: make(chan struct{}, maxBurst),
}
// Fill the bucket initially
for i := 0; i < maxBurst; i++ {
c.tokens <- struct{}{}
}
// Refill the bucket at the specified rate in the background
go func() {
ticker := time.NewTicker(time.Second / time.Duration(refillRate))
for range ticker.C {
select {
case c.tokens <- struct{}{}:
default:
// Bucket is full, drop the token
}
}
}()
return c
}
func (c *TokenBucketClient) Do(req *http.Request) (*http.Response, error) {
<-c.tokens // Block until a token is available
return c.client.Do(req)
}
One detail I should mention: notice the select block in the refill goroutine. Without the default case, the background goroutine would block forever once the bucket reached maxBurst, which would leak a goroutine or stop your ticker. Always ensure your refill logic is non-blocking.
📋 Practical Task
Exercise: The Shopify-Style Order Synchronizer
You are building a tool that syncs orders from a third-party store. The API allows a maximum of 3 requests per second, but it allows a burst of up to 5 requests if the client has been idle.
Your Task:
- Implement a
SyncClientusing the Token Bucket pattern described in the lesson. - The client must support a burst of 5 and a refill rate of 3 per second.
- Write a
mainfunction that launches 20 concurrent goroutines, each attempting to make a "request" (for the sake of the exercise, a "request" can simply be printing"Request sent at %v"with the current timestamp). - Verify that the first 5 requests happen almost instantaneously, and the subsequent requests are spaced out by approximately 333ms.
There are no comments for now.