summaryrefslogtreecommitdiff
path: root/internal/api/ratelimit.go
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-08 20:40:31 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-08 20:40:31 +0000
commit417034be7f745062901a940d1a021f6d85be496e (patch)
tree666956207b58c915090f6641891304156cf93670 /internal/api/ratelimit.go
parent181a37698410b68e00a885593b6f2b7acf21f4b4 (diff)
api: SetAPIToken, SetNotifier, questionStore, per-IP rate limiter
- Extract questionStore interface for testability of handleAnswerQuestion - Add SetAPIToken/SetNotifier methods for post-construction wiring - Extract processResult() from forwardResults() for direct testability - Add ipRateLimiter with token-bucket per IP; applied to /elaborate and /validate - Fix tests for running-task deletion and retry-limit that relied on invalid state transitions in setup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'internal/api/ratelimit.go')
-rw-r--r--internal/api/ratelimit.go99
1 files changed, 99 insertions, 0 deletions
diff --git a/internal/api/ratelimit.go b/internal/api/ratelimit.go
new file mode 100644
index 0000000..089354c
--- /dev/null
+++ b/internal/api/ratelimit.go
@@ -0,0 +1,99 @@
+package api
+
+import (
+ "net"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// ipRateLimiter provides per-IP token-bucket rate limiting.
+type ipRateLimiter struct {
+ mu sync.Mutex
+ limiters map[string]*tokenBucket
+ rate float64 // tokens replenished per second
+ burst int // maximum token capacity
+}
+
+// newIPRateLimiter creates a limiter with the given replenishment rate (tokens/sec)
+// and burst capacity. Use rate=0 to disable replenishment (tokens never refill).
+func newIPRateLimiter(rate float64, burst int) *ipRateLimiter {
+ return &ipRateLimiter{
+ limiters: make(map[string]*tokenBucket),
+ rate: rate,
+ burst: burst,
+ }
+}
+
+func (l *ipRateLimiter) allow(ip string) bool {
+ l.mu.Lock()
+ b, ok := l.limiters[ip]
+ if !ok {
+ b = &tokenBucket{
+ tokens: float64(l.burst),
+ capacity: float64(l.burst),
+ rate: l.rate,
+ lastTime: time.Now(),
+ }
+ l.limiters[ip] = b
+ }
+ l.mu.Unlock()
+ return b.allow()
+}
+
+// middleware wraps h with per-IP rate limiting, returning 429 when exceeded.
+func (l *ipRateLimiter) middleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ip := realIP(r)
+ if !l.allow(ip) {
+ writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"})
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// tokenBucket is a simple token-bucket rate limiter for a single key.
+type tokenBucket struct {
+ mu sync.Mutex
+ tokens float64
+ capacity float64
+ rate float64 // tokens per second
+ lastTime time.Time
+}
+
+func (b *tokenBucket) allow() bool {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ now := time.Now()
+ if !b.lastTime.IsZero() {
+ elapsed := now.Sub(b.lastTime).Seconds()
+ b.tokens = min(b.capacity, b.tokens+elapsed*b.rate)
+ }
+ b.lastTime = now
+ if b.tokens >= 1.0 {
+ b.tokens--
+ return true
+ }
+ return false
+}
+
+// realIP extracts the client's real IP from a request.
+func realIP(r *http.Request) string {
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ for i, c := range xff {
+ if c == ',' {
+ return xff[:i]
+ }
+ }
+ return xff
+ }
+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
+ return xri
+ }
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ return r.RemoteAddr
+ }
+ return host
+}