jfastframework
Documentación English PyPI GitHub

Rate limiting

Un endpoint de login sin rate limit es un blanco de credential stuffing. Este plugin es el límite, y vive en la aplicación y no en el edge.

[plugins]
enabled = ["observability", "cache", "auth", "ratelimit"]

[plugin.ratelimit]
limit = 100
window = 60.0

Apagado salvo que lo actives. Un límite es una decisión de política con una respuesta equivocada para alguien, y un servicio que empieza a rechazar tráfico porque un default se encendió solo es peor que uno sin límite.

Este es el limiter de capa de aplicación, no un reemplazo del edge

Un limiter en el edge — Caddy, un ingress, Cloudflare — ve direcciones y rutas. Este ve lo que el edge no puede:

Los dos son complementarios y quieres los dos. El edge frena una inundación volumétrica antes de que llegue a un worker; este evita que un cliente se gaste la cuota de otro, y es la única capa que puede distinguir entre ambas cosas. Fíjate en el límite hacia el otro lado: la aplicación del límite es una dependencia por ruta, así que un request que no matchea ninguna ruta — una inundación de 404, un escaneo — no queda limitado acá. Eso le toca al edge.

Algoritmo: token bucket, evaluado dentro de Redis

Una ventana fija deja que alguien gaste una cuota completa a las 11:59:59 y otra a las 12:00:00, así que el techo real es el doble del configurado en cada frontera. Un token bucket no tiene frontera. Se rellena de forma continua a limit / window tokens por segundo, y su tamaño de ráfaga es una perilla explícita en lugar de un accidente de dónde cae el reloj. Además cuesta un hash por identidad, mientras que un log de ventana deslizante cuesta una entrada por request.

Ajuste Significado
limit Requests por ventana. También la tasa de recarga: limit / window por segundo.
window Segundos para rellenar por completo un bucket vacío.
burst Tamaño del bucket. Por defecto limit — una ventana entera puede llegar de golpe.

Por qué un script Lua y no GET y después SET

Dos requests que leen el mismo contador antes de que alguno escriba pasan los dos. Eso no es un entrelazado raro; es exactamente la carga para la que existe un limiter, y un limiter que se escapa bajo carga es decoración. Redis ejecuta un script hasta el final sin nada entrelazado, así que la lectura, la decisión y la escritura son un único paso indivisible:

local clock = redis.call('TIME')
local now = tonumber(clock[1]) + tonumber(clock[2]) / 1000000

local stored = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(stored[1])
-- refill, then spend, then store -- all before anyone else runs
tokens = math.min(burst, tokens + (now - ts) * rate)
if tokens >= cost then allowed = 1; tokens = tokens - cost end
redis.call('HSET', key, 'tokens', tokens, 'ts', now)

Dos detalles que no son incidentales:

El bucket está en Redis y no en el proceso porque un contador en memoria está mal en el momento en que hay dos workers, y tanto jfast dev como el compose generado levantan más de uno. La conexión viene del plugin cache — de ahí requires = ["cache"] — en vez de abrir un segundo pool contra el mismo servidor.

Sobre qué se aplica el límite

key_sources es un orden de especificidad, y gana la primera fuente que responde.

Fuente Clave Notas
principal sub:<subject> Del token verificado. La respuesta correcta cuando existe.
api_key key:<prefijo sha256> Apagada salvo que definas api_key_header.
tenant tenant:<id> Tráfico anónimo, limitado por tenant.
ip ip:<dirección> El fallback.

Con la IP como clave, una oficina entera detrás de un NAT es un solo llamante. Con el sujeto como clave, una cuenta comprometida no puede gastarse la cuota de sus vecinos. Caer al tenant limita el tráfico anónimo por tenant en vez de globalmente — lo que también significa que un llamante abusivo puede gastarse la cuota anónima de ese tenant. Saca tenant de key_sources donde ese trade sea el equivocado.

api_key está apagada por defecto a propósito. Un header sin verificar lo elige quien llama, y un llamante que puede elegir su propio bucket no tiene ningún límite. Define api_key_header solo donde algo aguas arriba ya verificó la clave.

La IP, detrás de un proxy

Detrás de un balanceador cada request lleva la dirección del balanceador, así que un limiter con el peer del socket como clave le da a todo internet un único bucket compartido.

Este plugin nunca parsea X-Forwarded-For por su cuenta. El header lo escribe el atacante hasta que algo que conoce la topología de proxies lo recorre desde la derecha y se detiene en el primer salto no confiable; un limiter que lo cree en crudo le regala a cada llamante un bucket nuevo por cada salto falsificado. El contrato es un solo atributo:

