Rate Limiting

Sentinel provides multi-dimensional rate limiting with sliding window counters. You can enforce limits per IP address, per authenticated user, per route, and globally — all at the same time. Every dimension is evaluated independently, and a request must pass all applicable limits to be allowed through.

Opt-In Feature

Rate limiting is disabled by default. Set Enabled: true in your RateLimitConfig to activate it. You only need to configure the dimensions you care about — any dimension left as nil is simply skipped.

Enabling Rate Limiting

The simplest way to get started is to enable rate limiting with a single per-IP limit. This protects every route in your application from individual clients sending too many requests.

main.gogo
1import (
2 "time"
3
4 sentinel "github.com/MUKE-coder/sentinel/v2"
5 "github.com/gin-gonic/gin"
6)
7
8func main() {
9 r := gin.Default()
10
11 sentinel.Mount(r, nil, sentinel.Config{
12 RateLimit: sentinel.RateLimitConfig{
13 Enabled: true,
14 ByIP: &sentinel.Limit{Requests: 100, Window: time.Minute},
15 },
16 })
17
18 r.GET("/api/hello", func(c *gin.Context) {
19 c.JSON(200, gin.H{"message": "Hello, World!"})
20 })
21
22 r.Run(":8080")
23}

Rate Limit Dimensions

Sentinel supports four independent rate limit dimensions. You can use any combination of them. Each dimension maintains its own set of counters and is evaluated in a specific order.

The Limit Struct

Every dimension is configured with the same Limit struct, which defines a maximum number of requests within a time window.

type Limit struct {
Requests int // Maximum requests allowed within the window
Window time.Duration // Time window (e.g., time.Minute, 15 * time.Minute)
}

Per-IP (ByIP)

Each unique client IP address gets its own counter. This is the most common dimension and protects against individual clients overwhelming your server.

// 100 requests per minute per IP address
ByIP: &sentinel.Limit{Requests: 100, Window: time.Minute}

Per-User (ByUser)

Each authenticated user gets their own counter, identified by a user ID string. This requires a UserIDExtractor function that extracts the user ID from the request. If the extractor returns an empty string (unauthenticated request), the per-user limit is skipped for that request.

// 500 requests per minute per authenticated user
ByUser: &sentinel.Limit{Requests: 500, Window: time.Minute},
// Tell Sentinel how to identify the user
UserIDExtractor: func(c *gin.Context) string {
return c.GetHeader("X-User-ID")
},

UserIDExtractor Required

The ByUser limit is only enforced when UserIDExtractor is set. Without it, per-user rate limiting is silently skipped even if ByUser is configured.

Per-Route (ByRoute)

Different routes can have different limits. The ByRoute map keys are exact route paths. Each route limit is tracked per IP address (the counter key is a combination of the route path and the client IP).

// Strict limits on sensitive endpoints
ByRoute: map[string]sentinel.Limit{
"/api/login": {Requests: 5, Window: 15 * time.Minute},
"/api/register": {Requests: 3, Window: time.Hour},
"/api/password-reset": {Requests: 3, Window: time.Hour},
},

Global

A single counter shared across all requests regardless of source. This is a safety net to protect your application from being overwhelmed by aggregate traffic.

// 5000 total requests per minute across all clients
Global: &sentinel.Limit{Requests: 5000, Window: time.Minute}

All Dimensions Together

config.gogo
1RateLimit: sentinel.RateLimitConfig{
2 Enabled: true,
3 Strategy: sentinel.SlidingWindow,
4
5 // Per-IP: 100 req/min
6 ByIP: &sentinel.Limit{Requests: 100, Window: time.Minute},
7
8 // Per-user: 500 req/min (requires UserIDExtractor)
9 ByUser: &sentinel.Limit{Requests: 500, Window: time.Minute},
10
11 // Per-route: different limits for sensitive endpoints
12 ByRoute: map[string]sentinel.Limit{
13 "/api/login": {Requests: 5, Window: 15 * time.Minute},
14 "/api/register": {Requests: 3, Window: time.Hour},
15 },
16
17 // Global: 5000 req/min total
18 Global: &sentinel.Limit{Requests: 5000, Window: time.Minute},
19
20 // Extract user ID for per-user limiting
21 UserIDExtractor: func(c *gin.Context) string {
22 return c.GetHeader("X-User-ID")
23 },
24}

Configuration Reference

FieldTypeDefaultDescription
EnabledboolfalseEnables the rate limiting middleware.
StrategyRateLimitStrategysentinel.SlidingWindowAlgorithm used for counting. Options: sentinel.SlidingWindow, sentinel.FixedWindow, sentinel.TokenBucket.
ByIP*LimitnilPer-IP rate limit. Each unique client IP gets its own counter.
ByUser*LimitnilPer-user rate limit. Requires a UserIDExtractor.
ByRoutemap[string]LimitnilPer-route rate limits. Keys are exact route paths.
Global*LimitnilGlobal rate limit applied across all requests regardless of source.
UserIDExtractorfunc(*gin.Context) stringnilFunction to extract a user ID from the request for per-user limiting.

