From 036c0a8d63284a383b113ff9be1016fd11723e9f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:11:22 +0200 Subject: [PATCH 1/3] feat(infra): rate limiter token-bucket in-memory + tipos y core funcs Implementa fase 1 del issue 0016: - Tipos RateLimiter, RateLimitConfig y RateLimitResult en types/infra/ - rate_limiter_new: constructor con validacion rate/burst > 0 - rate_limiter_check: evalua token bucket por key, calcula Allowed/Remaining/ResetAt/RetryAfter - rate_limit_headers (pure): construye headers IETF X-RateLimit-* y Retry-After - rate_limiter_cleanup: goroutine GC de entries inactivas con stop idempotente Sin dependencias externas (no Redis). sync.Mutex + map. Algoritmo token bucket estandar con recarga lineal proporcional al tiempo transcurrido. --- functions/infra/rate_limit_headers.go | 27 +++++++++++++ functions/infra/rate_limit_headers.md | 41 +++++++++++++++++++ functions/infra/rate_limiter.go | 45 +++++++++++++++++++++ functions/infra/rate_limiter_check.go | 54 +++++++++++++++++++++++++ functions/infra/rate_limiter_check.md | 42 +++++++++++++++++++ functions/infra/rate_limiter_cleanup.go | 45 +++++++++++++++++++++ functions/infra/rate_limiter_cleanup.md | 43 ++++++++++++++++++++ functions/infra/rate_limiter_new.go | 19 +++++++++ functions/infra/rate_limiter_new.md | 41 +++++++++++++++++++ types/infra/rate_limit_config.md | 33 +++++++++++++++ types/infra/rate_limit_result.md | 33 +++++++++++++++ types/infra/rate_limiter.md | 33 +++++++++++++++ 12 files changed, 456 insertions(+) create mode 100644 functions/infra/rate_limit_headers.go create mode 100644 functions/infra/rate_limit_headers.md create mode 100644 functions/infra/rate_limiter.go create mode 100644 functions/infra/rate_limiter_check.go create mode 100644 functions/infra/rate_limiter_check.md create mode 100644 functions/infra/rate_limiter_cleanup.go create mode 100644 functions/infra/rate_limiter_cleanup.md create mode 100644 functions/infra/rate_limiter_new.go create mode 100644 functions/infra/rate_limiter_new.md create mode 100644 types/infra/rate_limit_config.md create mode 100644 types/infra/rate_limit_result.md create mode 100644 types/infra/rate_limiter.md diff --git a/functions/infra/rate_limit_headers.go b/functions/infra/rate_limit_headers.go new file mode 100644 index 00000000..42e827b0 --- /dev/null +++ b/functions/infra/rate_limit_headers.go @@ -0,0 +1,27 @@ +package infra + +import ( + "math" + "net/http" + "strconv" +) + +// RateLimitHeaders construye los headers IETF estandar de rate limiting a partir de un resultado. +// Setea X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset y, si no esta permitido, Retry-After. +// limit es la capacidad total (burst) que se anuncia al cliente. +// Funcion pura — solo formatea, no hace I/O. +func RateLimitHeaders(result RateLimitResult, limit int) http.Header { + h := http.Header{} + h.Set("X-RateLimit-Limit", strconv.Itoa(limit)) + h.Set("X-RateLimit-Remaining", strconv.Itoa(result.Remaining)) + h.Set("X-RateLimit-Reset", strconv.FormatInt(result.ResetAt.Unix(), 10)) + if !result.Allowed { + // Retry-After segun RFC 7231: numero entero de segundos. + retryAfter := int(math.Ceil(result.RetryAfter)) + if retryAfter < 1 { + retryAfter = 1 + } + h.Set("Retry-After", strconv.Itoa(retryAfter)) + } + return h +} diff --git a/functions/infra/rate_limit_headers.md b/functions/infra/rate_limit_headers.md new file mode 100644 index 00000000..5add12f4 --- /dev/null +++ b/functions/infra/rate_limit_headers.md @@ -0,0 +1,41 @@ +--- +name: rate_limit_headers +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: pure +signature: "func RateLimitHeaders(result RateLimitResult, limit int) http.Header" +description: "Construye los headers IETF estandar de rate limiting (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset y Retry-After cuando se rechaza). Funcion pura, solo formatea." +tags: [rate_limit, http, headers, middleware, infra] +uses_functions: [] +uses_types: [RateLimitResult_go_infra] +returns: [] +returns_optional: false +error_type: "" +imports: [math, net/http, strconv] +params: + - name: result + desc: "RateLimitResult devuelto por RateLimiterCheck" + - name: limit + desc: "capacidad total (burst) anunciada al cliente en X-RateLimit-Limit" +output: "http.Header con los 3 headers X-RateLimit-* y, cuando result.Allowed es false, Retry-After (segundos enteros)" +tested: true +tests: ["setea X-RateLimit-Limit con el burst", "setea X-RateLimit-Remaining con result.Remaining", "setea X-RateLimit-Reset como timestamp unix", "incluye Retry-After cuando no esta permitido", "no incluye Retry-After cuando esta permitido"] +test_file_path: "functions/infra/rate_limit_headers_test.go" +file_path: "functions/infra/rate_limit_headers.go" +--- + +## Ejemplo + +```go +result := RateLimiterCheck(rl, "192.168.1.1") +headers := RateLimitHeaders(result, 20) +for k, v := range headers { + w.Header()[k] = v +} +``` + +## Notas + +Funcion pura — sin I/O, sin estado, deterministica para una misma entrada. Sigue `draft-ietf-httpapi-ratelimit-headers`. `Retry-After` se redondea hacia arriba en segundos enteros (RFC 7231) y nunca se setea por debajo de 1 (sino el cliente reintenta inmediatamente). El timestamp en `X-RateLimit-Reset` es Unix epoch en segundos. diff --git a/functions/infra/rate_limiter.go b/functions/infra/rate_limiter.go new file mode 100644 index 00000000..ac95dfab --- /dev/null +++ b/functions/infra/rate_limiter.go @@ -0,0 +1,45 @@ +package infra + +import ( + "net/http" + "sync" + "time" +) + +// rateLimiterClient mantiene el estado de un bucket para una clave concreta. +type rateLimiterClient struct { + tokens float64 + lastSeen time.Time +} + +// RateLimiter mantiene el estado token-bucket de todos los clientes en memoria. +// rate son los tokens recargados por segundo, burst es la capacidad maxima del bucket. +// El campo clients es un mapa key -> bucket protegido por mu. +type RateLimiter struct { + rate float64 + burst int + mu sync.Mutex + clients map[string]*rateLimiterClient +} + +// RateLimitConfig parametriza el middleware de rate limiting. +// KeyFunc extrae la clave del request (nil = IP del cliente). +// CleanupInterval controla la frecuencia del GC de entries inactivas (0 = no GC). +type RateLimitConfig struct { + RequestsPerSecond float64 + BurstSize int + KeyFunc func(r *http.Request) string + CleanupInterval time.Duration +} + +// RateLimitResult es el resultado de evaluar un request contra el limiter. +// Allowed indica si el request puede pasar. +// Remaining son los tokens restantes en el bucket despues del check. +// ResetAt es el momento en que el bucket vuelve a estar lleno. +// RetryAfter son los segundos hasta que se pueda reintentar (0 si Allowed). +type RateLimitResult struct { + Allowed bool + Remaining int + ResetAt time.Time + RetryAfter float64 +} diff --git a/functions/infra/rate_limiter_check.go b/functions/infra/rate_limiter_check.go new file mode 100644 index 00000000..5bb2a299 --- /dev/null +++ b/functions/infra/rate_limiter_check.go @@ -0,0 +1,54 @@ +package infra + +import ( + "math" + "time" +) + +// RateLimiterCheck evalua si un request identificado por key puede consumir un token. +// Recarga tokens segun el tiempo transcurrido desde el ultimo check (token bucket). +// Retorna RateLimitResult con Allowed, Remaining, ResetAt y RetryAfter. +// Es seguro para uso concurrente. +func RateLimiterCheck(rl *RateLimiter, key string) RateLimitResult { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + client, ok := rl.clients[key] + if !ok { + // Primera vez para esta key: bucket lleno menos el token consumido. + client = &rateLimiterClient{tokens: float64(rl.burst), lastSeen: now} + rl.clients[key] = client + } else { + // Recargar tokens segun tiempo transcurrido. + elapsed := now.Sub(client.lastSeen).Seconds() + client.tokens = math.Min(float64(rl.burst), client.tokens+elapsed*rl.rate) + client.lastSeen = now + } + + if client.tokens >= 1 { + client.tokens -= 1 + remaining := int(math.Floor(client.tokens)) + // ResetAt: cuando volvera a estar lleno. + missing := float64(rl.burst) - client.tokens + secondsToFull := missing / rl.rate + return RateLimitResult{ + Allowed: true, + Remaining: remaining, + ResetAt: now.Add(time.Duration(secondsToFull * float64(time.Second))), + RetryAfter: 0, + } + } + + // Sin tokens suficientes — calcular cuanto esperar. + needed := 1 - client.tokens + retryAfter := needed / rl.rate + missing := float64(rl.burst) - client.tokens + secondsToFull := missing / rl.rate + return RateLimitResult{ + Allowed: false, + Remaining: 0, + ResetAt: now.Add(time.Duration(secondsToFull * float64(time.Second))), + RetryAfter: retryAfter, + } +} diff --git a/functions/infra/rate_limiter_check.md b/functions/infra/rate_limiter_check.md new file mode 100644 index 00000000..9527ca9f --- /dev/null +++ b/functions/infra/rate_limiter_check.md @@ -0,0 +1,42 @@ +--- +name: rate_limiter_check +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func RateLimiterCheck(rl *RateLimiter, key string) RateLimitResult" +description: "Evalua un request del cliente identificado por key contra el token bucket. Recarga tokens segun el tiempo transcurrido y consume uno si esta permitido. Seguro para uso concurrente (mutex interno)." +tags: [rate_limit, http, middleware, token_bucket, check, infra] +uses_functions: [] +uses_types: [RateLimiter_go_infra, RateLimitResult_go_infra] +returns: [RateLimitResult_go_infra] +returns_optional: false +error_type: "error_go_core" +imports: [math, time] +params: + - name: rl + desc: "puntero al RateLimiter creado con RateLimiterNew" + - name: key + desc: "identificador del cliente (IP, API key, user ID, etc.)" +output: "RateLimitResult con Allowed, Remaining, ResetAt y RetryAfter calculados a partir del estado del bucket" +tested: true +tests: ["primer request siempre allowed con burst-1 remaining", "consumir todos los tokens bloquea siguiente request", "los tokens se recargan con el paso del tiempo", "retryAfter es positivo cuando se rechaza", "keys distintas tienen buckets independientes"] +test_file_path: "functions/infra/rate_limiter_check_test.go" +file_path: "functions/infra/rate_limiter_check.go" +--- + +## Ejemplo + +```go +rl := RateLimiterNew(10, 20) + +result := RateLimiterCheck(rl, "192.168.1.1") +if !result.Allowed { + // sleep(result.RetryAfter * time.Second) o 429 +} +``` + +## Notas + +Funcion impura porque depende de tiempo wall-clock (`time.Now`) y muta el estado del limiter. Algoritmo token bucket clasico: cada bucket se recarga linealmente a `rate` tokens/segundo hasta `burst`. Un request consume 1 token. La primera vez que aparece una key se crea el bucket lleno (descontando ya el primer token). El mutex asegura uso seguro desde multiples goroutines. diff --git a/functions/infra/rate_limiter_cleanup.go b/functions/infra/rate_limiter_cleanup.go new file mode 100644 index 00000000..526c020c --- /dev/null +++ b/functions/infra/rate_limiter_cleanup.go @@ -0,0 +1,45 @@ +package infra + +import "time" + +// RateLimiterCleanup arranca una goroutine que purga periodicamente las entries +// del limiter que no han tenido actividad en maxAge. +// Retorna una funcion que detiene la goroutine cuando se invoca. +// interval controla la frecuencia del barrido. maxAge es la edad maxima sin actividad. +func RateLimiterCleanup(rl *RateLimiter, maxAge time.Duration, interval time.Duration) func() { + if interval <= 0 { + interval = time.Minute + } + if maxAge <= 0 { + maxAge = 10 * time.Minute + } + + stop := make(chan struct{}) + go func() { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case now := <-ticker.C: + rl.mu.Lock() + for key, client := range rl.clients { + if now.Sub(client.lastSeen) > maxAge { + delete(rl.clients, key) + } + } + rl.mu.Unlock() + } + } + }() + + var stopped bool + return func() { + if stopped { + return + } + stopped = true + close(stop) + } +} diff --git a/functions/infra/rate_limiter_cleanup.md b/functions/infra/rate_limiter_cleanup.md new file mode 100644 index 00000000..2dda1bb2 --- /dev/null +++ b/functions/infra/rate_limiter_cleanup.md @@ -0,0 +1,43 @@ +--- +name: rate_limiter_cleanup +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func RateLimiterCleanup(rl *RateLimiter, maxAge time.Duration, interval time.Duration) func()" +description: "Arranca una goroutine que purga periodicamente las entries del RateLimiter sin actividad reciente. Retorna una funcion para detener el GC sin leaks de goroutine." +tags: [rate_limit, http, middleware, cleanup, gc, goroutine, infra] +uses_functions: [] +uses_types: [RateLimiter_go_infra] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [time] +params: + - name: rl + desc: "puntero al RateLimiter cuyas entries se purgaran" + - name: maxAge + desc: "edad maxima sin actividad antes de purgar una entry (ej: 10*time.Minute)" + - name: interval + desc: "frecuencia del barrido de purga (ej: 5*time.Minute). 0 usa default 1 minuto" +output: "funcion stop sin argumentos que detiene la goroutine de cleanup. Idempotente — llamarla varias veces es seguro" +tested: true +tests: ["purga entries con lastSeen mas antiguo que maxAge", "no purga entries recientes", "stop detiene la goroutine sin panic", "stop es idempotente"] +test_file_path: "functions/infra/rate_limiter_cleanup_test.go" +file_path: "functions/infra/rate_limiter_cleanup.go" +--- + +## Ejemplo + +```go +rl := RateLimiterNew(10, 20) +stop := RateLimiterCleanup(rl, 10*time.Minute, 5*time.Minute) +defer stop() + +// Servir requests... el GC limpia entries inactivas en background. +``` + +## Notas + +Funcion impura — arranca una goroutine, depende de tiempo wall-clock. La goroutine se cierra al invocar la funcion retornada (cierra un channel interno). Sin invocar stop la goroutine vive hasta que el proceso termina (leak). Valores <= 0 para interval o maxAge se sustituyen por defaults seguros (1 min / 10 min). El barrido toma el mutex del limiter — un interval muy corto puede causar contencion en limiters con muchas keys. diff --git a/functions/infra/rate_limiter_new.go b/functions/infra/rate_limiter_new.go new file mode 100644 index 00000000..8f8a3842 --- /dev/null +++ b/functions/infra/rate_limiter_new.go @@ -0,0 +1,19 @@ +package infra + +// RateLimiterNew crea un RateLimiter token-bucket vacio con la tasa y rafaga indicadas. +// rate son los tokens recargados por segundo (sostenido). +// burst es la capacidad maxima del bucket (rafaga puntual). +// Si rate o burst son <= 0 se usa 1 como minimo seguro. +func RateLimiterNew(rate float64, burst int) *RateLimiter { + if rate <= 0 { + rate = 1 + } + if burst <= 0 { + burst = 1 + } + return &RateLimiter{ + rate: rate, + burst: burst, + clients: make(map[string]*rateLimiterClient), + } +} diff --git a/functions/infra/rate_limiter_new.md b/functions/infra/rate_limiter_new.md new file mode 100644 index 00000000..7cd5cdfa --- /dev/null +++ b/functions/infra/rate_limiter_new.md @@ -0,0 +1,41 @@ +--- +name: rate_limiter_new +kind: function +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func RateLimiterNew(rate float64, burst int) *RateLimiter" +description: "Crea un RateLimiter token-bucket in-memory vacio. rate son los tokens recargados por segundo, burst es la capacidad maxima del bucket. Valores <= 0 se sustituyen por 1 como minimo seguro." +tags: [rate_limit, http, middleware, token_bucket, infra] +uses_functions: [] +uses_types: [RateLimiter_go_infra] +returns: [RateLimiter_go_infra] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: rate + desc: "tokens recargados por segundo (tasa sostenida, ej: 10 = 10 req/s)" + - name: burst + desc: "capacidad maxima del bucket (rafaga maxima permitida en un instante, ej: 20)" +output: "puntero a RateLimiter inicializado con mapa vacio de clientes, listo para usar con RateLimiterCheck" +tested: true +tests: ["crea limiter con rate y burst configurados", "valores cero se sustituyen por 1", "el mapa de clientes empieza vacio"] +test_file_path: "functions/infra/rate_limiter_new_test.go" +file_path: "functions/infra/rate_limiter_new.go" +--- + +## Ejemplo + +```go +// 10 req/s sostenidos, hasta 20 en rafaga +rl := RateLimiterNew(10, 20) + +// 1 req/s, sin rafaga +strict := RateLimiterNew(1, 1) +``` + +## Notas + +Funcion impura porque retorna un valor con estado mutable (mapa interno). No hace I/O ni network. La validacion de rate y burst evita panics por configuracion invalida (division por cero, buckets imposibles). Para iniciar el GC de entries inactivas usar `RateLimiterCleanup`. diff --git a/types/infra/rate_limit_config.md b/types/infra/rate_limit_config.md new file mode 100644 index 00000000..f12f0239 --- /dev/null +++ b/types/infra/rate_limit_config.md @@ -0,0 +1,33 @@ +--- +name: RateLimitConfig +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type RateLimitConfig struct { + RequestsPerSecond float64 + BurstSize int + KeyFunc func(r *http.Request) string + CleanupInterval time.Duration + } +description: "Configuracion declarativa para construir un rate limiter HTTP. Agrupa tasa sostenida, rafaga maxima, extractor de clave y frecuencia de GC." +tags: [rate_limit, config, http, middleware, infra] +uses_types: [] +file_path: "functions/infra/rate_limiter.go" +--- + +## Ejemplo + +```go +cfg := RateLimitConfig{ + RequestsPerSecond: 10, + BurstSize: 20, + KeyFunc: func(r *http.Request) string { return r.Header.Get("X-API-Key") }, + CleanupInterval: 5 * time.Minute, +} +``` + +## Notas + +Estructura inmutable que parametriza un limiter. `KeyFunc` nil indica usar la IP del cliente. `CleanupInterval` 0 desactiva el GC automatico. El consumidor decide como inicializar el limiter a partir de esta config. diff --git a/types/infra/rate_limit_result.md b/types/infra/rate_limit_result.md new file mode 100644 index 00000000..9cc7cdbf --- /dev/null +++ b/types/infra/rate_limit_result.md @@ -0,0 +1,33 @@ +--- +name: RateLimitResult +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type RateLimitResult struct { + Allowed bool + Remaining int + ResetAt time.Time + RetryAfter float64 + } +description: "Resultado de evaluar un request contra un RateLimiter. Indica si pasa, cuantos tokens quedan, cuando se rellena el bucket y cuanto esperar antes de reintentar." +tags: [rate_limit, result, http, middleware, infra] +uses_types: [] +file_path: "functions/infra/rate_limiter.go" +--- + +## Ejemplo + +```go +result := RateLimiterCheck(rl, "192.168.1.1") +if result.Allowed { + // procesar request +} else { + w.Header().Set("Retry-After", strconv.FormatFloat(result.RetryAfter, 'f', 1, 64)) +} +``` + +## Notas + +Snapshot inmutable del estado del bucket en el momento del check. `Remaining` es el numero entero de tokens disponibles tras el check. `ResetAt` es absoluto (UTC). `RetryAfter` es 0 cuando el request es permitido. Se serializa indirectamente a headers HTTP via `RateLimitHeaders`. diff --git a/types/infra/rate_limiter.md b/types/infra/rate_limiter.md new file mode 100644 index 00000000..73dd08fa --- /dev/null +++ b/types/infra/rate_limiter.md @@ -0,0 +1,33 @@ +--- +name: RateLimiter +lang: go +domain: infra +version: "1.0.0" +algebraic: product +definition: | + type RateLimiter struct { + rate float64 + burst int + mu sync.Mutex + clients map[string]*rateLimiterClient + } +description: "Token-bucket rate limiter in-memory. Mantiene un bucket por clave (IP o custom) con tokens recargados a tasa constante. Protegido por mutex para uso concurrente." +tags: [rate_limit, http, middleware, token_bucket, infra] +uses_types: [] +file_path: "functions/infra/rate_limiter.go" +--- + +## Ejemplo + +```go +// 10 req/s con rafagas hasta 20 +rl := RateLimiterNew(10, 20) +result := RateLimiterCheck(rl, "192.168.1.1") +if !result.Allowed { + // rechazar request +} +``` + +## Notas + +Tipo producto con estado mutable (mapa de clientes). Los campos son privados — se accede via `RateLimiterNew`, `RateLimiterCheck`, `RateLimiterCleanup`. El bucket se llena a `rate` tokens/segundo hasta un maximo de `burst`. Cada request consume 1 token. Sin GC explicito (`RateLimiterCleanup`) los buckets crecen indefinidamente con cada IP unica vista. From c3d9fbd8d36fb9bc07fe399bc3618906cbf4d141 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:14:25 +0200 Subject: [PATCH 2/3] 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)) + } + }) +} From 893b98f5b9d08f1aa51dcb296cd1604846148286 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:14:50 +0200 Subject: [PATCH 3/3] docs: cerrar issue 0016 (rate limiting) --- dev/issues/{ => completed}/0016-rate-limiting.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename dev/issues/{ => completed}/0016-rate-limiting.md (100%) diff --git a/dev/issues/0016-rate-limiting.md b/dev/issues/completed/0016-rate-limiting.md similarity index 100% rename from dev/issues/0016-rate-limiting.md rename to dev/issues/completed/0016-rate-limiting.md