package collector import ( "net" "net/http" "strings" "sync" "time" "golang.org/x/time/rate" ) // ipRateLimiter tracks a token-bucket rate limiter per client IP, so a single // caller can't monopolize a blocking endpoint like /api/history/wait while // leaving other clients unaffected. Entries are evicted after being idle. type ipRateLimiter struct { rps rate.Limit burst int mu sync.Mutex limiters map[string]*rate.Limiter lastSeen map[string]time.Time } func newIPRateLimiter(rps rate.Limit, burst int) *ipRateLimiter { l := &ipRateLimiter{ rps: rps, burst: burst, limiters: make(map[string]*rate.Limiter), lastSeen: make(map[string]time.Time), } go l.evictLoop() return l } // Allow reports whether a request from ip may proceed. func (l *ipRateLimiter) Allow(ip string) bool { l.mu.Lock() limiter, ok := l.limiters[ip] if !ok { limiter = rate.NewLimiter(l.rps, l.burst) l.limiters[ip] = limiter } l.lastSeen[ip] = time.Now() l.mu.Unlock() return limiter.Allow() } func (l *ipRateLimiter) evictLoop() { ticker := time.NewTicker(10 * time.Minute) defer ticker.Stop() for range ticker.C { cutoff := time.Now().Add(-10 * time.Minute) l.mu.Lock() for ip, seen := range l.lastSeen { if seen.Before(cutoff) { delete(l.limiters, ip) delete(l.lastSeen, ip) } } l.mu.Unlock() } } // clientIP extracts the caller's IP for rate limiting purposes, preferring // X-Forwarded-For (set by the ingress/proxy in front of this service) and // falling back to the raw connection address. func clientIP(r *http.Request) string { if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { if idx := strings.IndexByte(fwd, ','); idx != -1 { return fwd[:idx] } return fwd } host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { return r.RemoteAddr } return host }