Priority Order

When multiple dimensions are configured, Sentinel evaluates them in a specific order. The request is rejected as soon as any dimension's limit is exceeded — remaining dimensions are not checked.

PriorityDimensionCounter KeyDescription
1 (highest)Per-Routeroute:/path:IPChecked first. Only applies if the request path matches a key in ByRoute.
2Per-IPip:IPChecked second. Applies to every request when ByIP is set.
3Per-Useruser:userIDChecked third. Only applies when ByUser is set and UserIDExtractor returns a non-empty string.
4 (lowest)GlobalglobalChecked last. A single counter shared across all requests.

Independent Evaluation

Route limits do not replace IP or user limits — they are additive. A request to /api/login is checked against the route limit and the IP limit and the user limit and the global limit (if all are configured). The request must pass every applicable check.

Response Headers

Sentinel automatically sets standard rate limit headers on responses so clients can self-regulate. When a limit is exceeded, the client receives a 429 Too Many Requests response with a JSON body.

HeaderDescriptionExample
X-RateLimit-LimitThe maximum number of requests allowed in the current window.100
X-RateLimit-RemainingThe number of requests remaining in the current window.73
Retry-AfterSeconds until the rate limit window resets. Only sent when the limit is exceeded (429 response).60

On a successful request, the response includes X-RateLimit-Limit and X-RateLimit-Remaining based on the per-IP limit. When a limit is exceeded, the response body is:

{
"error": "Rate limit exceeded",
"code": "RATE_LIMITED"
}

Per-Route Limits

Per-route limits let you apply different thresholds to different endpoints. This is especially useful for protecting sensitive routes like login, registration, and password reset endpoints with much stricter limits than the rest of your API.

main.gogo
1sentinel.Mount(r, nil, sentinel.Config{
2 RateLimit: sentinel.RateLimitConfig{
3 Enabled: true,
4
5 // General API limit: 100 req/min per IP
6 ByIP: &sentinel.Limit{Requests: 100, Window: time.Minute},
7
8 // Strict limits on sensitive routes
9 ByRoute: map[string]sentinel.Limit{
10 // Login: 5 attempts per 15 minutes per IP
11 "/api/login": {Requests: 5, Window: 15 * time.Minute},
12
13 // Registration: 3 per hour per IP
14 "/api/register": {Requests: 3, Window: time.Hour},
15
16 // Password reset: 3 per hour per IP
17 "/api/password-reset": {Requests: 3, Window: time.Hour},
18
19 // File upload: 10 per minute per IP
20 "/api/upload": {Requests: 10, Window: time.Minute},
21 },
22 },
23})

Route limits are keyed by the combination of the route path and the client IP. For example, a request to /api/login from IP 1.2.3.4 uses the counter key route:/api/login:1.2.3.4. This means each IP gets its own counter for each route-limited path.

Exact Paths and Wildcard Patterns

