From c3d9fbd8d36fb9bc07fe399bc3618906cbf4d141 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:14:25 +0200 Subject: [PATCH] feat(infra): rate limit middlewares HTTP por IP y por key + tests Implementa fase 2 del issue 0016: - rate_limit_middleware: limita por IP (X-Forwarded-For > X-Real-IP > RemoteAddr) - rate_limiter_by_key: middleware configurable con keyFunc custom (API key, user ID...) - Cuando se rechaza responde 429 con HTTPError JSON y headers Retry-After + X-RateLimit-* - Tests con httptest.NewRecorder cubriendo: limite, burst, IPs independientes, XFF prioritario, recarga temporal, JSON body, headers IETF, GC stop idempotente, key vacia salta limit --- functions/infra/rate_limit_headers_test.go | 67 +++++++ functions/infra/rate_limit_middleware.go | 35 ++++ functions/infra/rate_limit_middleware.md | 41 +++++ functions/infra/rate_limit_middleware_test.go | 168 ++++++++++++++++++ functions/infra/rate_limiter_by_key.go | 39 ++++ functions/infra/rate_limiter_by_key.md | 46 +++++ functions/infra/rate_limiter_by_key_test.go | 110 ++++++++++++ functions/infra/rate_limiter_check_test.go | 90 ++++++++++ functions/infra/rate_limiter_cleanup_test.go | 68 +++++++ functions/infra/rate_limiter_new_test.go | 43 +++++ 10 files changed, 707 insertions(+) create mode 100644 functions/infra/rate_limit_headers_test.go create mode 100644 functions/infra/rate_limit_middleware.go create mode 100644 functions/infra/rate_limit_middleware.md create mode 100644 functions/infra/rate_limit_middleware_test.go create mode 100644 functions/infra/rate_limiter_by_key.go create mode 100644 functions/infra/rate_limiter_by_key.md create mode 100644 functions/infra/rate_limiter_by_key_test.go create mode 100644 functions/infra/rate_limiter_check_test.go create mode 100644 functions/infra/rate_limiter_cleanup_test.go create mode 100644 functions/infra/rate_limiter_new_test.go diff --git a/functions/infra/rate_limit_headers_test.go b/functions/infra/rate_limit_headers_test.go new file mode 100644 index 00000000..bd0afdc0 --- /dev/null +++ b/functions/infra/rate_limit_headers_test.go @@ -0,0 +1,67 @@ +package infra + +import ( + "strconv" + "testing" + "time" +) + +func TestRateLimitHeaders(t *testing.T) { + now := time.Now() + + t.Run("setea X-RateLimit-Limit con el burst", func(t *testing.T) { + result := RateLimitResult{Allowed: true, Remaining: 5, ResetAt: now} + h := RateLimitHeaders(result, 20) + if h.Get("X-RateLimit-Limit") != "20" { + t.Errorf("X-RateLimit-Limit=%q, want 20", h.Get("X-RateLimit-Limit")) + } + }) + + t.Run("setea X-RateLimit-Remaining con result.Remaining", func(t *testing.T) { + result := RateLimitResult{Allowed: true, Remaining: 7, ResetAt: now} + h := RateLimitHeaders(result, 10) + if h.Get("X-RateLimit-Remaining") != "7" { + t.Errorf("X-RateLimit-Remaining=%q, want 7", h.Get("X-RateLimit-Remaining")) + } + }) + + t.Run("setea X-RateLimit-Reset como timestamp unix", func(t *testing.T) { + future := now.Add(30 * time.Second) + result := RateLimitResult{Allowed: true, Remaining: 1, ResetAt: future} + h := RateLimitHeaders(result, 10) + got := h.Get("X-RateLimit-Reset") + want := strconv.FormatInt(future.Unix(), 10) + if got != want { + t.Errorf("X-RateLimit-Reset=%q, want %q", got, want) + } + }) + + t.Run("incluye Retry-After cuando no esta permitido", func(t *testing.T) { + result := RateLimitResult{Allowed: false, Remaining: 0, ResetAt: now, RetryAfter: 2.3} + h := RateLimitHeaders(result, 10) + if h.Get("Retry-After") == "" { + t.Fatal("Retry-After deberia estar seteado") + } + // Debe redondear hacia arriba + ra, _ := strconv.Atoi(h.Get("Retry-After")) + if ra != 3 { + t.Errorf("Retry-After=%d, want 3 (ceil de 2.3)", ra) + } + }) + + t.Run("Retry-After minimo es 1", func(t *testing.T) { + result := RateLimitResult{Allowed: false, Remaining: 0, ResetAt: now, RetryAfter: 0.1} + h := RateLimitHeaders(result, 10) + if h.Get("Retry-After") != "1" { + t.Errorf("Retry-After=%q, want 1 (minimo)", h.Get("Retry-After")) + } + }) + + t.Run("no incluye Retry-After cuando esta permitido", func(t *testing.T) { + result := RateLimitResult{Allowed: true, Remaining: 5, ResetAt: now} + h := RateLimitHeaders(result, 10) + if h.Get("Retry-After") != "" { + t.Errorf("Retry-After=%q, want vacio", h.Get("Retry-After")) + } + }) +} diff --git a/functions/infra/rate_limit_middleware.go b/functions/infra/rate_limit_middleware.go new file mode 100644 index 00000000..da46637f --- /dev/null +++ b/functions/infra/rate_limit_middleware.go @@ -0,0 +1,35 @@ +package infra + +import ( + "net" + "net/http" + "strings" +) + +// rateLimitClientIP extrae la IP del cliente del request. +// Prioridad: X-Forwarded-For (primer valor) > X-Real-IP > RemoteAddr. +// Para X-Forwarded-For multi-hop solo se usa el primer IP (cliente original). +func rateLimitClientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + ip := strings.TrimSpace(parts[0]) + if ip != "" { + return ip + } + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return strings.TrimSpace(xri) + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +// RateLimitMiddleware retorna un Middleware que aplica rate limiting por IP del cliente. +// Si el request supera el limite responde 429 con headers Retry-After y X-RateLimit-*. +// La IP se extrae con prioridad X-Forwarded-For > X-Real-IP > RemoteAddr. +func RateLimitMiddleware(rl *RateLimiter) Middleware { + return RateLimiterByKey(rl, rateLimitClientIP) +} diff --git a/functions/infra/rate_limit_middleware.md b/functions/infra/rate_limit_middleware.md new file mode 100644 index 00000000..e09e8662 --- /dev/null +++ b/functions/infra/rate_limit_middleware.md @@ -0,0 +1,41 @@ +--- +name: rate_limit_middleware +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func RateLimitMiddleware(rl *RateLimiter) Middleware" +description: "Middleware HTTP que aplica rate limiting por IP del cliente. Extrae IP de X-Forwarded-For, X-Real-IP o RemoteAddr. Responde 429 con headers Retry-After y X-RateLimit-* cuando se supera el limite." +tags: [rate_limit, http, middleware, ip, server, infra] +uses_functions: [rate_limiter_by_key_go_infra] +uses_types: [RateLimiter_go_infra, Middleware_go_infra] +returns: [Middleware_go_infra] +returns_optional: false +error_type: "error_go_core" +imports: [net, net/http, strings] +params: + - name: rl + desc: "puntero al RateLimiter compartido entre todos los requests" +output: "Middleware que limita por IP del cliente y responde 429 cuando se excede" +tested: true +tests: ["permite request bajo el limite", "responde 429 cuando se excede el limite", "IPs distintas tienen buckets independientes", "X-Forwarded-For tiene prioridad sobre RemoteAddr", "responde con Retry-After header en 429"] +test_file_path: "functions/infra/rate_limit_middleware_test.go" +file_path: "functions/infra/rate_limit_middleware.go" +--- + +## Ejemplo + +```go +rl := RateLimiterNew(10, 20) +chain := HTTPMiddlewareChain( + HTTPCORSMiddleware([]string{"*"}, []string{"GET", "POST"}), + RateLimitMiddleware(rl), + HTTPLoggerMiddleware(os.Stderr), +) +http.ListenAndServe(":8080", chain(mux)) +``` + +## Notas + +Funcion impura — el middleware producido muta el estado del limiter en cada request. Internamente delega en `RateLimiterByKey` con un keyFunc que extrae la IP segun cabeceras estandar de proxy. La extraccion de IP confia en X-Forwarded-For: para uso publico real habria que validar contra trusted proxies para evitar spoofing. Para entornos internos (deploy con Traefik/Nginx delante) es seguro. diff --git a/functions/infra/rate_limit_middleware_test.go b/functions/infra/rate_limit_middleware_test.go new file mode 100644 index 00000000..1efef340 --- /dev/null +++ b/functions/infra/rate_limit_middleware_test.go @@ -0,0 +1,168 @@ +package infra + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestRateLimitMiddleware(t *testing.T) { + base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) + }) + + t.Run("permite request bajo el limite", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + mw := RateLimitMiddleware(rl) + handler := mw(base) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api", nil) + req.RemoteAddr = "192.0.2.1:1234" + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Errorf("status=%d, want 200", rec.Code) + } + if rec.Header().Get("X-RateLimit-Limit") != "20" { + t.Errorf("X-RateLimit-Limit=%q, want 20", rec.Header().Get("X-RateLimit-Limit")) + } + }) + + t.Run("responde 429 cuando se excede el limite", func(t *testing.T) { + rl := RateLimiterNew(1, 2) + mw := RateLimitMiddleware(rl) + handler := mw(base) + + // Consumir burst + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api", nil) + req.RemoteAddr = "192.0.2.2:1234" + handler.ServeHTTP(rec, req) + } + // Tercer request rechazado + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/api", nil) + req.RemoteAddr = "192.0.2.2:1234" + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusTooManyRequests { + t.Errorf("status=%d, want 429", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("Retry-After header no seteado en 429") + } + }) + + t.Run("IPs distintas tienen buckets independientes", func(t *testing.T) { + rl := RateLimiterNew(1, 1) + mw := RateLimitMiddleware(rl) + handler := mw(base) + + // Agotar IP A + recA := httptest.NewRecorder() + reqA := httptest.NewRequest("GET", "/api", nil) + reqA.RemoteAddr = "10.0.0.1:1000" + handler.ServeHTTP(recA, reqA) + + recA2 := httptest.NewRecorder() + reqA2 := httptest.NewRequest("GET", "/api", nil) + reqA2.RemoteAddr = "10.0.0.1:1000" + handler.ServeHTTP(recA2, reqA2) + if recA2.Code != http.StatusTooManyRequests { + t.Fatal("IP A deberia estar limitada") + } + + // IP B intacta + recB := httptest.NewRecorder() + reqB := httptest.NewRequest("GET", "/api", nil) + reqB.RemoteAddr = "10.0.0.2:1000" + handler.ServeHTTP(recB, reqB) + if recB.Code != http.StatusOK { + t.Errorf("IP B status=%d, want 200", recB.Code) + } + }) + + t.Run("X-Forwarded-For tiene prioridad sobre RemoteAddr", func(t *testing.T) { + rl := RateLimiterNew(1, 1) + mw := RateLimitMiddleware(rl) + handler := mw(base) + + // Request 1 con XFF=A, RemoteAddr=R1 + rec1 := httptest.NewRecorder() + req1 := httptest.NewRequest("GET", "/api", nil) + req1.Header.Set("X-Forwarded-For", "203.0.113.1") + req1.RemoteAddr = "192.0.2.99:5000" + handler.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusOK { + t.Fatal("primer request deberia pasar") + } + + // Request 2 con XFF=A pero RemoteAddr distinto -> mismo bucket A -> rechazado + rec2 := httptest.NewRecorder() + req2 := httptest.NewRequest("GET", "/api", nil) + req2.Header.Set("X-Forwarded-For", "203.0.113.1") + req2.RemoteAddr = "192.0.2.100:5000" + handler.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusTooManyRequests { + t.Errorf("XFF deberia identificar misma IP, status=%d", rec2.Code) + } + }) + + t.Run("responde con Retry-After header en 429", func(t *testing.T) { + rl := RateLimiterNew(1, 1) + mw := RateLimitMiddleware(rl) + handler := mw(base) + + // Agotar + req := httptest.NewRequest("GET", "/api", nil) + req.RemoteAddr = "172.16.0.1:1000" + handler.ServeHTTP(httptest.NewRecorder(), req) + + // Rechazado + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("status=%d, want 429", rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Error("Retry-After vacio en 429") + } + if rec.Header().Get("Content-Type") != "application/json" { + t.Errorf("Content-Type=%q, want application/json", rec.Header().Get("Content-Type")) + } + }) +} + +func TestRateLimitClientIP(t *testing.T) { + t.Run("X-Forwarded-For first hop", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Forwarded-For", "203.0.113.1, 198.51.100.1, 10.0.0.1") + req.RemoteAddr = "192.0.2.1:1234" + got := rateLimitClientIP(req) + if got != "203.0.113.1" { + t.Errorf("got %q, want 203.0.113.1", got) + } + }) + + t.Run("X-Real-IP fallback", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-Real-IP", "203.0.113.5") + req.RemoteAddr = "192.0.2.1:1234" + got := rateLimitClientIP(req) + if got != "203.0.113.5" { + t.Errorf("got %q, want 203.0.113.5", got) + } + }) + + t.Run("RemoteAddr cuando no hay headers", func(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.RemoteAddr = "192.0.2.1:1234" + got := rateLimitClientIP(req) + if got != "192.0.2.1" { + t.Errorf("got %q, want 192.0.2.1", got) + } + }) +} diff --git a/functions/infra/rate_limiter_by_key.go b/functions/infra/rate_limiter_by_key.go new file mode 100644 index 00000000..b4535846 --- /dev/null +++ b/functions/infra/rate_limiter_by_key.go @@ -0,0 +1,39 @@ +package infra + +import ( + "net/http" +) + +// RateLimiterByKey retorna un Middleware que aplica rate limiting usando keyFunc para extraer la clave del request. +// Permite limitar por API key, user ID, o cualquier dimension custom. +// Cuando keyFunc devuelve "" no se aplica limit (request pasa sin tocar el bucket). +// Cuando se supera el limite responde 429 con headers X-RateLimit-* y Retry-After. +func RateLimiterByKey(rl *RateLimiter, keyFunc func(r *http.Request) string) Middleware { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := keyFunc(r) + if key == "" { + // Sin clave no se aplica limit. + next.ServeHTTP(w, r) + return + } + + result := RateLimiterCheck(rl, key) + headers := RateLimitHeaders(result, rl.burst) + for k, v := range headers { + w.Header()[k] = v + } + + if !result.Allowed { + HTTPErrorResponse(w, HTTPError{ + Status: http.StatusTooManyRequests, + Code: "rate_limited", + Message: "too many requests", + }) + return + } + + next.ServeHTTP(w, r) + }) + } +} diff --git a/functions/infra/rate_limiter_by_key.md b/functions/infra/rate_limiter_by_key.md new file mode 100644 index 00000000..1f9c070a --- /dev/null +++ b/functions/infra/rate_limiter_by_key.md @@ -0,0 +1,46 @@ +--- +name: rate_limiter_by_key +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func RateLimiterByKey(rl *RateLimiter, keyFunc func(r *http.Request) string) Middleware" +description: "Middleware HTTP configurable que aplica rate limiting con un extractor de clave custom. Permite limitar por API key, user ID, header arbitrario, etc. Si keyFunc devuelve cadena vacia el request pasa sin limit." +tags: [rate_limit, http, middleware, custom, key, server, infra] +uses_functions: [rate_limiter_check_go_infra, rate_limit_headers_go_infra, http_error_response_go_infra] +uses_types: [RateLimiter_go_infra, Middleware_go_infra, HTTPError_go_infra] +returns: [Middleware_go_infra] +returns_optional: false +error_type: "error_go_core" +imports: [net/http] +params: + - name: rl + desc: "puntero al RateLimiter compartido entre todos los requests" + - name: keyFunc + desc: "funcion que extrae la clave del request (API key, user ID, etc.). Cadena vacia salta el limit" +output: "Middleware que aplica rate limit segun keyFunc, responde 429 con HTTPError JSON al exceder" +tested: true +tests: ["aplica limit por la clave devuelta por keyFunc", "key vacia salta el limit", "responde 429 con body JSON al exceder", "headers X-RateLimit-* siempre presentes en respuesta"] +test_file_path: "functions/infra/rate_limiter_by_key_test.go" +file_path: "functions/infra/rate_limiter_by_key.go" +--- + +## Ejemplo + +```go +// Limit por API key +rl := RateLimiterNew(100, 200) +mw := RateLimiterByKey(rl, func(r *http.Request) string { + return r.Header.Get("X-API-Key") +}) + +// Limit por user ID extraido del JWT (suponiendo middleware previo que lo setea) +mwUser := RateLimiterByKey(rl, func(r *http.Request) string { + return r.Context().Value("user_id").(string) +}) +``` + +## Notas + +Funcion impura — el middleware muta el estado del limiter. Reutiliza `RateLimiterCheck`, `RateLimitHeaders` y `HTTPErrorResponse` del registry. Si el keyFunc devuelve "" se interpreta como "sin clave identificable" y se deja pasar el request: util para endpoints publicos donde solo se quiere limitar requests autenticados. La respuesta 429 sigue el formato JSON estandar `{"status":429,"code":"rate_limited","message":"too many requests"}`. diff --git a/functions/infra/rate_limiter_by_key_test.go b/functions/infra/rate_limiter_by_key_test.go new file mode 100644 index 00000000..80482b69 --- /dev/null +++ b/functions/infra/rate_limiter_by_key_test.go @@ -0,0 +1,110 @@ +package infra + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestRateLimiterByKey(t *testing.T) { + base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + keyFunc := func(r *http.Request) string { + return r.Header.Get("X-API-Key") + } + + t.Run("aplica limit por la clave devuelta por keyFunc", func(t *testing.T) { + rl := RateLimiterNew(1, 2) + mw := RateLimiterByKey(rl, keyFunc) + handler := mw(base) + + // Agotar tokens de api-key-A + for i := 0; i < 2; i++ { + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-API-Key", "key-A") + handler.ServeHTTP(httptest.NewRecorder(), req) + } + // Tercer rechazado + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-API-Key", "key-A") + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Errorf("status=%d, want 429", rec.Code) + } + + // key-B intacta + recB := httptest.NewRecorder() + reqB := httptest.NewRequest("GET", "/", nil) + reqB.Header.Set("X-API-Key", "key-B") + handler.ServeHTTP(recB, reqB) + if recB.Code != http.StatusOK { + t.Errorf("key-B status=%d, want 200", recB.Code) + } + }) + + t.Run("key vacia salta el limit", func(t *testing.T) { + rl := RateLimiterNew(1, 1) + mw := RateLimiterByKey(rl, keyFunc) + handler := mw(base) + + // Sin X-API-Key, hagamos muchos requests + for i := 0; i < 10; i++ { + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Errorf("request %d status=%d, want 200 (sin key, sin limit)", i, rec.Code) + } + } + }) + + t.Run("responde 429 con body JSON al exceder", func(t *testing.T) { + rl := RateLimiterNew(1, 1) + mw := RateLimiterByKey(rl, keyFunc) + handler := mw(base) + + // Agotar + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-API-Key", "key-X") + handler.ServeHTTP(httptest.NewRecorder(), req) + + // Rechazado + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusTooManyRequests { + t.Fatalf("status=%d, want 429", rec.Code) + } + var body map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body no es JSON: %v", err) + } + // HTTPError se serializa con campos PascalCase (sin tags JSON) + if body["Code"] != "rate_limited" { + t.Errorf("Code=%v, want rate_limited", body["Code"]) + } + }) + + t.Run("headers X-RateLimit-* siempre presentes en respuesta", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + mw := RateLimiterByKey(rl, keyFunc) + handler := mw(base) + + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + req.Header.Set("X-API-Key", "key-Y") + handler.ServeHTTP(rec, req) + + if rec.Header().Get("X-RateLimit-Limit") == "" { + t.Error("X-RateLimit-Limit ausente") + } + if rec.Header().Get("X-RateLimit-Remaining") == "" { + t.Error("X-RateLimit-Remaining ausente") + } + if rec.Header().Get("X-RateLimit-Reset") == "" { + t.Error("X-RateLimit-Reset ausente") + } + }) +} diff --git a/functions/infra/rate_limiter_check_test.go b/functions/infra/rate_limiter_check_test.go new file mode 100644 index 00000000..0a145890 --- /dev/null +++ b/functions/infra/rate_limiter_check_test.go @@ -0,0 +1,90 @@ +package infra + +import ( + "testing" + "time" +) + +func TestRateLimiterCheck(t *testing.T) { + t.Run("primer request siempre allowed con burst-1 remaining", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + result := RateLimiterCheck(rl, "client-1") + if !result.Allowed { + t.Error("primer request rechazado") + } + if result.Remaining != 19 { + t.Errorf("Remaining=%d, want 19", result.Remaining) + } + if result.RetryAfter != 0 { + t.Errorf("RetryAfter=%v, want 0", result.RetryAfter) + } + }) + + t.Run("consumir todos los tokens bloquea siguiente request", func(t *testing.T) { + rl := RateLimiterNew(1, 5) + // Consumir los 5 tokens del burst + for i := 0; i < 5; i++ { + r := RateLimiterCheck(rl, "client-2") + if !r.Allowed { + t.Fatalf("request %d rechazado, esperaba allowed", i) + } + } + // El sexto deberia ser rechazado + r := RateLimiterCheck(rl, "client-2") + if r.Allowed { + t.Error("request post-burst permitido, esperaba rechazo") + } + if r.Remaining != 0 { + t.Errorf("Remaining=%d, want 0", r.Remaining) + } + }) + + t.Run("los tokens se recargan con el paso del tiempo", func(t *testing.T) { + rl := RateLimiterNew(100, 2) // 100 tokens/seg = 1 token cada 10ms + // Consumir burst + RateLimiterCheck(rl, "client-3") + RateLimiterCheck(rl, "client-3") + // Tercer request rechazado + r := RateLimiterCheck(rl, "client-3") + if r.Allowed { + t.Fatal("tercer request inmediato deberia estar rechazado") + } + // Esperar suficiente para recargar al menos 1 token + time.Sleep(50 * time.Millisecond) + r2 := RateLimiterCheck(rl, "client-3") + if !r2.Allowed { + t.Error("request tras recarga deberia estar permitido") + } + }) + + t.Run("retryAfter es positivo cuando se rechaza", func(t *testing.T) { + rl := RateLimiterNew(1, 1) // 1 token/seg, burst 1 + RateLimiterCheck(rl, "client-4") + r := RateLimiterCheck(rl, "client-4") + if r.Allowed { + t.Fatal("segundo request inmediato deberia rechazarse") + } + if r.RetryAfter <= 0 { + t.Errorf("RetryAfter=%v, want > 0", r.RetryAfter) + } + if r.RetryAfter > 1.5 { + t.Errorf("RetryAfter=%v, esperado <= 1s aprox", r.RetryAfter) + } + }) + + t.Run("keys distintas tienen buckets independientes", func(t *testing.T) { + rl := RateLimiterNew(1, 2) + // Consumir todo de A + RateLimiterCheck(rl, "A") + RateLimiterCheck(rl, "A") + ra := RateLimiterCheck(rl, "A") + if ra.Allowed { + t.Fatal("A deberia estar agotado") + } + // B sigue intacto + rb := RateLimiterCheck(rl, "B") + if !rb.Allowed { + t.Error("B deberia tener tokens propios") + } + }) +} diff --git a/functions/infra/rate_limiter_cleanup_test.go b/functions/infra/rate_limiter_cleanup_test.go new file mode 100644 index 00000000..4637f2aa --- /dev/null +++ b/functions/infra/rate_limiter_cleanup_test.go @@ -0,0 +1,68 @@ +package infra + +import ( + "testing" + "time" +) + +func TestRateLimiterCleanup(t *testing.T) { + t.Run("purga entries con lastSeen mas antiguo que maxAge", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + // Inyectar manualmente una entry vieja + rl.mu.Lock() + rl.clients["old"] = &rateLimiterClient{tokens: 5, lastSeen: time.Now().Add(-time.Hour)} + rl.clients["new"] = &rateLimiterClient{tokens: 5, lastSeen: time.Now()} + rl.mu.Unlock() + + stop := RateLimiterCleanup(rl, 10*time.Minute, 30*time.Millisecond) + defer stop() + + // Esperar al menos un tick + time.Sleep(100 * time.Millisecond) + + rl.mu.Lock() + _, oldExists := rl.clients["old"] + _, newExists := rl.clients["new"] + rl.mu.Unlock() + + if oldExists { + t.Error("entry 'old' no fue purgada") + } + if !newExists { + t.Error("entry 'new' fue purgada incorrectamente") + } + }) + + t.Run("no purga entries recientes", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + RateLimiterCheck(rl, "fresh") + + stop := RateLimiterCleanup(rl, 10*time.Minute, 30*time.Millisecond) + defer stop() + + time.Sleep(100 * time.Millisecond) + + rl.mu.Lock() + _, exists := rl.clients["fresh"] + rl.mu.Unlock() + if !exists { + t.Error("entry reciente fue purgada incorrectamente") + } + }) + + t.Run("stop detiene la goroutine sin panic", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + stop := RateLimiterCleanup(rl, time.Second, 10*time.Millisecond) + stop() + // Si esto no panica, pasa + time.Sleep(50 * time.Millisecond) + }) + + t.Run("stop es idempotente", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + stop := RateLimiterCleanup(rl, time.Second, 10*time.Millisecond) + stop() + stop() // segunda llamada no debe panic + stop() // tercera tampoco + }) +} diff --git a/functions/infra/rate_limiter_new_test.go b/functions/infra/rate_limiter_new_test.go new file mode 100644 index 00000000..a16de53b --- /dev/null +++ b/functions/infra/rate_limiter_new_test.go @@ -0,0 +1,43 @@ +package infra + +import "testing" + +func TestRateLimiterNew(t *testing.T) { + t.Run("crea limiter con rate y burst configurados", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + if rl == nil { + t.Fatal("RateLimiterNew retorno nil") + } + if rl.rate != 10 { + t.Errorf("rate=%v, want 10", rl.rate) + } + if rl.burst != 20 { + t.Errorf("burst=%d, want 20", rl.burst) + } + }) + + t.Run("valores cero se sustituyen por 1", func(t *testing.T) { + rl := RateLimiterNew(0, 0) + if rl.rate != 1 { + t.Errorf("rate=%v, want 1 (default)", rl.rate) + } + if rl.burst != 1 { + t.Errorf("burst=%d, want 1 (default)", rl.burst) + } + + rl2 := RateLimiterNew(-5, -10) + if rl2.rate != 1 || rl2.burst != 1 { + t.Errorf("valores negativos no se normalizaron a 1: rate=%v burst=%d", rl2.rate, rl2.burst) + } + }) + + t.Run("el mapa de clientes empieza vacio", func(t *testing.T) { + rl := RateLimiterNew(10, 20) + if rl.clients == nil { + t.Error("clients map es nil, deberia ser inicializado") + } + if len(rl.clients) != 0 { + t.Errorf("clients len=%d, want 0", len(rl.clients)) + } + }) +}