Skip to content

Repository files navigation

duallimit

Go Reference License: MIT

Redis sliding-window rate limiter with local memory fallback and human-readable retry messages for Go.

Why duallimit?

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.

Features

  • 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.
  • Remove refunds 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/http middleware with X-RateLimit-* and Retry-After headers.

Installation

go get github.com/fumbledlol/duallimit

Usage

Redis with in-memory fallback

package 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.
	}
}

HTTP middleware

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)
}

Refunding failed operations

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)
}

License

MIT

About

Redis sliding-window rate limiter with in-memory fallback and human-readable retry messages

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages