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.
This commit is contained in:
2026-04-18 17:11:22 +02:00
parent 95826cb14f
commit 4ac93a0933
12 changed files with 456 additions and 0 deletions
+27
View File
@@ -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
}
+41
View File
@@ -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.
+45
View File
@@ -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
}
+54
View File
@@ -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,
}
}
+42
View File
@@ -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.
+45
View File
@@ -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)
}
}
+43
View File
@@ -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.
+19
View File
@@ -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),
}
}
+41
View File
@@ -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`.
+33
View File
@@ -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.
+33
View File
@@ -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`.
+33
View File
@@ -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.