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
This commit is contained in:
2026-04-18 17:14:25 +02:00
parent 4ac93a0933
commit df0227d4f2
10 changed files with 707 additions and 0 deletions
@@ -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"))
}
})
}
+35
View File
@@ -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)
}
+41
View File
@@ -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.
@@ -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)
}
})
}
+39
View File
@@ -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)
})
}
}
+46
View File
@@ -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"}`.
+110
View File
@@ -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")
}
})
}
@@ -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")
}
})
}
@@ -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
})
}
+43
View File
@@ -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))
}
})
}