package limiter
import (
"context"
"errors"
)
var ErrBucketFull = errors.New("limiter: no free slot available")
type Limiter struct {
slots chan struct{}
}
func New(max int) *Limiter {
if max < 1 {
max = 1
}
return &Limiter{slots: make(chan struct{}, max)}
}
func (l *Limiter) Acquire(ctx context.Context) error {
select {
case l.slots <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (l *Limiter) TryAcquire() bool {
select {
case l.slots <- struct{}{}:
return true
default:
return false
}
}
func (l *Limiter) Release() {
select {
case <-l.slots:
default:
panic("limiter: Release called without matching Acquire")
}
}
func (l *Limiter) Do(ctx context.Context, fn func() error) error {
if err := l.Acquire(ctx); err != nil {
return err
}
defer l.Release()
return fn()
}
func (l *Limiter) InFlight() int {
return len(l.slots)
}
package limiter
import (
"net/http"
"strconv"
)
func Middleware(l *Limiter, retryAfter int) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !l.TryAcquire() {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
http.Error(w, "server busy", http.StatusTooManyRequests)
return
}
defer l.Release()
// Respect client cancellation while work is in flight.
next.ServeHTTP(w, r.WithContext(r.Context()))
})
}
}
package main
import (
"context"
"fmt"
"log"
"net/http"
"sync"
"time"
"example.com/app/limiter"
)
func main() {
lim := limiter.New(8)
mux := http.NewServeMux()
mux.HandleFunc("/report", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(50 * time.Millisecond) // simulate downstream work
fmt.Fprintf(w, "in-flight: %d\n", lim.InFlight())
})
handler := limiter.Middleware(lim, 2)(mux)
go fanOut(lim)
log.Println("listening on :8080")
log.Fatal(http.ListenAndServe(":8080", handler))
}
func fanOut(lim *limiter.Limiter) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
err := lim.Do(ctx, func() error {
time.Sleep(20 * time.Millisecond)
return nil
})
if err != nil {
log.Printf("job %d dropped: %v", id, err)
}
}(i)
}
wg.Wait()
}
A buffered channel is one of the simplest ways to bound how many goroutines run a piece of work at once. In limiter.go the Limiter wraps a chan struct{} whose capacity equals the maximum number of concurrent slots. Acquiring a slot is a send into the channel; releasing is a receive. Because the channel has a fixed buffer, the (N+1)th sender blocks until an in-flight worker drains a slot, which is exactly the leaky-bucket shape: the bucket holds N tokens and leaks one every time a job finishes.
The empty struct element type is deliberate — struct{} occupies zero bytes, so the channel only tracks occupancy, not payload. Acquire takes a context.Context and uses select to race the send against ctx.Done(), so a caller with a deadline or a cancelled request never blocks forever waiting for a slot. TryAcquire offers the non-blocking variant with a default branch, returning immediately when the bucket is full. The Do helper pairs an acquire with a guaranteed defer l.Release(), which is the safest way to use the limiter because the slot is returned even if the wrapped function panics.
A subtle correctness rule shows up in Release: it must only be called after a successful Acquire. Releasing without acquiring would push an extra token into the bucket and silently raise the effective concurrency, so the Do method exists precisely to make the acquire/release pairing hard to get wrong.
In middleware.go the limiter is applied as HTTP middleware. Middleware calls TryAcquire and sheds load with 429 Too Many Requests plus a Retry-After header when the bucket is saturated, rather than queueing unbounded requests and blowing up memory. Using the request's own r.Context() means a client disconnect frees the slot promptly.
The pattern trades a hard concurrency ceiling for predictable resource usage and backpressure. It does not smooth bursts over time the way a token-bucket rate limiter does — it caps simultaneous work, not requests per second. It is the right tool for protecting a downstream dependency (a database pool, an upstream API) from being overwhelmed by fan-out. The main.go tab wires a size-limited Limiter into a server and demonstrates Do for a background fan-out task.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
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
export type Settled<R> =
| { status: 'fulfilled'; value: R }
| { status: 'rejected'; reason: unknown };
export interface ConcurrencyOptions {
limit: number;
Simple concurrency limiter for batch operations
Share this code
Here's the card — post it anywhere.