merge: issue/0016-rate-limiting — token bucket rate limiter (6 fns, 3 tipos)

This commit is contained in:
2026-04-18 17:33:07 +02:00
23 changed files with 1163 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.
@@ -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)
}
})
}
+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
}
+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")
}
})
}
+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.
@@ -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")
}
})
}
+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.
@@ -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
})
}
+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`.
+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))
}
})
}
+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.