Files
agent-desk-ng/internal/middleware/ratelimit_middleware.go

57 lines
1.9 KiB
Go

package middleware
import (
"net/http"
"strconv"
"time"
"agent-desk/internal/pkg/errorsx"
"agent-desk/internal/pkg/httpx"
"agent-desk/internal/pkg/ratelimit"
"github.com/gin-gonic/gin"
)
// rateLimitMessageKey is the localized explanation returned with a 429. It
// deliberately does not disclose the limit or the remaining budget, which would
// only tell a caller how much room they have left.
const rateLimitMessageKey = "error.e0354"
// RateLimit bounds how often one client address may call a single public
// endpoint.
//
// The address comes from ctx.ClientIP(), which is only meaningful because
// NewServer configures Gin's trusted proxies and platform before any middleware
// is registered. Without that a caller picks their own bucket by setting
// X-Forwarded-For, and the limit is decoration.
//
// A nil limiter allows everything, so "disabled" is expressed by handing out nil
// limiters rather than by branching at every call site.
//
// Rejections are not logged here. requestLogMiddleware already records the path,
// status and client address of every request, so a 429 is visible there without
// giving a flood a second way to fill the log.
func RateLimit(limiter *ratelimit.Limiter) gin.HandlerFunc {
return func(ctx *gin.Context) {
allowed, retryAfter := limiter.Allow(ctx.ClientIP())
if allowed {
ctx.Next()
return
}
// Retry-After is a whole number of seconds, so round up: telling a caller
// to come back sooner than the window actually resets just earns another
// 429. Rounding down and adding one overshoots when the remaining time is
// already an exact number of seconds.
seconds := int64(retryAfter / time.Second)
if time.Duration(seconds)*time.Second < retryAfter {
seconds++
}
if seconds < 1 {
seconds = 1
}
ctx.Header("Retry-After", strconv.FormatInt(seconds, 10))
httpx.WriteHttpStatusJSON(ctx, http.StatusTooManyRequests, errorsx.InvalidParamI18n(rateLimitMessageKey))
ctx.Abort()
}
}