Files
graph/cmd/graphd/ratelimit.go
T
zeekay 8508374fbf refactor: rename cmd/graph → cmd/graphd, hanzo runners
Daemon naming convention: standalone binary is graphd. The library
package (engine, indexer, storage, resolvers) keeps the import path
github.com/luxfi/graph for use by the unified luxfi/explorer binary
and external consumers.

- cmd/graphd/                        renamed (was cmd/graph/)
- Dockerfile                         builds /usr/local/bin/graphd
- Makefile                           bin/graphd target
- .github/workflows/docker.yml       multi-arch + hanzo ARC runners
- .github/workflows/ci.yml           hanzo runner
- .github/actionlint.yaml            register custom runner labels
2026-05-05 20:45:54 -07:00

113 lines
2.4 KiB
Go

package main
import (
"math"
"net"
"net/http"
"sync"
"time"
)
// rateLimiter implements a per-IP token bucket rate limiter.
// Stdlib only, no external dependencies.
type rateLimiter struct {
mu sync.Mutex
buckets map[string]*bucket
rate float64 // tokens per second
burst int // max tokens
}
type bucket struct {
tokens float64
lastSeen time.Time
}
func newRateLimiter(rate float64, burst int) *rateLimiter {
rl := &rateLimiter{
buckets: make(map[string]*bucket),
rate: rate,
burst: burst,
}
go rl.cleanup()
return rl
}
// allow checks whether the given IP has tokens remaining.
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
b, ok := rl.buckets[ip]
now := time.Now()
if !ok {
rl.buckets[ip] = &bucket{tokens: float64(rl.burst) - 1, lastSeen: now}
return true
}
elapsed := now.Sub(b.lastSeen).Seconds()
b.tokens = math.Min(float64(rl.burst), b.tokens+elapsed*rl.rate)
b.lastSeen = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// cleanup evicts stale entries every 60 seconds.
func (rl *rateLimiter) cleanup() {
for {
time.Sleep(60 * time.Second)
rl.mu.Lock()
cutoff := time.Now().Add(-3 * time.Minute)
for ip, b := range rl.buckets {
if b.lastSeen.Before(cutoff) {
delete(rl.buckets, ip)
}
}
rl.mu.Unlock()
}
}
// rateLimitMiddleware wraps a handler with per-IP rate limiting.
// Exempts /health and /ready paths.
func rateLimitMiddleware(rl *rateLimiter, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Exempt health/readiness probes
if r.URL.Path == "/health" || r.URL.Path == "/ready" {
next.ServeHTTP(w, r)
return
}
ip := clientIP(r)
if !rl.allow(ip) {
http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
return
}
next.ServeHTTP(w, r)
})
}
// clientIP extracts the client IP from the request.
// Prefers X-Forwarded-For (first entry) since we sit behind hanzoai/ingress.
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
// First IP in the chain is the original client
for i := 0; i < len(xff); i++ {
if xff[i] == ',' {
return xff[:i]
}
}
return xff
}
if xff := r.Header.Get("X-Real-Ip"); xff != "" {
return xff
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}