Skip to content
164 changes: 164 additions & 0 deletions examples/using-rate-limiter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
# HTTP Rate Limiter Example

This example demonstrates how to use the HTTP rate limiter middleware in GoFr to protect your API from abuse and ensure fair resource distribution.

## Features

- **Token Bucket Algorithm**: Smooth rate limiting with configurable burst
- **Per-IP Rate Limiting**: Each client IP gets its own rate limit
- **Automatic Health Check Exemption**: `/.well-known/alive` and `/.well-known/health` are not rate limited
- **Prometheus Metrics**: Track rate limit violations
- **429 Status Code**: Standard HTTP response when limit exceeded

## Configuration

```go
rateLimiterConfig := middleware.RateLimiterConfig{
RequestsPerSecond: 5, // Average requests per second
Burst: 10, // Maximum burst size
PerIP: true, // Enable per-IP limiting (false for global)
}

app.UseMiddleware(middleware.RateLimiter(rateLimiterConfig, app.Metrics()))
```

### Parameters

- **RequestsPerSecond**: Average number of requests allowed per second
- **Burst**: Maximum number of requests that can be made in a burst (allows temporary spikes)
- **PerIP**:
- `true`: Each IP address gets its own rate limit (recommended)
- `false`: Global rate limit shared across all clients

## Running the Example

```bash
go run main.go
```

The server will start on `http://localhost:8000`

## Testing Rate Limiting

### Test 1: Normal Requests (Within Limit)
```bash
# Send a few requests - should succeed
curl http://localhost:8000/limited
curl http://localhost:8000/limited
curl http://localhost:8000/limited
```

**Expected**: All requests return `200 OK`

### Test 2: Exceed Rate Limit
```bash
# Send many rapid requests
for i in {1..15}; do
curl -w "\nStatus: %{http_code}\n" http://localhost:8000/limited
echo "---"
done
```

**Expected**:
- First 10 requests succeed (burst capacity)
- Subsequent requests return `429 Too Many Requests`

### Test 3: Health Endpoints (Always Accessible)
```bash
# Health endpoints are never rate limited
for i in {1..20}; do
curl http://localhost:8000/.well-known/alive
done
```

**Expected**: All requests succeed with `200 OK`

### Test 4: Token Refill
```bash
# Exhaust rate limit
for i in {1..12}; do curl http://localhost:8000/limited; done

# Wait 1 second for tokens to refill
sleep 1

# Try again - should succeed
curl http://localhost:8000/limited
```

**Expected**: Request after waiting succeeds

### Test 5: Per-IP Isolation
If you have access to multiple IPs or can use a proxy:

```bash
# Terminal 1 (IP1)
curl http://localhost:8000/limited

# Terminal 2 (IP2 via proxy)
curl -x http://proxy:8080 http://localhost:8000/limited
```

**Expected**: Each IP has independent rate limits

## Monitoring

View Prometheus metrics at `http://localhost:2121/metrics`:

```bash
curl http://localhost:2121/metrics | grep rate_limit
```

**Metrics:**
- `app_http_rate_limit_exceeded_total`: Counter of rejected requests

## Use Cases

### Production API Protection
```go
rateLimiterConfig := middleware.RateLimiterConfig{
RequestsPerSecond: 100,
Burst: 200,
PerIP: true,
}
```

### Development/Staging
```go
rateLimiterConfig := middleware.RateLimiterConfig{
RequestsPerSecond: 10,
Burst: 20,
PerIP: true,
}
```

### Global Rate Limit (All Clients)
```go
rateLimiterConfig := middleware.RateLimiterConfig{
RequestsPerSecond: 1000,
Burst: 2000,
PerIP: false, // Shared limit
}
```

## Notes

- Rate limiter uses `golang.org/x/time/rate` for efficient token bucket implementation
- Stale per-IP limiters are cleaned up automatically every 5 minutes
- IP extraction order: `X-Forwarded-For` → `X-Real-IP` → `RemoteAddr`
- Works seamlessly with other middleware (auth, logging, metrics)

### Single-Pod vs Multi-Pod Deployments

**Important**: The current implementation uses in-memory storage and is designed for **single-pod deployments**.

In **multi-pod/distributed systems**:
- Each pod enforces rate limits independently
- A client can send up to `RequestsPerSecond × NumberOfPods` requests
- Example: With 3 pods and 10 req/sec limit, a client could make 30 req/sec total

**For distributed rate limiting**, consider:
1. **Redis-backed limiter** (planned for future release)
2. **API Gateway** (Kong, Envoy, NGINX) rate limiting at the ingress layer
3. **Sticky sessions** (less reliable, but keeps clients on same pod)

For distributed rate limiting support, see the related issue that will be created after this PR is merged.
41 changes: 41 additions & 0 deletions examples/using-rate-limiter/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package main

import (
"gofr.dev/pkg/gofr"
"gofr.dev/pkg/gofr/http/middleware"
)

func main() {
app := gofr.New()

// Configure rate limiter: 5 requests per second with burst of 10
// This limits each IP to 5 req/sec on average, allowing bursts up to 10
rateLimiterConfig := middleware.RateLimiterConfig{
RequestsPerSecond: 5,
Burst: 10,
PerIP: true, // Enable per-IP rate limiting
}

// Add rate limiter middleware
app.UseMiddleware(middleware.RateLimiter(rateLimiterConfig, app.Metrics()))

// Define routes
app.GET("/limited", limitedHandler)
app.GET("/test", testHandler)

app.Run()
}

func limitedHandler(c *gofr.Context) (any, error) {
return map[string]string{
"message": "This endpoint is rate limited to 5 req/sec per IP",
"tip": "Try sending multiple rapid requests to see 429 errors",
}, nil
}

func testHandler(c *gofr.Context) (any, error) {
return map[string]string{
"message": "Test endpoint also rate limited",
"status": "success",
}, nil
}
17 changes: 17 additions & 0 deletions pkg/gofr/http/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,21 @@ func (ErrorPanicRecovery) LogLevel() logging.Level {
return logging.ERROR
}

// ErrorTooManyRequests represents an error when rate limit is exceeded.
type ErrorTooManyRequests struct{}

func (ErrorTooManyRequests) Error() string {
return "rate limit exceeded"
}

func (ErrorTooManyRequests) StatusCode() int {
return http.StatusTooManyRequests
}

func (ErrorTooManyRequests) LogLevel() logging.Level {
return logging.WARN
}

// validate the errors satisfy the underlying interfaces they depend on.
var (
_ StatusCodeResponder = ErrorEntityNotFound{}
Expand All @@ -174,6 +189,7 @@ var (
_ StatusCodeResponder = ErrorPanicRecovery{}
_ StatusCodeResponder = ErrorServiceUnavailable{}
_ StatusCodeResponder = ErrorClientClosedRequest{}
_ StatusCodeResponder = ErrorTooManyRequests{}

_ logging.LogLevelResponder = ErrorClientClosedRequest{}
_ logging.LogLevelResponder = ErrorEntityNotFound{}
Expand All @@ -184,4 +200,5 @@ var (
_ logging.LogLevelResponder = ErrorRequestTimeout{}
_ logging.LogLevelResponder = ErrorPanicRecovery{}
_ logging.LogLevelResponder = ErrorServiceUnavailable{}
_ logging.LogLevelResponder = ErrorTooManyRequests{}
)
105 changes: 105 additions & 0 deletions pkg/gofr/http/middleware/rate_limiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package middleware

import (
"context"
"net"
"net/http"
"strings"

gofrHttp "gofr.dev/pkg/gofr/http"
)

// RateLimiterConfig holds configuration for rate limiting.
//
// Note: The default implementation uses in-memory token buckets and is suitable
// for single-pod deployments. In multi-pod deployments, each pod will enforce
// limits independently. For distributed rate limiting across multiple pods,
// a Redis-backed store can be implemented in a future update.
type RateLimiterConfig struct {
RequestsPerSecond float64
Burst int
PerIP bool
Store RateLimiterStore // Optional: defaults to in-memory store
}

type rateLimiterMetrics interface {
IncrementCounter(ctx context.Context, name string, labels ...string)
}

// getIP extracts the client IP address from the request.
func getIP(r *http.Request) string {
// Check X-Forwarded-For header first
forwarded := r.Header.Get("X-Forwarded-For")
if forwarded != "" {
// X-Forwarded-For can contain multiple IPs, take the first one
ips := strings.Split(forwarded, ",")
if len(ips) > 0 {
return strings.TrimSpace(ips[0])
}
}

// Check X-Real-IP header
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}

// Fall back to RemoteAddr
ip, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}

return ip
}

// RateLimiter creates a middleware that limits requests based on the configuration.
func RateLimiter(config RateLimiterConfig, metrics rateLimiterMetrics) func(http.Handler) http.Handler {
// Use in-memory store if none provided
if config.Store == nil {
config.Store = NewMemoryRateLimiterStore()
}

// Start cleanup routine
ctx := context.Background()
config.Store.StartCleanup(ctx)

return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip rate limiting for health check endpoints
if isWellKnown(r.URL.Path) {
next.ServeHTTP(w, r)
return
}

// Determine the rate limit key (IP or global)
key := "global"
if config.PerIP {
key = getIP(r)
}

// Check rate limit
allowed, retryAfter, err := config.Store.Allow(r.Context(), key, config)
if err != nil {
// Fail open on errors
next.ServeHTTP(w, r)
return
}

if !allowed {
// Increment rate limit exceeded metric
if metrics != nil {
metrics.IncrementCounter(r.Context(), "app_http_rate_limit_exceeded_total",
"path", r.URL.Path, "method", r.Method, "ip", getIP(r), "retry_after", retryAfter.String())
}

// Return 429 Too Many Requests
responder := gofrHttp.NewResponder(w, r.Method)
responder.Respond(nil, gofrHttp.ErrorTooManyRequests{})

return
}

next.ServeHTTP(w, r)
})
}
}
Loading
Loading