Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions i18n/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ const (

// Rate limit related messages
const (
MsgRateLimitExceeded = "rate_limit.exceeded"
MsgRateLimitReached = "rate_limit.reached"
MsgRateLimitTotalReached = "rate_limit.total_reached"
)
Expand Down
1 change: 1 addition & 0 deletions i18n/locales/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,7 @@ twofa.record_id_empty: "2FA record ID cannot be empty"
twofa.code_invalid: "Verification code or backup code is incorrect"

# Rate limit messages
rate_limit.exceeded: "Too many requests. Please try again later."
rate_limit.reached: "You have reached the request limit: maximum {{.Max}} requests in {{.Minutes}} minutes"
rate_limit.total_reached: "You have reached the total request limit: maximum {{.Max}} requests in {{.Minutes}} minutes, including failed attempts"

Expand Down
1 change: 1 addition & 0 deletions i18n/locales/zh-CN.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ twofa.record_id_empty: "2FA记录ID不能为空"
twofa.code_invalid: "验证码或备用码不正确"

# Rate limit messages
rate_limit.exceeded: "请求过于频繁,请稍后再试"
rate_limit.reached: "您已达到请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次"
rate_limit.total_reached: "您已达到总请求数限制:{{.Minutes}}分钟内最多请求{{.Max}}次,包括失败次数"

Expand Down
1 change: 1 addition & 0 deletions i18n/locales/zh-TW.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ twofa.record_id_empty: "2FA記錄ID不能為空"
twofa.code_invalid: "驗證碼或備用碼不正確"

# Rate limit messages
rate_limit.exceeded: "請求過於頻繁,請稍後再試"
rate_limit.reached: "您已達到請求數限制:{{.Minutes}}分鐘內最多請求{{.Max}}次"
rate_limit.total_reached: "您已達到總請求數限制:{{.Minutes}}分鐘內最多請求{{.Max}}次,包括失敗次數"

Expand Down
13 changes: 9 additions & 4 deletions middleware/rate-limit.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strconv"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/QuantumNous/new-api/logger"
"github.com/gin-gonic/gin"
)
Expand Down Expand Up @@ -124,6 +125,7 @@ func redisRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark st
}
}

// memoryRateLimiter enforces an IP-based limit using process-local state.
func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark string) {
key := mark + c.ClientIP()
if !inMemoryRateLimiter.Request(key, maxRequestNum, duration) {
Expand All @@ -132,18 +134,21 @@ func memoryRateLimiter(c *gin.Context, maxRequestNum int, duration int64, mark s
}
}

// writeRateLimited rejects the request with 429 and a Retry-After hint so
// clients can back off instead of treating the rejection as a fatal error.
// writeRateLimited rejects the request with a structured 429 response and a
// Retry-After hint so clients can report the error and back off safely.
// The in-memory limiter cannot report the remaining window, so callers
// without a TTL pass the full window duration as a conservative upper bound.
func writeRateLimited(c *gin.Context, retryAfterSeconds int64) {
if retryAfterSeconds > 0 {
c.Header("Retry-After", strconv.FormatInt(retryAfterSeconds, 10))
}
c.Status(http.StatusTooManyRequests)
c.Abort()
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"success": false,
"message": common.TranslateMessage(c, i18n.MsgRateLimitExceeded),
})
}