A plain key matches exactly: /api/login does not match /api/login/ (query strings are ignored). Keys can also be patterns — /v1/* or /v1/** for a whole subtree, /api/apps/*/products for one segment, or /api/apps/*/products/** for both. Every path matching a pattern shares that pattern's counter, so rotating sub-paths can't reset a client's budget. When an exact key and a pattern both match, the exact key wins; otherwise the longest pattern does.

How It Works

Counters live in process memory, one per key (e.g. ip:1.2.3.4 or route:/api/login:1.2.3.4). How a counter decides is set by RateLimitConfig.Strategy:

StrategyHow it counts
sentinel.SlidingWindow (default)Keeps the current and previous window's counts. A request is allowed while previous × overlap + current is under the limit, where overlap is how much of the previous window still falls inside the window ending now. At a boundary the whole previous window still counts, so a burst straddling it is limited. Rejected requests are not counted.
sentinel.FixedWindowA window starts at a client's first request and resets completely when it ends. Cheap, but up to twice the limit can pass across a boundary. Rejected requests count.
sentinel.TokenBucketA bucket holding up to Requests tokens, refilled at Requests per Window. Each request spends one; a burst up to the limit is allowed, then a steady rate.

When a limit is exceeded the request is rejected with 429. A background goroutine runs every 30 seconds and removes counters that no longer affect any decision, preventing unbounded memory growth. An unknown strategy falls back to sliding window and is reported by ValidateConfig.

Before v2.3.0

Strategy was never read: every limit was a fixed window whatever the config said. If you relied on that behavior, set Strategy: sentinel.FixedWindow explicitly.

Thread Safety

All counter operations are protected by a read-write mutex. Reads (checking remaining counts) use a read lock for concurrency, while writes (incrementing, cleanup) use an exclusive write lock.

Dashboard Management

The Sentinel dashboard includes a dedicated Rate Limits page that gives you real-time visibility into your rate limit counters and configuration.

Live Counter States

The dashboard displays all active rate limit counters in real time. Each entry shows the counter key, the current request count, the window expiration time, and how many requests remain. Counters are automatically removed from the view when their window expires.

Edit Per-Route Limits

You can edit per-route limits directly from the dashboard without restarting your application. This is useful for responding to traffic spikes or adjusting thresholds after observing real-world patterns.

Reset Individual Counters

If a legitimate client gets rate-limited (e.g., during testing or after a deployment), you can reset their counter from the dashboard. This removes the specific counter key, allowing the client to send requests again immediately.

No Restart Required

Changes made through the dashboard (editing route limits, resetting counters) take effect on live requests immediately. Edits are validated — a non-positive window, a route without a leading /, or an unmatchable pattern is rejected with 400 and nothing changes — and every change is written to the audit log. Since v2.6.0 a route-limit edit is also stored, so it survives a restart and reaches every replica within Storage.SyncInterval; the response carries a warning when the storage backend could not keep it. (Before v2.3.0, route-limit edits updated only the dashboard's copy of the config and were never enforced.)

Testing

You can verify rate limiting is working by sending rapid requests with curl and inspecting the response headers.

Check Rate Limit Headers

# Send a request and inspect rate limit headers
curl -v http://localhost:8080/api/hello 2>&1 | grep -i "x-ratelimit\|retry-after"
# Expected output (first request):
# < X-RateLimit-Limit: 100
# < X-RateLimit-Remaining: 99

Hit the Rate Limit

# Send requests in a tight loop to trigger the limit
# (adjust the count based on your configured limit)
for i in $(seq 1 110); do
STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/api/hello)
echo "Request $i: HTTP $STATUS"
done
# You should see HTTP 200 for the first 100 requests,
# then HTTP 429 once the limit is exceeded.

Test Per-Route Limits

# Test the login endpoint (5 requests per 15 minutes)
for i in $(seq 1 7); do
RESPONSE=$(curl -s -w "\nHTTP %{http_code}" \
-X POST http://localhost:8080/api/login \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"test"}')
echo "Request $i: $RESPONSE"
done
# Requests 1-5: normal response
# Requests 6-7: HTTP 429 with Retry-After header

Inspect a 429 Response

# After exceeding the limit, inspect the full 429 response
curl -v http://localhost:8080/api/hello 2>&1
# Response headers will include:
# < HTTP/1.1 429 Too Many Requests
# < X-RateLimit-Limit: 100
# < X-RateLimit-Remaining: 0
# < Retry-After: 60
#
# Response body:
# {"code":"RATE_LIMITED","error":"Rate limit exceeded"}

Across Replicas

By default, counters live in process memory. If you run several instances behind a load balancer, each one counts on its own, so a client gets N × limit requests across N instances. To share the counters, set Config.Counters to a shared store. Every replica then counts against the same numbers:

1import (
2 "github.com/MUKE-coder/sentinel/v2/redisstore"
3 "github.com/redis/go-redis/v9"
4)
5
6client := redis.NewClient(&redis.Options{
7 Addr: "redis:6379",
8 ReadTimeout: 250 * time.Millisecond, // bound how long a request waits on Redis
9})
10
11sentinel.Mount(r, nil, sentinel.Config{
12 Counters: redisstore.New(client),
13 RateLimit: sentinel.RateLimitConfig{
14 Enabled: true,
15 ByIP: &sentinel.Limit{Requests: 100, Window: time.Minute},
16 },
17})
  • Each decision runs as one Lua script, so two replicas can't both take the last slot. All three strategies behave the same as they do in memory.
  • If Redis is unreachable, requests are allowed rather than failed. The error is logged at most once a minute.
  • If several applications share one Redis, give each one its own redisstore.WithPrefix.
  • Per-IP limits key on the client address. Behind a load balancer, set WAF.TrustedProxies, or every request counts against the balancer's address.

examples/multi-replica in the repository runs two replicas behind Caddy, with a shared Redis and Postgres.

Limitations

The rate limiter has a few limitations to keep in mind when planning your deployment.

LimitationDetailsWorkaround
In-memory by defaultWithout Config.Counters, counters live in process memory. They reset when the application restarts, and each instance behind a load balancer counts on its own.Set Config.Counters: redisstore.New(client). See Across Replicas.
Dashboard edits win over your configSince v2.6.0 a route limit changed from the dashboard is stored and keeps applying after a restart — including after a deploy that changes ByRoute in code.DELETE /sentinel/api/settings/live discards the stored settings and puts your configured values back on every replica.

Multi-Instance Deployments

If you deploy multiple instances behind a load balancer, be aware that each instance tracks rate limits independently. The effective limit per client is multiplied by the number of instances. Plan your per-instance limits accordingly.

Next Steps


Built with by JB