Skip to content

Commit b270ce9

Browse files
authored
feat(api): add rate limiter (#2157)
* feat(api): add rate limiter for some endpoints * chore: make tidy * Update packages/api/internal/middleware/ratelimit/ratelimit.go * Apply suggestion from @jakubno * chore: generalize the rate limiter * fix: close redis and feature flags client correctly * fix: test * fix: lint * chore: pr comments * fix: test
1 parent 164f31a commit b270ce9

7 files changed

Lines changed: 567 additions & 29 deletions

File tree

packages/api/go.mod

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/gin-contrib/cors v1.7.6
2929
github.com/gin-contrib/size v1.0.2
3030
github.com/gin-gonic/gin v1.10.1
31+
github.com/go-redis/redis_rate/v10 v10.0.1
3132
github.com/gogo/status v1.1.1
3233
github.com/golang-jwt/jwt/v5 v5.3.1
3334
github.com/golang/protobuf v1.5.4
@@ -36,6 +37,7 @@ require (
3637
github.com/hashicorp/nomad/api v0.0.0-20251216171439-1dee0671280e
3738
github.com/jackc/pgx/v5 v5.7.5
3839
github.com/launchdarkly/go-sdk-common/v3 v3.3.0
40+
github.com/launchdarkly/go-server-sdk/v7 v7.13.0
3941
github.com/oapi-codegen/gin-middleware v1.0.2
4042
github.com/oapi-codegen/runtime v1.1.1
4143
github.com/orcaman/concurrent-map/v2 v2.0.1
@@ -235,7 +237,6 @@ require (
235237
github.com/launchdarkly/go-sdk-events/v3 v3.5.0 // indirect
236238
github.com/launchdarkly/go-semver v1.0.3 // indirect
237239
github.com/launchdarkly/go-server-sdk-evaluation/v3 v3.0.1 // indirect
238-
github.com/launchdarkly/go-server-sdk/v7 v7.13.0 // indirect
239240
github.com/leodido/go-urn v1.4.0 // indirect
240241
github.com/lib/pq v1.11.2 // indirect
241242
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect

packages/api/go.sum

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/api/internal/handlers/store.go

Lines changed: 1 addition & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,6 @@ import (
3232
authdb "github.com/e2b-dev/infra/packages/db/pkg/auth"
3333
"github.com/e2b-dev/infra/packages/db/pkg/pool"
3434
"github.com/e2b-dev/infra/packages/shared/pkg/apierrors"
35-
"github.com/e2b-dev/infra/packages/shared/pkg/factories"
3635
"github.com/e2b-dev/infra/packages/shared/pkg/featureflags"
3736
"github.com/e2b-dev/infra/packages/shared/pkg/logger"
3837
"github.com/e2b-dev/infra/packages/shared/pkg/logs/loki"
@@ -66,7 +65,7 @@ type APIStore struct {
6665
snapshotBuildQuerySem *sharedutils.AdjustableSemaphore
6766
}
6867

69-
func NewAPIStore(ctx context.Context, tel *telemetry.Client, config cfg.Config, serviceName string) *APIStore {
68+
func NewAPIStore(ctx context.Context, tel *telemetry.Client, redisClient redis.UniversalClient, featureFlags *featureflags.Client, config cfg.Config) *APIStore {
7069
logger.L().Info(ctx, "Initializing API store and services")
7170

7271
sqlcDB, err := sqlcdb.NewClient(ctx, config.PostgresConnectionString, pool.WithMaxConnections(config.DBMaxOpenConnections), pool.WithMinIdle(config.DBMinIdleConnections))
@@ -113,15 +112,6 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, config cfg.Config,
113112
if err != nil {
114113
logger.L().Fatal(ctx, "Initializing Nomad client", zap.Error(err))
115114
}
116-
redisClient, err := factories.NewRedisClient(ctx, factories.RedisConfig{
117-
RedisURL: config.RedisURL,
118-
RedisClusterURL: config.RedisClusterURL,
119-
RedisTLSCABase64: config.RedisTLSCABase64,
120-
PoolSize: config.RedisPoolSize,
121-
})
122-
if err != nil {
123-
logger.L().Fatal(ctx, "Initializing Redis client", zap.Error(err))
124-
}
125115

126116
queryLogsProvider, err := loki.NewLokiQueryProvider(config.LokiURL, config.LokiUser, config.LokiPassword)
127117
if err != nil {
@@ -133,14 +123,6 @@ func NewAPIStore(ctx context.Context, tel *telemetry.Client, config cfg.Config,
133123
logger.L().Fatal(ctx, "initializing edge clusters pool failed", zap.Error(err))
134124
}
135125

136-
featureFlags, err := featureflags.NewClient()
137-
if err != nil {
138-
logger.L().Fatal(ctx, "failed to create feature flags client", zap.Error(err))
139-
}
140-
141-
featureFlags.SetServiceName(serviceName)
142-
featureFlags.SetDeploymentName(config.DomainName)
143-
144126
accessTokenGenerator, err := sandbox.NewAccessTokenGenerator(config.SandboxAccessTokenHashSeed)
145127
if err != nil {
146128
logger.L().Fatal(ctx, "Initializing access token generator failed", zap.Error(err))
@@ -273,12 +255,6 @@ func (a *APIStore) Close(ctx context.Context) error {
273255
errs = append(errs, fmt.Errorf("closing snapshot cache: %w", err))
274256
}
275257

276-
if a.redisClient != nil {
277-
if err := a.redisClient.Close(); err != nil {
278-
errs = append(errs, fmt.Errorf("closing redis client: %w", err))
279-
}
280-
}
281-
282258
return errors.Join(errs...)
283259
}
284260

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package ratelimit
2+
3+
import (
4+
"context"
5+
"math"
6+
"net/http"
7+
"strconv"
8+
"time"
9+
10+
"github.com/gin-gonic/gin"
11+
"github.com/go-redis/redis_rate/v10"
12+
"github.com/redis/go-redis/v9"
13+
"go.uber.org/zap"
14+
15+
"github.com/e2b-dev/infra/packages/auth/pkg/auth"
16+
"github.com/e2b-dev/infra/packages/shared/pkg/featureflags"
17+
"github.com/e2b-dev/infra/packages/shared/pkg/logger"
18+
redis_utils "github.com/e2b-dev/infra/packages/shared/pkg/redis"
19+
)
20+
21+
const rateLimitPefix = "ratelimit"
22+
23+
// Config defines the rate limit parameters.
24+
type Config struct {
25+
// Rate is the number of requests allowed per Period.
26+
Rate int
27+
// Burst is the maximum number of requests allowed in a single burst.
28+
Burst int
29+
// Period is the time window for the rate.
30+
Period time.Duration
31+
// FailOpen allows requests through when Redis is unavailable.
32+
FailOpen bool
33+
}
34+
35+
// DefaultConfig returns a sensible default: 50 req/s with burst of 100.
36+
func DefaultConfig() Config {
37+
return Config{
38+
Rate: 50,
39+
Burst: 100,
40+
Period: time.Second,
41+
FailOpen: true,
42+
}
43+
}
44+
45+
// NewLimiter creates a redis_rate.Limiter from a Redis client.
46+
func NewLimiter(redisClient redis.UniversalClient) *redis_rate.Limiter {
47+
return redis_rate.NewLimiter(redisClient)
48+
}
49+
50+
// Middleware returns a Gin middleware that enforces per-team rate limits
51+
// using the GCRA algorithm backed by Redis (go-redis/redis_rate).
52+
//
53+
// The middleware is gated by the RateLimitEnabledFlag feature flag for
54+
// gradual rollout. Unauthenticated requests are passed through.
55+
// resolveLimit returns the rate limit for the current request, checking the
56+
// RateLimitConfigFlag for per-team overrides. The flag JSON format is:
57+
//
58+
// {
59+
// "/sandboxes/": {"rate": 50, "burst": 100},
60+
// "/sandboxes/:sandboxID/pause": {"rate": 10, "burst": 20}
61+
// }
62+
//
63+
// The route is the Gin route pattern (c.FullPath()). If no override exists
64+
// for the route (or the flag is null), code defaults are used.
65+
func resolveLimit(ctx context.Context, ff *featureflags.Client, cfg Config, route string) redis_rate.Limit {
66+
rate := cfg.Rate
67+
burst := cfg.Burst
68+
69+
flagValue := ff.JSONFlag(ctx, featureflags.RateLimitConfigFlag)
70+
if !flagValue.IsNull() {
71+
override := flagValue.GetByKey(route)
72+
if !override.IsNull() {
73+
if v := override.GetByKey("rate"); v.IsInt() {
74+
rate = v.IntValue()
75+
}
76+
77+
if v := override.GetByKey("burst"); v.IsInt() {
78+
burst = v.IntValue()
79+
}
80+
}
81+
}
82+
83+
return redis_rate.Limit{
84+
Rate: rate,
85+
Burst: burst,
86+
Period: cfg.Period,
87+
}
88+
}
89+
90+
func Middleware(limiter *redis_rate.Limiter, cfg Config, ff *featureflags.Client) gin.HandlerFunc {
91+
return func(c *gin.Context) {
92+
ctx := c.Request.Context()
93+
94+
// Check feature flag — skip if rate limiting is disabled.
95+
if !ff.BoolFlag(ctx, featureflags.RateLimitEnabledFlag) {
96+
c.Next()
97+
98+
return
99+
}
100+
101+
// Skip unauthenticated requests (they'll be rejected by auth middleware).
102+
team, ok := auth.GetTeamInfo(c)
103+
if !ok {
104+
c.Next()
105+
106+
return
107+
}
108+
109+
teamID := team.ID.String()
110+
route := c.FullPath()
111+
key := redis_utils.CreateKey(rateLimitPefix, teamID, route)
112+
113+
// Resolve per-team limit overrides from feature flag.
114+
limit := resolveLimit(ctx, ff, cfg, route)
115+
116+
// Build a logger with rate limit context for reuse.
117+
l := logger.L().With(
118+
logger.WithTeamID(teamID),
119+
zap.String("route", route),
120+
zap.Int("rate_limit_rate", limit.Rate),
121+
zap.Int("rate_limit_burst", limit.Burst),
122+
)
123+
124+
res, err := limiter.Allow(ctx, key, limit)
125+
if err != nil {
126+
l.Warn(ctx, "rate limiter Redis error", zap.Error(err))
127+
128+
if cfg.FailOpen {
129+
c.Next()
130+
131+
return
132+
}
133+
134+
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{
135+
"code": http.StatusInternalServerError,
136+
"message": "Rate limiter unavailable",
137+
})
138+
139+
return
140+
}
141+
142+
// Set standard rate limit headers
143+
c.Header("RateLimit-Limit", strconv.Itoa(limit.Burst))
144+
c.Header("RateLimit-Remaining", strconv.Itoa(res.Remaining))
145+
c.Header("RateLimit-Reset", strconv.FormatInt(int64(math.Ceil(res.ResetAfter.Seconds())), 10))
146+
147+
if res.Allowed > 0 {
148+
c.Next()
149+
150+
return
151+
}
152+
153+
// Denied — set Retry-After and return 429.
154+
retryAfterSecs := max(int(res.RetryAfter.Seconds()), 1)
155+
c.Header("Retry-After", strconv.Itoa(retryAfterSecs))
156+
157+
l.Warn(ctx, "rate limit exceeded",
158+
zap.Int("remaining", res.Remaining),
159+
zap.Int("retry_after_s", retryAfterSecs),
160+
)
161+
162+
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
163+
"code": http.StatusTooManyRequests,
164+
"message": "Rate limit exceeded",
165+
})
166+
}
167+
}

0 commit comments

Comments
 (0)