// rateLimitFactory selects the configured backend for an IP-based rate limiter.
func rateLimitFactory(maxRequestNum int, duration int64, mark string) func(c *gin.Context) {
if common.RedisEnabled {
return func(c *gin.Context) {
Expand Down
67 changes: 63 additions & 4 deletions middleware/rate_limit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package middleware

import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
Expand All @@ -10,13 +11,17 @@ import (
"time"

"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/i18n"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/go-redis/redis/v8"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

var memoryCriticalRateLimitTestRun atomic.Uint64

// useRateLimitMiniRedis configures an isolated Redis backend for rate-limit tests.
func useRateLimitMiniRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client) {
t.Helper()

Expand All @@ -37,6 +42,7 @@ func useRateLimitMiniRedis(t *testing.T) (*miniredis.Miniredis, *redis.Client) {
return redisServer, redisClient
}

// performRateLimitRequest records a GET request with a controlled client address.
func performRateLimitRequest(router http.Handler, path string, remoteAddr string) *httptest.ResponseRecorder {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, path, nil)
Expand All @@ -45,27 +51,80 @@ func performRateLimitRequest(router http.Handler, path string, remoteAddr string
return recorder
}

func TestRedisIPRateLimiterThresholdTTLAndNamespace(t *testing.T) {
// TestMemoryCriticalRateLimiterReturnsJSONWithRetryAfter verifies the in-memory 429 response contract.
func TestMemoryCriticalRateLimiterReturnsJSONWithRetryAfter(t *testing.T) {
gin.SetMode(gin.TestMode)
require.NoError(t, i18n.Init())

previousRedisEnabled := common.RedisEnabled
previousEnabled := common.CriticalRateLimitEnable
previousMaxRequests := common.CriticalRateLimitNum
previousDuration := common.CriticalRateLimitDuration
common.RedisEnabled = false
common.CriticalRateLimitEnable = true
common.CriticalRateLimitNum = 2
common.CriticalRateLimitDuration = 43
t.Cleanup(func() {
common.RedisEnabled = previousRedisEnabled
common.CriticalRateLimitEnable = previousEnabled
common.CriticalRateLimitNum = previousMaxRequests
common.CriticalRateLimitDuration = previousDuration
})

router := gin.New()
require.NoError(t, router.SetTrustedProxies(nil))
router.GET("/limited", CriticalRateLimit(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})

runID := memoryCriticalRateLimitTestRun.Add(1)
remoteAddr := fmt.Sprintf("[2001:db8::%x]:12345", runID)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code)
limitedResponse := performRateLimitRequest(router, "/limited", remoteAddr)
assert.Equal(t, http.StatusTooManyRequests, limitedResponse.Code)
assert.Equal(t, "43", limitedResponse.Header().Get("Retry-After"))
assert.Equal(t, "application/json; charset=utf-8", limitedResponse.Header().Get("Content-Type"))
assert.JSONEq(t, `{"success":false,"message":"Too many requests. Please try again later."}`, limitedResponse.Body.String())
}

// TestRedisCriticalRateLimiterReturnsJSONWithRetryAfter verifies the Redis-backed 429 response contract.
func TestRedisCriticalRateLimiterReturnsJSONWithRetryAfter(t *testing.T) {
gin.SetMode(gin.TestMode)
require.NoError(t, i18n.Init())
redisServer, _ := useRateLimitMiniRedis(t)

previousEnabled := common.CriticalRateLimitEnable
previousMaxRequests := common.CriticalRateLimitNum
previousDuration := common.CriticalRateLimitDuration
common.CriticalRateLimitEnable = true
common.CriticalRateLimitNum = 2
common.CriticalRateLimitDuration = 37
t.Cleanup(func() {
common.CriticalRateLimitEnable = previousEnabled
common.CriticalRateLimitNum = previousMaxRequests
common.CriticalRateLimitDuration = previousDuration
})

router := gin.New()
require.NoError(t, router.SetTrustedProxies(nil))
router.GET("/limited", rateLimitFactory(2, 37, "TEST"), func(c *gin.Context) {
router.GET("/limited", CriticalRateLimit(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})

remoteAddr := "192.0.2.10:12345"
legacyKey := "rateLimit:TEST192.0.2.10"
legacyKey := "rateLimit:CT192.0.2.10"
_, err := redisServer.Push(legacyKey, "legacy-list-entry")
require.NoError(t, err)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code)
assert.Equal(t, http.StatusNoContent, performRateLimitRequest(router, "/limited", remoteAddr).Code)
limitedResponse := performRateLimitRequest(router, "/limited", remoteAddr)
assert.Equal(t, http.StatusTooManyRequests, limitedResponse.Code)
assert.Equal(t, "37", limitedResponse.Header().Get("Retry-After"))
assert.Equal(t, "application/json; charset=utf-8", limitedResponse.Header().Get("Content-Type"))
assert.JSONEq(t, `{"success":false,"message":"Too many requests. Please try again later."}`, limitedResponse.Body.String())

key := redisIPRateLimitKey("TEST", "192.0.2.10")
key := redisIPRateLimitKey("CT", "192.0.2.10")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
count, err := redisServer.Get(key)
require.NoError(t, err)
assert.Equal(t, "3", count)
Expand Down