request.state.client_ip = "203.0.113.7"   # written by the trusted-proxy middleware

Hasta que eso esté puesto se usa el peer del socket, y un request que llega con headers de forwarding que nadie resolvió escribe un warning una vez, diciendo que todos los clientes detrás del proxy están compartiendo un bucket.

Costo por ruta

Un único límite global o queda demasiado apretado para las rutas baratas o demasiado suelto para las caras. Sobrescríbelo por ruta con una dependencia, con la misma forma que usa require_scopes:

from fastapi import Depends
from jfastframework.plugins.builtin.ratelimit import rate_limit

@router.post("/search", dependencies=[Depends(rate_limit(10, 60))])
async def search(): ...

# One combined budget across a family of expensive endpoints.
@router.post("/embed", dependencies=[Depends(rate_limit(30, 60, scope="vectors"))])
async def embed(): ...

# Or keep the shared budget and charge more for this one.
@router.post("/reindex", dependencies=[Depends(rate_limit(100, 60, cost=25))])
async def reindex(): ...

Un override reemplaza el default de todo el servicio en esa ruta en vez de sumarse a él, y tiene su propio bucket: gastar el presupuesto de /search no toca el default. Las rutas que comparten un scope comparten presupuesto.

El default se instala en cada ruta de la API al arrancar, que es cuando los routers terminaron de montarse. Las rutas agregadas después de que la aplicación arrancó no lo reciben.

La respuesta

429, con un problem document — la misma forma application/problem+json que usa cualquier otro fallo en un servicio JFast:

HTTP/1.1 429 Too Many Requests
Content-Type: application/problem+json
Retry-After: 12
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 47

{
  "type": "about:blank",
  "title": "Too Many Requests",
  "status": 429,
  "detail": "Rate limit of 100 request(s) per 60s exceeded.",
  "instance": "/search",
  "limit": 100,
  "remaining": 0,
  "retry_after": 12,
  "request_id": "01J..."
}

RateLimit-Limit, RateLimit-Remaining y RateLimit-Reset también van en las respuestas exitosas, para que un cliente bien educado pueda bajar el ritmo antes de que lo rechacen. Reset son los segundos hasta que el bucket vuelve a estar lleno; Retry-After son los segundos hasta que se permitiría un request más, y nunca es 0 — decirle a un cliente rechazado que reintente de inmediato es lo único que no debe hacer.

Cuando Redis se cae: fail open

El limiter deja pasar el request, escribe un log en WARNING, y reporta el servicio degraded en /ready.

Fallar cerrado convierte una caída de la caché en una caída total: Redis parpadea y cada endpoint de la flota empieza a responder 429, incluidos los que nunca estuvieron cerca de un límite. Fallar abierto cuesta el límite mientras dure la caída. Se cambia un problema de disponibilidad por una ventana de abuso, y la ventana de abuso es la más barata de las dos.

Lo que hace defendible ese trade es que no es silencioso, porque "silenciosamente" es toda la objeción:

{
  "status": "degraded",
  "checks": {
    "ratelimit": {
      "healthy": false,
      "critical": false,
      "detail": "failing open: rate limit backend unreachable, no limit is being enforced",
      "meta": {"enforcing": false}
    }
  }
}

/ready sigue respondiendo 200 — el check no es crítico, porque sacar a cada réplica de rotación es justamente la caída que este diseño existe para evitar — pero dice degraded y nombra el motivo. Ponle una alerta.

Define fail_open = false en un servicio donde el límite importe más que el endpoint.

Ajustes

Clave Default Significado
limit 100 Requests por ventana.
window 60.0 Segundos para una recarga completa.
burst limit Tamaño del bucket.
key_prefix nombre de la app Namespace de claves en Redis.
key_sources ["principal", "api_key", "tenant", "ip"] Orden de especificidad.
api_key_header "" Apagado. Defínelo solo donde la clave se verifica aguas arriba.
fail_open true Ver arriba.
exempt_paths probes y docs /health, /ready, /info, /metrics, /docs, /redoc, /openapi.json

Los probes están exentos por defecto y deberían seguir así: un orquestador que consulta /ready cada dos segundos nunca puede quedar throttleado fuera de la flota.

Los overrides por entorno usan el prefijo JFAST_RATELIMIT_ — JFAST_RATELIMIT_LIMIT=500.