Skip to content

Commit 11fdbda

Browse files
vishrclaude
andcommitted
feat(middleware): set X-RateLimit-* / Retry-After from built-in store (#2961)
Implements AllowContext on RateLimiterMemoryStore so the default store sets X-RateLimit-Limit, X-RateLimit-Remaining, and (on deny) Retry-After headers out of the box — mirroring the v4 PR #2985 by @leno23 on the v5 line. Allow() is refactored to share an internal allow() with AllowContext; the optional RateLimiterStoreContext interface (added earlier in this PR) routes the middleware to AllowContext when the store implements it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 4a8ed8a commit 11fdbda

2 files changed

Lines changed: 77 additions & 3 deletions

File tree

middleware/rate_limiter.go

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@ import (
77
"errors"
88
"math"
99
"net/http"
10+
"strconv"
1011
"sync"
1112
"time"
1213

1314
"github.com/labstack/echo/v5"
1415
"golang.org/x/time/rate"
1516
)
1617

18+
// Rate limit response headers set by stores that implement RateLimiterStoreContext.
19+
const (
20+
HeaderXRateLimitLimit = "X-RateLimit-Limit"
21+
HeaderXRateLimitRemaining = "X-RateLimit-Remaining"
22+
)
23+
1724
// RateLimiterStore is the interface to be implemented by custom stores.
1825
type RateLimiterStore interface {
1926
Allow(identifier string) (bool, error)
@@ -247,7 +254,22 @@ var DefaultRateLimiterMemoryStoreConfig = RateLimiterMemoryStoreConfig{
247254

248255
// Allow implements RateLimiterStore.Allow
249256
func (store *RateLimiterMemoryStore) Allow(identifier string) (bool, error) {
257+
_, allowed := store.allow(identifier)
258+
return allowed, nil
259+
}
260+
261+
// AllowContext implements RateLimiterStoreContext: it makes the allow/deny decision
262+
// and sets the X-RateLimit-* (and Retry-After when denied) response headers.
263+
func (store *RateLimiterMemoryStore) AllowContext(c *echo.Context, identifier string) (bool, error) {
264+
limiter, allowed := store.allow(identifier)
265+
store.setRateLimitHeaders(c, limiter, allowed)
266+
return allowed, nil
267+
}
268+
269+
func (store *RateLimiterMemoryStore) allow(identifier string) (*rate.Limiter, bool) {
250270
store.mutex.Lock()
271+
defer store.mutex.Unlock()
272+
251273
limiter, exists := store.visitors[identifier]
252274
if !exists {
253275
limiter = new(Visitor)
@@ -259,9 +281,26 @@ func (store *RateLimiterMemoryStore) Allow(identifier string) (bool, error) {
259281
if now.Sub(store.lastCleanup) > store.expiresIn {
260282
store.cleanupStaleVisitors(now)
261283
}
262-
allowed := limiter.AllowN(now, 1)
263-
store.mutex.Unlock()
264-
return allowed, nil
284+
return limiter.Limiter, limiter.AllowN(now, 1)
285+
}
286+
287+
func (store *RateLimiterMemoryStore) setRateLimitHeaders(c *echo.Context, limiter *rate.Limiter, allowed bool) {
288+
header := c.Response().Header()
289+
header.Set(HeaderXRateLimitLimit, strconv.Itoa(store.burst))
290+
291+
remaining := int(math.Floor(limiter.Tokens()))
292+
if remaining < 0 {
293+
remaining = 0
294+
}
295+
header.Set(HeaderXRateLimitRemaining, strconv.Itoa(remaining))
296+
297+
if !allowed {
298+
reservation := limiter.ReserveN(store.timeNow(), 1)
299+
if delay := reservation.Delay(); delay > 0 {
300+
header.Set(echo.HeaderRetryAfter, strconv.Itoa(int(math.Ceil(delay.Seconds()))))
301+
}
302+
reservation.Cancel()
303+
}
265304
}
266305

267306
/*

middleware/rate_limiter_context_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package middleware
66
import (
77
"net/http"
88
"net/http/httptest"
9+
"strconv"
910
"testing"
1011

1112
"github.com/labstack/echo/v5"
@@ -52,3 +53,37 @@ func TestRateLimiter_storeAllowContextIsPreferred(t *testing.T) {
5253
assert.False(t, store.allowCalled, "Allow should not be called when AllowContext is implemented")
5354
assert.Equal(t, "42", rec.Header().Get("Retry-After"), "store should be able to set headers via the context")
5455
}
56+
57+
// The built-in memory store implements AllowContext, so it sets X-RateLimit-Limit /
58+
// X-RateLimit-Remaining on every request and Retry-After when the limit is hit (#2961).
59+
func TestRateLimiterMemoryStore_AllowContextSetsHeaders(t *testing.T) {
60+
store := NewRateLimiterMemoryStoreWithConfig(RateLimiterMemoryStoreConfig{Rate: 1, Burst: 3})
61+
e := echo.New()
62+
e.GET("/", func(c *echo.Context) error { return c.String(http.StatusOK, "ok") },
63+
RateLimiterWithConfig(RateLimiterConfig{
64+
Store: store,
65+
IdentifierExtractor: func(c *echo.Context) (string, error) { return "id", nil },
66+
}))
67+
68+
do := func() *httptest.ResponseRecorder {
69+
req := httptest.NewRequest(http.MethodGet, "/", nil)
70+
rec := httptest.NewRecorder()
71+
e.ServeHTTP(rec, req)
72+
return rec
73+
}
74+
75+
// Burst of 3: each allowed request advertises the limit and decreasing remaining.
76+
for i := 0; i < 3; i++ {
77+
rec := do()
78+
assert.Equal(t, http.StatusOK, rec.Code)
79+
assert.Equal(t, "3", rec.Header().Get(HeaderXRateLimitLimit))
80+
assert.Equal(t, strconv.Itoa(2-i), rec.Header().Get(HeaderXRateLimitRemaining))
81+
assert.Empty(t, rec.Header().Get(echo.HeaderRetryAfter))
82+
}
83+
84+
// 4th request is denied: 429, remaining 0, and a Retry-After hint.
85+
rec := do()
86+
assert.Equal(t, http.StatusTooManyRequests, rec.Code)
87+
assert.Equal(t, "0", rec.Header().Get(HeaderXRateLimitRemaining))
88+
assert.NotEmpty(t, rec.Header().Get(echo.HeaderRetryAfter))
89+
}

0 commit comments

Comments
 (0)