Redis sliding-window rate limiter with local memory fallback and human-readable retry messages for Go.
Distributed rate limiters face an availability trade-off when Redis is slow or unavailable:
- Failing open leaves services unprotected against credential stuffing or scraping.
- Failing closed returns HTTP 500 errors to legitimate users during cache restarts.
duallimit uses Redis sorted sets for sliding-window limits across multiple application servers. If Redis drops connection or errors, it falls back to an in-process token bucket (MemoryLimiter) per node until Redis recovers.
- Uses atomic Redis sorted-set Lua script to track timestamps.
- Only adds accepted requests to the sorted set, avoiding memory growth during denied traffic spikes.
- In-memory fallback handles outages locally without crashing incoming requests.
Removerefunds tokens when an operation fails upstream (e.g. 4xx or 5xx errors).- Formats wait times into readable text, such as
"Please try again in 1 minute and 15 seconds.". - Includes standard
net/httpmiddleware withX-RateLimit-*andRetry-Afterheaders.
go get github.com/fumbledlol/duallimitpackage main
import (
"context"
"fmt"
"log"
"time"
"github.com/fumbledlol/duallimit"
"github.com/redis/go-redis/v9"
)
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "localhost:6379",
})
limiter := duallimit.New(duallimit.Config{
RedisClient: rdb,
Prefix: "myapp:rl",
Fallback: true, // Fall back to local memory if Redis is down
})
ctx := context.Background()
res, err := limiter.Allow(ctx, "user:123", 5, time.Minute)
if err != nil {
log.Fatal(err)
}
if res.Allowed {
fmt.Printf("Allowed: remaining=%d\n", res.Remaining)
} else {
fmt.Println(duallimit.FormatMessage("Rate limit exceeded", res.RetryAfter))
// Rate limit exceeded. Please try again in 45 seconds.
}
}package main
import (
"net/http"
"time"
"github.com/fumbledlol/duallimit"
)
func main() {
limiter := duallimit.New(duallimit.Config{
Fallback: true,
})
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})
// 60 requests per minute by client IP
limited := duallimit.Middleware(limiter, 60, time.Minute, duallimit.IPKeyFunc)(handler)
http.ListenAndServe(":8080", limited)
}res, _ := limiter.Allow(ctx, "ip:"+clientIP, 10, time.Minute)
if !res.Allowed {
return
}
if err := processPayment(); err != nil {
// Refund token so the user is not penalized for backend errors
_ = limiter.Remove(ctx, "ip:"+clientIP, res.Member)
}