Skip to content

Commit 4a8ed8a

Browse files
vishrclaude
andcommitted
feat(middleware): optional RateLimiterStoreContext for response headers (#2961)
Adds an optional RateLimiterStoreContext interface. When the configured store implements AllowContext(c, identifier), the rate limiter calls it instead of Allow, giving the store access to the request context so it can set response headers such as Retry-After / X-RateLimit-*. Fully backward compatible: stores implementing only Allow are unchanged. This is the optional-interface approach proposed by the maintainer in the issue thread; it does not alter the existing Allow interface or the built-in store. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a9ede66 commit 4a8ed8a

2 files changed

Lines changed: 70 additions & 1 deletion

File tree

middleware/rate_limiter.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ type RateLimiterStore interface {
1919
Allow(identifier string) (bool, error)
2020
}
2121

22+
// RateLimiterStoreContext is an optional interface a RateLimiterStore may implement.
23+
// When the configured store implements it, the rate limiter calls AllowContext
24+
// (with the request context) instead of Allow, allowing the store to set response
25+
// headers such as Retry-After or X-RateLimit-* on the allow/deny decision.
26+
type RateLimiterStoreContext interface {
27+
AllowContext(c *echo.Context, identifier string) (bool, error)
28+
}
29+
2230
// RateLimiterConfig defines the configuration for the rate limiter
2331
type RateLimiterConfig struct {
2432
Skipper Skipper
@@ -136,7 +144,14 @@ func (config RateLimiterConfig) ToMiddleware() (echo.MiddlewareFunc, error) {
136144
return config.ErrorHandler(c, err)
137145
}
138146

139-
if allow, allowErr := config.Store.Allow(identifier); !allow {
147+
var allow bool
148+
var allowErr error
149+
if sc, ok := config.Store.(RateLimiterStoreContext); ok {
150+
allow, allowErr = sc.AllowContext(c, identifier)
151+
} else {
152+
allow, allowErr = config.Store.Allow(identifier)
153+
}
154+
if !allow {
140155
return config.DenyHandler(c, identifier, allowErr)
141156
}
142157
return next(c)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// SPDX-License-Identifier: MIT
2+
// SPDX-FileCopyrightText: © 2015 LabStack LLC and Echo contributors
3+
4+
package middleware
5+
6+
import (
7+
"net/http"
8+
"net/http/httptest"
9+
"testing"
10+
11+
"github.com/labstack/echo/v5"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
// ctxAwareStore implements both Allow and the optional AllowContext. AllowContext
16+
// gives the store the request context so it can set response headers (e.g.
17+
// Retry-After / X-RateLimit-*) — see #2961.
18+
type ctxAwareStore struct {
19+
allowCalled bool
20+
ctxAllowCalled bool
21+
allow bool
22+
}
23+
24+
func (s *ctxAwareStore) Allow(identifier string) (bool, error) {
25+
s.allowCalled = true
26+
return s.allow, nil
27+
}
28+
29+
func (s *ctxAwareStore) AllowContext(c *echo.Context, identifier string) (bool, error) {
30+
s.ctxAllowCalled = true
31+
c.Response().Header().Set("Retry-After", "42")
32+
return s.allow, nil
33+
}
34+
35+
// When the store implements AllowContext, the middleware must call it instead of
36+
// Allow, so the store can set rate-limit headers on the response.
37+
func TestRateLimiter_storeAllowContextIsPreferred(t *testing.T) {
38+
e := echo.New()
39+
store := &ctxAwareStore{allow: true}
40+
mw := RateLimiterWithConfig(RateLimiterConfig{
41+
Store: store,
42+
IdentifierExtractor: func(c *echo.Context) (string, error) { return "id", nil },
43+
})
44+
handler := mw(func(c *echo.Context) error { return c.String(http.StatusOK, "ok") })
45+
46+
req := httptest.NewRequest(http.MethodGet, "/", nil)
47+
rec := httptest.NewRecorder()
48+
c := e.NewContext(req, rec)
49+
50+
assert.NoError(t, handler(c))
51+
assert.True(t, store.ctxAllowCalled, "AllowContext should be called when implemented")
52+
assert.False(t, store.allowCalled, "Allow should not be called when AllowContext is implemented")
53+
assert.Equal(t, "42", rec.Header().Get("Retry-After"), "store should be able to set headers via the context")
54+
}

0 commit comments

Comments
 (0)