merge: issue/0016-rate-limiting — token bucket rate limiter (6 fns, 3 tipos)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"}`.
|
||||
@@ -0,0 +1,110 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRateLimiterByKey(t *testing.T) {
|
||||
base := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
keyFunc := func(r *http.Request) string {
|
||||
return r.Header.Get("X-API-Key")
|
||||
}
|
||||
|
||||
t.Run("aplica limit por la clave devuelta por keyFunc", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 2)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
// Agotar tokens de api-key-A
|
||||
for i := 0; i < 2; i++ {
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-A")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
}
|
||||
// Tercer rechazado
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-A")
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("status=%d, want 429", rec.Code)
|
||||
}
|
||||
|
||||
// key-B intacta
|
||||
recB := httptest.NewRecorder()
|
||||
reqB := httptest.NewRequest("GET", "/", nil)
|
||||
reqB.Header.Set("X-API-Key", "key-B")
|
||||
handler.ServeHTTP(recB, reqB)
|
||||
if recB.Code != http.StatusOK {
|
||||
t.Errorf("key-B status=%d, want 200", recB.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("key vacia salta el limit", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 1)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
// Sin X-API-Key, hagamos muchos requests
|
||||
for i := 0; i < 10; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("request %d status=%d, want 200 (sin key, sin limit)", i, rec.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("responde 429 con body JSON al exceder", func(t *testing.T) {
|
||||
rl := RateLimiterNew(1, 1)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
// Agotar
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-X")
|
||||
handler.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
// Rechazado
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("status=%d, want 429", rec.Code)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("body no es JSON: %v", err)
|
||||
}
|
||||
// HTTPError se serializa con campos PascalCase (sin tags JSON)
|
||||
if body["Code"] != "rate_limited" {
|
||||
t.Errorf("Code=%v, want rate_limited", body["Code"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("headers X-RateLimit-* siempre presentes en respuesta", func(t *testing.T) {
|
||||
rl := RateLimiterNew(10, 20)
|
||||
mw := RateLimiterByKey(rl, keyFunc)
|
||||
handler := mw(base)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
req.Header.Set("X-API-Key", "key-Y")
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("X-RateLimit-Limit") == "" {
|
||||
t.Error("X-RateLimit-Limit ausente")
|
||||
}
|
||||
if rec.Header().Get("X-RateLimit-Remaining") == "" {
|
||||
t.Error("X-RateLimit-Remaining ausente")
|
||||
}
|
||||
if rec.Header().Get("X-RateLimit-Reset") == "" {
|
||||
t.Error("X-RateLimit-Reset ausente")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user