duallimit

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 18, 2026 License: MIT Imports: 12 Imported by: 0

README

duallimit

Go Reference

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

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func FormatMessage

func FormatMessage(baseMsg string, retryAfter time.Duration) string

FormatMessage appends a human-friendly retry message to a base error message. E.g. FormatMessage("Too many requests", 75*time.Second) -> "Too many requests. Please try again in 1 minute and 15 seconds."

func FormatRetryAfter

func FormatRetryAfter(d time.Duration) string

FormatRetryAfter formats a duration into human-friendly text (e.g. "1 minute and 15 seconds").

func FormatRetryAfterSeconds

func FormatRetryAfterSeconds(sec int64) string

FormatRetryAfterSeconds formats seconds into human-friendly text.

func IPKeyFunc

func IPKeyFunc(r *http.Request) string

IPKeyFunc extracts client IP from RemoteAddr.

func Middleware

func Middleware(l *Limiter, max int64, period time.Duration, keyFunc KeyFunc) func(http.Handler) http.Handler

Middleware creates a standard net/http rate limiter middleware.

Types

type Config

type Config struct {
	RedisClient *redis.Client
	Prefix      string
	Fallback    bool // If true, automatically degrades to in-memory limiter when Redis is unreachable
	MaxBuckets  int
}

Config holds configuration options for the Limiter.

type KeyFunc

type KeyFunc func(r *http.Request) string

KeyFunc extracts a rate-limiting key from an HTTP request.

type Limiter

type Limiter struct {
	// contains filtered or unexported fields
}

Limiter coordinates distributed Redis rate limiting with automatic in-memory fallback.

func New

func New(cfg Config) *Limiter

New creates a new dual-layer Limiter.

func (*Limiter) Allow

func (l *Limiter) Allow(ctx context.Context, identifier string, max int64, period time.Duration) (Result, error)

Allow checks whether a request fits within the sliding window budget. If Redis is unreachable or unconfigured and Fallback is enabled, it seamlessly degrades to the in-memory token bucket limiter.

func (*Limiter) Memory

func (l *Limiter) Memory() *MemoryLimiter

Memory returns the internal in-memory fallback limiter.

func (*Limiter) Remove

func (l *Limiter) Remove(ctx context.Context, identifier string, member string) error

Remove refunds a consumed request from either Redis or in-memory fallback.

type MemoryLimiter

type MemoryLimiter struct {
	// contains filtered or unexported fields
}

MemoryLimiter is a high-performance in-memory token bucket rate limiter with automatic stale bucket reclamation and capacity bounds.

func NewMemoryLimiter

func NewMemoryLimiter(maxBuckets ...int) *MemoryLimiter

NewMemoryLimiter creates a new in-memory fallback limiter.

func (*MemoryLimiter) Allow

func (m *MemoryLimiter) Allow(key string, max int64, period time.Duration) (allowed bool, remaining int64, retryAfter time.Duration)

Allow consumes one token if available.

func (*MemoryLimiter) Len

func (m *MemoryLimiter) Len() int

Len returns the number of active tracked keys in memory.

func (*MemoryLimiter) Remove

func (m *MemoryLimiter) Remove(key string)

Remove restores one token to the bucket (used to refund budget on failed requests).

type Result

type Result struct {
	Allowed    bool          `json:"allowed"`
	Remaining  int64         `json:"remaining"`
	RetryAfter time.Duration `json:"retry_after"`
	Member     string        `json:"member,omitempty"`
	Fallback   bool          `json:"fallback"`
}

Result contains the outcome of a rate-limit check.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL