Skip to content

Rate limiter for the grid-proxy #1385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/grid-proxy-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ jobs:
pushd tools/db
go run . --seed 13 --postgres-host localhost --postgres-db tfgrid-graphql --postgres-password postgres --postgres-user postgres --reset
popd
go run cmds/proxy_server/main.go -no-cert -no-indexer --address :8080 --log-level debug --postgres-host localhost --postgres-db tfgrid-graphql --postgres-password postgres --postgres-user postgres --mnemonics "$MNEMONICS" &
go run cmds/proxy_server/main.go --rate-limit-rps 1000 -no-cert -no-indexer --address :8080 --log-level debug --postgres-host localhost --postgres-db tfgrid-graphql --postgres-password postgres --postgres-user postgres --mnemonics "$MNEMONICS" &
sleep 10
pushd tests/queries
go test -v --seed 13 -no-modify --postgres-host localhost --postgres-db tfgrid-graphql --postgres-password postgres --postgres-user postgres --endpoint http://localhost:8080
Expand Down
1 change: 1 addition & 0 deletions grid-proxy/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ server-start: ## Start the proxy server (Args: `m=<MNEMONICS>`)
--postgres-db tfgrid-graphql \
--postgres-password postgres \
--postgres-user postgres \
--rate-limit-rps 1000 \
--mnemonics "$(m)" ;

all-start: db-stop db-start sleep db-fill server-start ## Full start of the database and the server (Args: `m=<MNEMONICS>`)
Expand Down
11 changes: 10 additions & 1 deletion grid-proxy/cmds/proxy_server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type flags struct {
relayURL string
mnemonics string
maxPoolOpenConnections int
rateLimitRPS int // Rate limit requests per second per IP

noIndexer bool // true to stop the indexer, useful on running for testing
indexerUpserterBatchSize uint
Expand Down Expand Up @@ -98,6 +99,7 @@ func main() {
flag.StringVar(&f.relayURL, "relay-url", DefaultRelayURL, "RMB relay url")
flag.StringVar(&f.mnemonics, "mnemonics", "", "Dummy user mnemonics for relay calls")
flag.IntVar(&f.maxPoolOpenConnections, "max-open-conns", 80, "max number of db connection pool open connections")
flag.IntVar(&f.rateLimitRPS, "rate-limit-rps", 20, "rate limit requests per second per IP address (0 to disable)")

flag.BoolVar(&f.noIndexer, "no-indexer", false, "do not start the indexer")
flag.UintVar(&f.indexerUpserterBatchSize, "indexer-upserter-batch-size", 20, "results batch size which collected before upserting")
Expand Down Expand Up @@ -182,6 +184,13 @@ func main() {
log.Fatal().Err(err).Msg("failed to create mux server")
}

// Log rate limiting configuration
if f.rateLimitRPS > 0 {
log.Info().Int("rate_limit_rps", f.rateLimitRPS).Msg("HTTP rate limiting enabled")
} else {
log.Info().Msg("HTTP rate limiting disabled")
}

if err := app(s, f); err != nil {
log.Fatal().Msg(err.Error())
}
Expand Down Expand Up @@ -331,7 +340,7 @@ func createServer(f flags, dbClient explorer.DBClient, gitCommit string, relayCl
router := mux.NewRouter().StrictSlash(true)

// setup explorer
if err := explorer.Setup(router, gitCommit, dbClient, relayClient, idxIntervals); err != nil {
if err := explorer.Setup(router, gitCommit, dbClient, relayClient, idxIntervals, f.rateLimitRPS); err != nil {
return nil, err
}

Expand Down
92 changes: 92 additions & 0 deletions grid-proxy/internal/explorer/mw/ratelimiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package mw

import (
"fmt"
"net/http"
"strconv"
"time"

"github.com/rs/zerolog/log"
"github.com/threefoldtech/tfgrid-sdk-go/grid-proxy/tools/ratelimiter"
)

// RateLimiterMiddleware wraps the rate limiter to work with the existing middleware pattern
type RateLimiterMiddleware struct {
limiter *ratelimiter.SlidingWindowRateLimiter
}

// NewRateLimiterMiddleware creates a new rate limiter middleware
func NewRateLimiterMiddleware(ratePerSecond int) *RateLimiterMiddleware {
return &RateLimiterMiddleware{
limiter: ratelimiter.NewSlidingWindowRateLimiter(ratePerSecond),
}
}

// RateLimitAction wraps an Action with rate limiting
func (rlm *RateLimiterMiddleware) RateLimitAction(action Action) Action {
return func(r *http.Request) (interface{}, Response) {
clientIP := ratelimiter.GetClientIP(r)

if !rlm.limiter.Allow(clientIP) {
log.Warn().
Str("ip", clientIP).
Str("method", r.Method).
Str("path", r.URL.Path).
Msg("Rate limit exceeded")

return nil, rlm.TooManyRequests(fmt.Errorf("rate limit exceeded for IP: %s", clientIP), clientIP)
}

return action(r)
}
}

// RateLimitProxyAction wraps a ProxyAction with rate limiting
func (rlm *RateLimiterMiddleware) RateLimitProxyAction(action ProxyAction) ProxyAction {
return func(r *http.Request) (*http.Response, Response) {
clientIP := ratelimiter.GetClientIP(r)
if !rlm.limiter.Allow(clientIP) {
log.Warn().
Str("ip", clientIP).
Str("method", r.Method).
Str("path", r.URL.Path).
Msg("Rate limit exceeded")

return nil, rlm.TooManyRequests(fmt.Errorf("rate limit exceeded for IP: %s", clientIP), clientIP)
}

return action(r)
}
}

// AsRateLimitedHandlerFunc wraps AsHandlerFunc with rate limiting
func (rlm *RateLimiterMiddleware) AsRateLimitedHandlerFunc(action Action) http.HandlerFunc {
rateLimitedAction := rlm.RateLimitAction(action)
return AsHandlerFunc(rateLimitedAction)
}

// AsRateLimitedProxyHandlerFunc wraps AsProxyHandlerFunc with rate limiting
func (rlm *RateLimiterMiddleware) AsRateLimitedProxyHandlerFunc(action ProxyAction) http.HandlerFunc {
rateLimitedAction := rlm.RateLimitProxyAction(action)
return AsProxyHandlerFunc(rateLimitedAction)
}

// GetStats returns rate limiter statistics
func (rlm *RateLimiterMiddleware) GetStats() map[string]interface{} {
return rlm.limiter.GetStats()
}

// TooManyRequests returns a 429 Too Many Requests response with accurate rate limit headers
func (rlm *RateLimiterMiddleware) TooManyRequests(err error, clientIP string) Response {
rateLimit := rlm.limiter.GetRateLimit()
currentRequests := rlm.limiter.GetCurrentRequestCount(clientIP)
remaining := max(0, rateLimit-currentRequests)
resetTime := time.Now().Add(time.Second)

return Error(err, http.StatusTooManyRequests).
WithHeader("Retry-After", "1").
WithHeader("X-RateLimit-Limit", strconv.Itoa(rateLimit)).
WithHeader("X-RateLimit-Remaining", strconv.Itoa(remaining)).
WithHeader("X-RateLimit-Reset", strconv.FormatInt(resetTime.Unix(), 10)).
WithHeader("X-Client-IP", clientIP)
}
23 changes: 23 additions & 0 deletions grid-proxy/internal/explorer/mw/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package mw

import "net/http"

// WithRateLimit wraps an Action with rate limiting if a rate limiter is provided.
// If rateLimiter is nil, it falls back to the standard AsHandlerFunc wrapper.
// This provides a clean way to conditionally apply rate limiting to endpoints.
func WithRateLimit(rateLimiter *RateLimiterMiddleware, action Action) http.HandlerFunc {
if rateLimiter != nil {
return rateLimiter.AsRateLimitedHandlerFunc(action)
}
return AsHandlerFunc(action)
}

// WithRateLimitProxy wraps a ProxyAction with rate limiting if a rate limiter is provided.
// If rateLimiter is nil, it falls back to the standard AsProxyHandlerFunc wrapper.
// This provides a clean way to conditionally apply rate limiting to proxy endpoints.
func WithRateLimitProxy(rateLimiter *RateLimiterMiddleware, action ProxyAction) http.HandlerFunc {
if rateLimiter != nil {
return rateLimiter.AsRateLimitedProxyHandlerFunc(action)
}
return AsProxyHandlerFunc(action)
}
50 changes: 29 additions & 21 deletions grid-proxy/internal/explorer/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ func (a *App) getContractBills(r *http.Request) (interface{}, mw.Response) {
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @BasePath /
func Setup(router *mux.Router, gitCommit string, cl DBClient, relayClient rmb.Client, idxIntervals map[string]uint) error {
func Setup(router *mux.Router, gitCommit string, cl DBClient, relayClient rmb.Client, idxIntervals map[string]uint, rateLimitRPS int) error {

a := App{
cl: cl,
Expand All @@ -638,32 +638,40 @@ func Setup(router *mux.Router, gitCommit string, cl DBClient, relayClient rmb.Cl
idxIntervals: idxIntervals,
}

router.HandleFunc("/farms", mw.AsHandlerFunc(a.listFarms))
router.HandleFunc("/stats", mw.AsHandlerFunc(a.getStats))
var rateLimiter *mw.RateLimiterMiddleware
if rateLimitRPS > 0 {
rateLimiter = mw.NewRateLimiterMiddleware(rateLimitRPS)
log.Info().Int("rate_limit_rps", rateLimitRPS).Msg("Rate limiting enabled")
} else {
log.Info().Msg("Rate limiting disabled")
}

router.HandleFunc("/farms", mw.WithRateLimit(rateLimiter, a.listFarms))
router.HandleFunc("/stats", mw.WithRateLimit(rateLimiter, a.getStats))

router.HandleFunc("/twins", mw.AsHandlerFunc(a.listTwins))
router.HandleFunc("/twins/{twin_id:[0-9]+}/consumption", mw.AsHandlerFunc(a.getTwinConsumption))
router.HandleFunc("/twins", mw.WithRateLimit(rateLimiter, a.listTwins))
router.HandleFunc("/twins/{twin_id:[0-9]+}/consumption", mw.WithRateLimit(rateLimiter, a.getTwinConsumption))

router.HandleFunc("/nodes", mw.AsHandlerFunc(a.getNodes))
router.HandleFunc("/nodes/{node_id:[0-9]+}", mw.AsHandlerFunc(a.getNode))
router.HandleFunc("/nodes/{node_id:[0-9]+}/status", mw.AsHandlerFunc(a.getNodeStatus))
router.HandleFunc("/nodes/{node_id:[0-9]+}/statistics", mw.AsHandlerFunc(a.getNodeStatistics))
router.HandleFunc("/nodes/{node_id:[0-9]+}/gpu", mw.AsHandlerFunc(a.getNodeGpus))
router.HandleFunc("/nodes", mw.WithRateLimit(rateLimiter, a.getNodes))
router.HandleFunc("/nodes/{node_id:[0-9]+}", mw.WithRateLimit(rateLimiter, a.getNode))
router.HandleFunc("/nodes/{node_id:[0-9]+}/status", mw.WithRateLimit(rateLimiter, a.getNodeStatus))
router.HandleFunc("/nodes/{node_id:[0-9]+}/statistics", mw.WithRateLimit(rateLimiter, a.getNodeStatistics))
router.HandleFunc("/nodes/{node_id:[0-9]+}/gpu", mw.WithRateLimit(rateLimiter, a.getNodeGpus))

router.HandleFunc("/gateways", mw.AsHandlerFunc(a.getGateways))
router.HandleFunc("/gateways/{node_id:[0-9]+}", mw.AsHandlerFunc(a.getGateway))
router.HandleFunc("/gateways/{node_id:[0-9]+}/status", mw.AsHandlerFunc(a.getNodeStatus))
router.HandleFunc("/gateways", mw.WithRateLimit(rateLimiter, a.getGateways))
router.HandleFunc("/gateways/{node_id:[0-9]+}", mw.WithRateLimit(rateLimiter, a.getGateway))
router.HandleFunc("/gateways/{node_id:[0-9]+}/status", mw.WithRateLimit(rateLimiter, a.getNodeStatus))

router.HandleFunc("/contracts", mw.AsHandlerFunc(a.listContracts))
router.HandleFunc("/contracts/{contract_id:[0-9]+}", mw.AsHandlerFunc(a.getContract))
router.HandleFunc("/contracts/{contract_id:[0-9]+}/bills", mw.AsHandlerFunc(a.getContractBills))
router.HandleFunc("/contracts", mw.WithRateLimit(rateLimiter, a.listContracts))
router.HandleFunc("/contracts/{contract_id:[0-9]+}", mw.WithRateLimit(rateLimiter, a.getContract))
router.HandleFunc("/contracts/{contract_id:[0-9]+}/bills", mw.WithRateLimit(rateLimiter, a.getContractBills))

router.HandleFunc("/public_ips", mw.AsHandlerFunc(a.GetPublicIps))
router.HandleFunc("/public_ips", mw.WithRateLimit(rateLimiter, a.GetPublicIps))

router.HandleFunc("/", mw.AsHandlerFunc(a.indexPage(router)))
router.HandleFunc("/ping", mw.AsHandlerFunc(a.ping))
router.HandleFunc("/version", mw.AsHandlerFunc(a.version))
router.HandleFunc("/health", mw.AsHandlerFunc(a.health))
router.HandleFunc("/", mw.WithRateLimit(rateLimiter, a.indexPage(router)))
router.HandleFunc("/ping", mw.WithRateLimit(rateLimiter, a.ping))
router.HandleFunc("/version", mw.WithRateLimit(rateLimiter, a.version))
router.HandleFunc("/health", mw.WithRateLimit(rateLimiter, a.health))
router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler)

return nil
Expand Down
83 changes: 83 additions & 0 deletions grid-proxy/tools/ratelimiter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# Rate Limiter

This package implements a sliding window rate limiter for the TFGrid Proxy server.

## Features

- **IP-based Rate Limiting**: Tracks requests per IP address
- **Sliding Window Algorithm**: Uses a sliding window approach for smooth rate limiting
- **Thread-Safe**: Safe for concurrent use across multiple goroutines
- **Memory Efficient**: Automatic cleanup of old entries
- **Configurable**: Rate limit can be set via command-line flag

## Usage

### Command Line Flag

Use the `--rate-limit-rps` flag to set the rate limit:

```bash
# Enable rate limiting at 20 requests per second per IP (default)
./proxy_server --rate-limit-rps 20

# Set custom rate limit of 100 requests per second per IP
./proxy_server --rate-limit-rps 100

# Disable rate limiting
./proxy_server --rate-limit-rps 0
```

### IP Address Detection

The rate limiter automatically extracts the client IP address using the following priority:

1. `X-Real-IP` header
2. `X-Forwarded-For` header (first IP if multiple)
3. `RemoteAddr` from the connection

This ensures proper rate limiting even when the proxy is behind load balancers or CDNs.

### HTTP Response

When rate limit is exceeded, the server returns:

- **Status Code**: 429 (Too Many Requests)
- **Headers**:
- `Retry-After: 1` - Suggests retrying after 1 second
- `X-RateLimit-Limit: 20` - Current rate limit
- `X-RateLimit-Remaining: 0` - Remaining requests (0 when exceeded)

### Algorithm Details

The sliding window algorithm works as follows:

1. **Time Window**: Uses a 1-second sliding window
2. **Request Tracking**: Stores timestamps of requests within the window
3. **Cleanup**: Automatically removes requests older than the window
4. **Memory Management**: Periodically cleans up inactive IP entries

### Performance

- **Memory Usage**: Minimal overhead, only stores active IP addresses
- **CPU Usage**: O(n) where n is the number of requests in the current window
- **Concurrency**: Thread-safe with read-write mutexes for optimal performance

## Configuration

| Flag | Default | Description |
|------|---------|-------------|
| `--rate-limit-rps` | 20 | Requests per second per IP address (0 to disable) |

## Logging

The rate limiter provides debug logging for:

- Rate limit violations (WARN level)
- New IP tracking (DEBUG level)
- Request allowances (DEBUG level)
- Cleanup operations (DEBUG level)

Example log output:
```
{"level":"warn","ip":"192.168.1.100","method":"GET","path":"/nodes","time":"2025-07-07T14:40:43Z","message":"Rate limit exceeded"}
```
26 changes: 26 additions & 0 deletions grid-proxy/tools/ratelimiter/ip_utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package ratelimiter

import (
"net"
"net/http"
"strings"
)

// GetClientIP extracts the real client IP from the HTTP request
// It checks X-Forwarded-For, X-Real-IP headers, and falls back to RemoteAddr
func GetClientIP(r *http.Request) string {
if realIP := r.Header.Get("X-Real-IP"); realIP != "" {
return realIP
}

if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
parts := strings.Split(fwd, ",")
return strings.TrimSpace(parts[0])
}

host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
Loading
Loading