Files
2026-07-14 10:24:10 +08:00

655 lines
22 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package middleware
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"sync/atomic"
"time"
"github.com/EthanCodeCraft/xlgo-core/database"
"github.com/EthanCodeCraft/xlgo-core/response"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
// RateLimiter 速率限制器(内存版,单实例使用)
type RateLimiter struct {
visitors map[string]*visitor
mu sync.RWMutex
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
ctx context.Context
cancel context.CancelFunc
wg sync.WaitGroup
nowFunc func() time.Time // 时间源(默认 time.Now,测试可注入可控时钟)
}
type visitor struct {
// windowStart 当前固定窗口的起点。仅在新窗口开始时设置,放行时不变更(H4a 修复)。
// 旧实现每次放行都更新 lastSeen,导致 time.Since(lastSeen) > window 重置分支对持续
// 客户端永不成立、count 单调累加,稳态客户端(低于 rate)被误限流。
windowStart time.Time
count int
}
// NewRateLimiter 创建速率限制器(内存版)。
// rate<=0 或 window<=0 时 panic。内部启动 cleanup goroutine,调用方负责在不再使用时
// 调用 (*RateLimiter).Stop 释放,否则 goroutine 泄漏。
func NewRateLimiter(rate int, window time.Duration) *RateLimiter {
mustValidRateLimit(rate, window)
ctx, cancel := context.WithCancel(context.Background())
limiter := &RateLimiter{
visitors: make(map[string]*visitor),
rate: rate,
window: window,
ctx: ctx,
cancel: cancel,
nowFunc: time.Now,
}
limiter.wg.Add(1)
go limiter.cleanupVisitors()
return limiter
}
func mustValidRateLimit(rate int, window time.Duration) {
if rate <= 0 {
panic("rate limiter: rate must be positive")
}
if window <= 0 {
panic("rate limiter: window must be positive")
}
}
// now 返回当前时间(可被测试注入 nowFunc 覆盖)。
func (rl *RateLimiter) now() time.Time {
if rl.nowFunc != nil {
return rl.nowFunc()
}
return time.Now()
}
// SetNowFunc 注入时间源(默认 time.Now),供测试用可控时钟验证窗口语义。
// 生产代码通常无需调用。
func (rl *RateLimiter) SetNowFunc(f func() time.Time) {
rl.mu.Lock()
rl.nowFunc = f
rl.mu.Unlock()
}
// Allow 检查是否允许请求。
//
// 固定窗口语义(H4a 修复):windowStart 是当前窗口起点,仅在窗口过期重置时变更;
// 放行时不再更新 windowStart。窗口内 count 达到 rate 即拒绝,窗口过期则重置为新窗口。
// 旧实现每次放行更新 lastSeen,致重置分支对持续客户端永不成立、稳态客户端被误限流。
//
// 注意:固定窗口算法允许窗口边界突发(两窗口交界处瞬时可达 2×rate)。
// 如需平滑限流(无突发),请用 Redis 版 RedisRateLimiter(滑动窗口)。
func (rl *RateLimiter) Allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := rl.now()
v, exists := rl.visitors[ip]
if !exists {
rl.visitors[ip] = &visitor{
windowStart: now,
count: 1,
}
return true
}
// 窗口过期:开新窗口,count 重置为 1。
if now.Sub(v.windowStart) > rl.window {
v.windowStart = now
v.count = 1
return true
}
// 当前窗口内已达上限:拒绝(不更新 windowStart)。
if v.count >= rl.rate {
return false
}
// 放行:count++windowStart 不变(固定窗口语义)。
v.count++
return true
}
// cleanupVisitors 清理过期的访问者记录
func (rl *RateLimiter) cleanupVisitors() {
defer rl.wg.Done()
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for {
select {
case <-rl.ctx.Done():
return
case <-ticker.C:
rl.mu.Lock()
for ip, v := range rl.visitors {
// 窗口起点超 window 未活跃即淘汰(H4awindowStart 是窗口起点,不再被放行更新)。
if rl.now().Sub(v.windowStart) > rl.window {
delete(rl.visitors, ip)
}
}
rl.mu.Unlock()
}
}
}
// Stop 停止限流器(释放资源)
func (rl *RateLimiter) Stop() {
rl.cancel()
rl.wg.Wait()
}
// ===== Redis 分布式限流器 =====
// H4c: Redis 限流器故障相关错误。
var (
// ErrRedisRateLimiterUnavailable Redis 未启用(database.GetRedis() == nil)。
// fail-closed 限流器在此情况下拒绝请求;fail-open 限流器放行。
ErrRedisRateLimiterUnavailable = errors.New("redis rate limiter: redis client unavailable")
// ErrRedisRateLimiterUnexpectedResult Redis 返回非预期的结果类型(非 int64)。
// fail-closed 限流器拒绝;fail-open 限流器放行。旧实现裸断言会 panic。
ErrRedisRateLimiterUnexpectedResult = errors.New("redis rate limiter: unexpected result type")
)
// RedisRateLimiter Redis 分布式限流器
type RedisRateLimiter struct {
keyPrefix string // 键名前缀
rate int // 每分钟允许的请求数
window time.Duration // 时间窗口
failClosed atomic.Bool // H4c/H-6: Redis 错误/断言失败时是否拒绝(true=安全型 fail-closed)。atomic 以支持运行期 SetFailClosed 并发安全切换。
client *redis.Client // Phase 5: 注入的 redis clientnil 回退 database.GetRedis(),照 jwt.TokenBlacklist 模型)。App 经 WithRedisClient 注入 per-App client 实现隔离。
}
// slidingWindowLua 滑动窗口限流 Lua 脚本
// 返回: 当前窗口内的请求数
const slidingWindowLua = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
-- 移除窗口外的旧记录
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- 获取当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count < rate then
-- 添加当前请求
redis.call('ZADD', key, now, now .. '-' .. math.random())
redis.call('PEXPIRE', key, window)
return 0
else
return count
end
`
// RedisRateLimiterOption 配置 RedisRateLimiter 的可选策略。
type RedisRateLimiterOption func(*RedisRateLimiter)
// WithFailClosed 设置 Redis 故障时 fail-closed(拒绝请求)。
// 不传或传 false 为 fail-open(放行,兼容默认)。安全敏感场景(登录防爆破等)应传 true。
func WithFailClosed(failClosed bool) RedisRateLimiterOption {
return func(rl *RedisRateLimiter) {
rl.failClosed.Store(failClosed)
}
}
// WithRedisClient 注入指定 Redis 客户端(Phase 5,多 Redis/测试隔离)。
// 不传则回退 database.GetRedis()(全局)。App 可经 app.RedisClient() 取自身 client 注入。
func WithRedisClient(client *redis.Client) RedisRateLimiterOption {
return func(rl *RedisRateLimiter) {
rl.client = client
}
}
// NewRedisRateLimiter 创建 Redis 分布式限流器。
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景传 WithFailClosed(true)。
func NewRedisRateLimiter(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) *RedisRateLimiter {
mustValidRateLimit(rate, window)
rl := &RedisRateLimiter{
keyPrefix: keyPrefix,
rate: rate,
window: window,
// failClosed 零值 falsefail-open,兼容默认)
}
for _, opt := range opts {
if opt != nil {
opt(rl)
}
}
return rl
}
// SetFailClosed 设置 Redis 故障时的策略:true=拒绝(安全型),false=放行(兼容默认)。
// 供已创建的限流器切换策略。并发安全(atomic.Store),可与并发 Allow 调用共存。
func (rl *RedisRateLimiter) SetFailClosed(failClosed bool) {
rl.failClosed.Store(failClosed)
}
// redisClient 返回注入的 redis client,未注入则回退 database.GetRedis()jwt 模型)。
// M-C 教训:取一次复用,避免 nil 检查与 Eval 各调一次 GetRedis() 之间的 CloseRedis 竞态。
func (rl *RedisRateLimiter) redisClient() *redis.Client {
if rl != nil && rl.client != nil {
return rl.client
}
return database.GetRedis()
}
// Allow 检查是否允许请求。
//
// H4c 修复:
// - result.(int64) 改 comma-ok,断言失败返 ErrRedisRateLimiterUnexpectedResult 而非 panic。
// - Redis 错误/断言失败时按 failClosed 策略决定:fail-closed 返 (false, err) 拒绝,
// fail-open 返 (true, err) 放行(兼容旧行为)。中间件层据此 allowed 值决定放行/拒绝,
// 不再无条件 fail-open--登录防爆破等安全场景用 fail-closed 限流器即可在 Redis 故障时拒绝。
//
// Redis 未启用(redisClient() == nil)时:fail-closed 返 (false, ErrRedisRateLimiterUnavailable)
// fail-open 返 (true, nil)(兼容旧行为)。安全场景必须确保 Redis 已启用。
//
// Phase 5redisClient() 注入优先、全局兜底,App 经 WithRedisClient 注入 per-App client。
func (rl *RedisRateLimiter) Allow(ctx context.Context, identifier string) (bool, error) {
// M-C 修复:取一次复用。原实现 nil 检查与 Eval 各调一次 GetRedis()
// 两次之间若 CloseRedis,第二次返回 nil -> nil.Eval panic。
rdb := rl.redisClient()
if rdb == nil {
if rl.failClosed.Load() {
return false, ErrRedisRateLimiterUnavailable
}
return true, nil
}
key := rl.keyPrefix + ":" + identifier
now := float64(time.Now().UnixMilli())
windowMs := float64(rl.window.Milliseconds())
result, err := rdb.Eval(ctx, slidingWindowLua, []string{key}, now, windowMs, rl.rate).Result()
if err != nil {
if rl.failClosed.Load() {
return false, err
}
return true, err // 出错时允许请求,避免影响业务(兼容旧行为)
}
// H4c: comma-ok 断言,避免 Redis 返回非 int64 时 panic。
count, ok := result.(int64)
if !ok {
if rl.failClosed.Load() {
return false, ErrRedisRateLimiterUnexpectedResult
}
return true, ErrRedisRateLimiterUnexpectedResult
}
return count == 0, nil
}
// GetCount 获取当前窗口内的请求数
func (rl *RedisRateLimiter) GetCount(ctx context.Context, identifier string) (int64, error) {
// M-C 修复(Phase 5:改 redisClient):取一次复用(原三次调用存在 nil-deref 窗口)。
rdb := rl.redisClient()
if rdb == nil {
return 0, nil
}
key := rl.keyPrefix + ":" + identifier
now := time.Now().UnixMilli()
windowStart := now - rl.window.Milliseconds()
// 移除旧记录并获取当前计数
rdb.ZRemRangeByScore(ctx, key, "0", fmt.Sprintf("%d", windowStart))
return rdb.ZCard(ctx, key).Result()
}
// Reset 重置限流计数
func (rl *RedisRateLimiter) Reset(ctx context.Context, identifier string) error {
// M-C 修复(Phase 5:改 redisClient):取一次复用(原两次调用存在 nil-deref 窗口)。
rdb := rl.redisClient()
if rdb == nil {
return nil
}
key := rl.keyPrefix + ":" + identifier
return rdb.Del(ctx, key).Err()
}
// ===== 全局限速器 RegistryPhase 5 实例化) =====
//
// 内存限流器(RateLimiter)持 cleanup goroutine,进程级共享会在多 App 下互相污染
// App A Shutdown 经 StopRateLimiters 把 App B 的 loginLimiter 也停了)。故照
// cache.CacheManager / cron.Scheduler 模式引入 RateLimitRegistry:实例化 + 全局默认
// + 包级 facade 代理,App 持自己的 Registry。
//
// 包级 LoginRateLimit() 等改为"请求时解析当前默认 Registry"懒创建限流器,使 App.Init
// swap 后请求落到 App 的 Registry(避免 pre-Init 注册到 init Registry 被 swap 丢弃)。
// RateLimitRegistry 持一组内存限流器,支持 per-App 隔离。
type RateLimitRegistry struct {
mu sync.Mutex
loginLimiter *RateLimiter
apiLimiter *RateLimiter
uploadLimiter *RateLimiter
customLimiters []*RateLimiter
}
// NewRateLimitRegistry 创建限流器注册表实例。
func NewRateLimitRegistry() *RateLimitRegistry {
return &RateLimitRegistry{}
}
func (r *RateLimitRegistry) loginLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter == nil {
r.loginLimiter = NewRateLimiter(10, time.Minute)
}
return r.loginLimiter
}
func (r *RateLimitRegistry) apiLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.apiLimiter == nil {
r.apiLimiter = NewRateLimiter(100, time.Minute)
}
return r.apiLimiter
}
func (r *RateLimitRegistry) uploadLimiterInstance() *RateLimiter {
r.mu.Lock()
defer r.mu.Unlock()
if r.uploadLimiter == nil {
r.uploadLimiter = NewRateLimiter(20, time.Minute)
}
return r.uploadLimiter
}
// registerCustom 登记自定义限流器供 Stop 统一停止(H4b)。
func (r *RateLimitRegistry) registerCustom(l *RateLimiter) {
r.mu.Lock()
r.customLimiters = append(r.customLimiters, l)
r.mu.Unlock()
}
// Init 预初始化标准限流器(可选;不调则首次请求时懒创建)。先停旧的同名限流器释放 goroutine。
func (r *RateLimitRegistry) Init() {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter != nil {
r.loginLimiter.Stop()
}
if r.apiLimiter != nil {
r.apiLimiter.Stop()
}
if r.uploadLimiter != nil {
r.uploadLimiter.Stop()
}
r.loginLimiter = NewRateLimiter(10, time.Minute)
r.apiLimiter = NewRateLimiter(100, time.Minute)
r.uploadLimiter = NewRateLimiter(20, time.Minute)
}
// Stop 停止注册表中所有限流器(释放 cleanup goroutine)。幂等。
func (r *RateLimitRegistry) Stop() {
r.mu.Lock()
defer r.mu.Unlock()
if r.loginLimiter != nil {
r.loginLimiter.Stop()
r.loginLimiter = nil
}
if r.apiLimiter != nil {
r.apiLimiter.Stop()
r.apiLimiter = nil
}
if r.uploadLimiter != nil {
r.uploadLimiter.Stop()
r.uploadLimiter = nil
}
for _, l := range r.customLimiters {
l.Stop()
}
r.customLimiters = nil
}
// --- App-bound 中间件(捕获此 Registry,不查全局默认,多 App per-App 隔离) ---
//
// 与包级 LoginRateLimit() 等的区别:包级在请求时查 GetDefaultRateLimitRegistry()(多 App 下
// 仅最后 Init 的 App 是全局默认,故包级 facade 不提供 per-App 计数隔离);本组方法捕获 r,
// 始终用此 Registry 的 limiter。多 App 场景用 app.RateLimitRegistry().LoginRateLimit() 装配路由。
// LoginRateLimit 返回绑定到此 Registry 的登录限流中间件。
func (r *RateLimitRegistry) LoginRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.loginLimiterInstance())
}
}
// APIRateLimit 返回绑定到此 Registry 的普通 API 限流中间件。
func (r *RateLimitRegistry) APIRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.apiLimiterInstance())
}
}
// UploadRateLimit 返回绑定到此 Registry 的上传限流中间件。
func (r *RateLimitRegistry) UploadRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, r.uploadLimiterInstance())
}
}
// CustomRateLimit 返回绑定到此 Registry 的自定义限流中间件。limiter 在首次请求时创建并
// 登记到此 Registryr.registerCustom),由 App.Shutdown 的 registry.Stop() 收口,无泄漏。
func (r *RateLimitRegistry) CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
var (
once sync.Once
limiter *RateLimiter
)
return func(c *gin.Context) {
once.Do(func() {
limiter = NewRateLimiter(rate, window)
r.registerCustom(limiter)
})
rateLimitAllow(c, limiter)
}
}
// defaultRateLimitRegistry 全局默认 Registryatomic,照 cache/cron 模式)。
var defaultRateLimitRegistry atomic.Pointer[RateLimitRegistry]
func init() {
defaultRateLimitRegistry.Store(NewRateLimitRegistry())
}
// GetDefaultRateLimitRegistry 返回全局默认 Registry。
func GetDefaultRateLimitRegistry() *RateLimitRegistry {
return defaultRateLimitRegistry.Load()
}
// SwapDefaultRateLimitRegistry 置为全局默认,返回被替换的旧(照 Swap 模式)。nil 忽略,返回当前默认。
func SwapDefaultRateLimitRegistry(r *RateLimitRegistry) *RateLimitRegistry {
if r == nil {
return defaultRateLimitRegistry.Load()
}
return defaultRateLimitRegistry.Swap(r)
}
// rateLimitAllow 公共放行/拒绝逻辑(内存版)。
func rateLimitAllow(c *gin.Context, rl *RateLimiter) {
if rl == nil {
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
"限流器未初始化", nil)
c.Abort()
return
}
if !rl.Allow(c.ClientIP()) {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
}
// RateLimit 通用速率限制中间件(内存版,用户自持 limiter 时使用)。
func RateLimit(limiter *RateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, limiter)
}
}
// LoginRateLimit 登录接口速率限制。限流器在首次请求时从当前默认 Registry 懒创建,
// 故 App.Init swap 后用的是 App 的 Registry(避免 pre-Init 注册到 init Registry 被丢弃)。
func LoginRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().loginLimiterInstance())
}
}
// APIRateLimit 普通 API 速率限制。
func APIRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().apiLimiterInstance())
}
}
// UploadRateLimit 上传接口速率限制。
func UploadRateLimit() gin.HandlerFunc {
return func(c *gin.Context) {
rateLimitAllow(c, GetDefaultRateLimitRegistry().uploadLimiterInstance())
}
}
// CustomRateLimit 自定义速率限制(内存版)。限流器在首次请求时创建并登记到当前默认
// Registry(供 Stop 统一停止,避免 cleanup goroutine 泄漏,H4b)。
func CustomRateLimit(rate int, window time.Duration) gin.HandlerFunc {
var (
once sync.Once
limiter *RateLimiter
)
return func(c *gin.Context) {
once.Do(func() {
limiter = NewRateLimiter(rate, window)
GetDefaultRateLimitRegistry().registerCustom(limiter)
})
rateLimitAllow(c, limiter)
}
}
// InitRateLimiters 初始化默认 Registry 的标准限流器(可选,不调则懒创建)。
func InitRateLimiters() {
GetDefaultRateLimitRegistry().Init()
}
// StopRateLimiters 停止默认 Registry 的所有限流器。注意:仅停默认 Registry--
// App 持自己的 Registry 时应在 Shutdown 调 app 自己的 StopPhase 5)。
func StopRateLimiters() {
GetDefaultRateLimitRegistry().Stop()
}
// ===== Redis 分布式限流中间件 =====
// redisLimitDecision 处理 RedisRateLimiter.Allow 的结果,按 allowed 值决定放行/拒绝。
//
// H4c: 不再无条件 fail-open。Allow 已按 limiter 的 failClosed 策略把 Redis 故障翻成
// allowed 值--fail-closed 时 allowed=false(拒绝),fail-open 时 allowed=true(放行)。
// 故 err 与 allowed 的组合语义:
// - err==nil, allowed==true -> 放行
// - err==nil, allowed==false -> 真超限,返 429response.RateLimit
// - err!=nil, allowed==false -> fail-closed 限流器在 Redis 故障下拒绝,返 503(服务不可用)
// - err!=nil, allowed==true -> fail-open 限流器在 Redis 故障下放行(兼容旧行为)
func redisLimitDecision(c *gin.Context, allowed bool, err error) {
if err != nil && !allowed {
// fail-closedRedis 故障时拒绝(防限流静默失效)。返 503 区别于真实超限的 429。
response.Custom(c, http.StatusServiceUnavailable, response.CodeServiceUnavailable,
"限流服务暂时不可用", nil)
c.Abort()
return
}
if !allowed {
response.RateLimit(c)
c.Abort()
return
}
c.Next()
}
// RedisRateLimit Redis 分布式限流中间件。
// 默认 fail-open(Redis 故障时放行,避免影响业务);安全敏感场景(登录防爆破等)
// 传 WithFailClosed(true)Redis 故障时拒绝以防限流失效。
func RedisRateLimit(keyPrefix string, rate int, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
return func(c *gin.Context) {
identifier := c.ClientIP()
// 可选:使用用户ID作为标识(登录后)
// userID := GetUserID(c)
// if userID > 0 {
// identifier = fmt.Sprintf("user:%d", userID)
// }
allowed, err := limiter.Allow(c.Request.Context(), identifier)
redisLimitDecision(c, allowed, err)
}
}
// RedisRateLimitWithIdentifier 自定义标识的 Redis 分布式限流。
// 参数: keyPrefix 键名前缀,rate 1 分钟窗口内允许的请求数,identifierFunc 标识获取函数。
// identifierFunc 为 nil 或返回空串时回退到 c.ClientIP()。默认 fail-open,传 WithFailClosed(true) 切换。
func RedisRateLimitWithIdentifier(keyPrefix string, rate int, identifierFunc func(c *gin.Context) string, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, time.Minute, opts...)
return func(c *gin.Context) {
identifier := ""
if identifierFunc != nil {
identifier = identifierFunc(c)
}
if identifier == "" {
identifier = c.ClientIP()
}
allowed, err := limiter.Allow(c.Request.Context(), identifier)
redisLimitDecision(c, allowed, err)
}
}
// LoginRedisRateLimit 登录接口 Redis 分布式限流(fail-closed)。
//
// H4c: 登录防爆破场景必须 fail-closed--Redis 故障时若 fail-open 则限流失效、
// 攻击者可借 Redis 抖动窗口无限爆破。改为 fail-closedRedis 故障时返 503 拒绝。
func LoginRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("login_limit", 10, WithFailClosed(true))
}
// APIRedisRateLimit API Redis 分布式限流(fail-open,避免影响业务)。
func APIRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("api_limit", 100)
}
// UploadRedisRateLimit 上传接口 Redis 分布式限流(fail-closed)。
// 上传属资源敏感操作,Redis 故障时拒绝以防限流静默失效。
func UploadRedisRateLimit() gin.HandlerFunc {
return RedisRateLimit("upload_limit", 20, WithFailClosed(true))
}
// CustomRedisRateLimit 自定义 Redis 分布式限流。
// 默认 fail-open,传 WithFailClosed(true) 切换为 fail-closed。
func CustomRedisRateLimit(keyPrefix string, rate int, window time.Duration, opts ...RedisRateLimiterOption) gin.HandlerFunc {
limiter := NewRedisRateLimiter(keyPrefix, rate, window, opts...)
return func(c *gin.Context) {
identifier := c.ClientIP()
allowed, err := limiter.Allow(c.Request.Context(), identifier)
redisLimitDecision(c, allowed, err)
}
}