go 129 lines · 3 tabs

Leaky-Bucket Concurrency Limiter with a Buffered Semaphore Channel in Go

Shared by codesnips Aug 2026
3 tabs
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)
}
3 files · go Explain with highlit

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

Share this code

Here's the card — post it anywhere.

Leaky-Bucket Concurrency Limiter with a Buffered Semaphore Channel in Go — share card
Link copied