A leaky bucket rate limiter for Go using valkey
  • Go 96.4%
  • Nix 3.6%
Find a file
AltF02 1f76774b49
All checks were successful
ci/woodpecker/push/go-test Pipeline was successful
feat(ci): create test pipeline
Signed-off-by: AltF02 <contact@altf2.dev>
2026-02-19 10:46:58 +01:00
.woodpecker feat(ci): create test pipeline 2026-02-19 10:46:58 +01:00
.envrc feat(flake): introduce direnv flake env 2026-02-19 10:32:03 +01:00
bucket.go feat: create capacitor 2026-02-18 17:27:02 +01:00
capacitor.go feat: change the key syntax 2026-02-18 17:49:13 +01:00
capacitor_test.go chore(tests): rename to btoi 2026-02-19 10:31:49 +01:00
config.go feat: change the key syntax 2026-02-18 17:49:13 +01:00
fallback.go feat: add unit tests for middleware and metrics 2026-02-18 17:35:18 +01:00
flake.lock feat(flake): introduce direnv flake env 2026-02-19 10:32:03 +01:00
flake.nix feat(flake): introduce direnv flake env 2026-02-19 10:32:03 +01:00
go.mod feat: create capacitor 2026-02-18 17:27:02 +01:00
go.sum feat: create capacitor 2026-02-18 17:27:02 +01:00
LICENSE feat: create capacitor 2026-02-18 17:27:02 +01:00
metrics.go feat: create capacitor 2026-02-18 17:27:02 +01:00
metrics_test.go feat: add unit tests for middleware and metrics 2026-02-18 17:35:18 +01:00
middleware.go feat: create capacitor 2026-02-18 17:27:02 +01:00
middleware_test.go chore(tests): rename to btoi 2026-02-19 10:31:49 +01:00
README.md feat: change the key syntax 2026-02-18 17:49:13 +01:00

Capacitor

A leaky-bucket rate limiter for Go, backed by Valkey. Atomic bucket logic runs server-side via a Lua script, making it safe for distributed deployments. Ships with drop-in net/http middleware.

Features

  • Atomic leaky-bucket algorithm executed in a single Valkey round-trip
  • Standard func(http.Handler) http.Handler middleware — works with http.ServeMux, chi, gorilla/mux, and any http.Handler-based router
  • Configurable key extraction (IP, header, custom function)
  • IETF RateLimit header fields on every response
  • Fallback strategy when Valkey is unreachable (fail-open or fail-closed)
  • Optional structured logging (log/slog) and metrics collection

Installation

go get codeberg.org/matthew/capacitor

Requires Go 1.22+ and a running Valkey (or Redis 7+) instance.

Quick Start

package main

import (
	"log"
	"net/http"
	"time"

	"github.com/valkey-io/valkey-go"
	"codeberg.org/matthew/capacitor"
)

func main() {
	client, err := valkey.NewClient(valkey.ClientOption{
		InitAddress: []string{"localhost:6379"},
	})
	if err != nil {
		log.Fatal(err)
	}

	limiter := capacitor.New(client, capacitor.Config{
		Capacity:  10,
		LeakRate:  1, // 1 token per second
		Timeout:   500 * time.Millisecond,
	})
	defer limiter.Close()

	mux := http.NewServeMux()
	mux.HandleFunc("GET /hello", func(w http.ResponseWriter, r *http.Request) {
		w.Write([]byte("Hello, world!\n"))
	})

	rl := capacitor.NewMiddleware(limiter)

	log.Println("listening on :8080")
	http.ListenAndServe(":8080", rl(mux))
}

Configuration

Field Type Description
KeyPrefix string Prefix for Valkey keys (e.g. "capacitor"capacitor:uid:<uid>)
Capacity int64 Maximum tokens in the bucket
LeakRate float64 Tokens drained per second
Timeout time.Duration Per-call Valkey timeout

Middleware Options

WithKeyFunc

Controls how the rate-limit key is derived from each request. Defaults to KeyFromRemoteIP.

// Rate-limit by API key header.
rl := capacitor.NewMiddleware(limiter,
	capacitor.WithKeyFunc(capacitor.KeyFromHeader("X-API-Key")),
)

Built-in key functions:

Function Key source
KeyFromRemoteIP Client IP from RemoteAddr (default)
KeyFromHeader(name) Value of the given HTTP header

You can provide any func(*http.Request) string. Return an empty string to skip rate limiting for that request.

WithDenyHandler

Replaces the default plain-text 429 response.

rl := capacitor.NewMiddleware(limiter,
	capacitor.WithDenyHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusTooManyRequests)
		w.Write([]byte(`{"error":"rate limited"}`))
	})),
)

Limiter Options

Pass these to capacitor.New:

Option Description
WithLogger(logger) Structured logger (*slog.Logger)
WithFallback(strategy) FallbackFailOpen (default) or FallbackFailClosed
WithMetrics(collector) Optional MetricsCollector implementation

Response Headers

Every response includes standard rate-limit headers:

Header Description
RateLimit-Limit Bucket capacity
RateLimit-Remaining Tokens remaining
RateLimit-Reset Seconds until a token becomes available (denied requests only)
Retry-After Same value as RateLimit-Reset (denied requests only)

Direct Usage (Without Middleware)

You can call the limiter directly for non-HTTP use cases such as background workers or gRPC interceptors:

result, err := limiter.Attempt(ctx, "user:42")
if err != nil {
	// Valkey unreachable — result contains the fallback decision.
	log.Println("fallback used:", err)
}

if !result.Allowed {
	log.Printf("denied, retry after %s\n", result.RetryAfter)
}

Health Check

if err := limiter.HealthCheck(ctx); err != nil {
	log.Fatal("valkey unreachable:", err)
}

License

EUPL-1.2