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"
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 Path Matching

Route keys must be exact paths. The path /api/login will not match /api/login/ (trailing slash) or /api/login?foo=bar (query parameters are stripped). Use the path as it appears in your Gin route definitions.

How It Works

Sentinel uses sliding window counters stored entirely in memory. Each counter tracks a request count and a window expiration timestamp.

  1. When a request arrives, Sentinel looks up the counter for the relevant key (e.g., ip:1.2.3.4).
  2. If no counter exists, or the current time is past the window expiration, a new counter is created with a count of 1 and a window end of now + Window.
  3. If the counter exists and the window has not expired, the count is incremented.
  4. If the count exceeds the configured limit, the request is rejected with a 429 status.

A background goroutine runs every 30 seconds to clean up expired counters, preventing unbounded memory growth. The cleanup removes any counter whose window has passed.

// Internal counter structure (simplified)
type rateLimitEntry struct {
count int // Number of requests in the current window
windowEnd time.Time // When the current window expires
}
// Cleanup runs every 30 seconds, removing expired entries
func (rl *RateLimiter) cleanup() {
ticker := time.NewTicker(30 * time.Second)
for range ticker.C {
now := time.Now()
for key, entry := range rl.counters {
if now.After(entry.windowEnd) {
delete(rl.counters, key)
}
}
}
}

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 immediately. There is no need to restart or redeploy your application.

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"}

Limitations

The current rate limiter implementation has a few limitations to be aware of when planning your deployment.

LimitationDetailsWorkaround
In-memory onlyAll rate limit counters are stored in process memory. Counters are lost when the application restarts.Accept that counters reset on restart. For most use cases, this is acceptable because windows are short (minutes/hours).
No cross-instance sharingIf you run multiple instances of your application behind a load balancer, each instance maintains its own independent counters. A client could effectively get N x limit requests across N instances.Use sticky sessions at the load balancer level, or divide your limits by the number of instances (e.g., set 50 req/min per instance if you have 2 instances and want a 100 req/min effective limit).
Exact path matchingPer-route limits use exact string matching on the request path. Patterns, wildcards, and path parameters are not supported.List each specific path you want to limit in the ByRoute map.

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