From 4ac93a0933cd4e48e0bb52e260db93bf87c6df6b Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 18 Apr 2026 17:11:22 +0200 Subject: [PATCH] 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.