Rate limiting en Django: el middleware que aplico en producción · Daniel Tinizaray

Rate limiting en Django: el middleware que aplico en producción · Daniel Tinizaray

Cuando un sitio público empieza a recibir bots, scrapers y probes, el límite de tasa deja de ser tema de diseño y se vuelve tema de supervivencia. En un directorio Django que opero detrás de Cloudflare implementé un middleware propio de rate limiting por IP con bans compartidos en cache. Esta es la configuración que quedó en producción, el código real y los trade-offs que no aparecen en los tutoriales: monitores de uptime que comparten IP de egress, bots que se esconden detrás de la IP de infraestructura y workers de gunicorn que no comparten memoria.

Por qué no me sirvió el throttling que ya traía Django

Django REST Framework trae un sistema de throttling por clase (AnonRateThrottle, UserRateThrottle), pero su ámbito es la vista de DRF, no el request completo. Yo necesitaba cortar tráfico antes de que tocara la aplicación: scripts de scraping que golpeaban rutas públicas, bots de IA probando endpoints y crawlers sin respetar robots.txt. Además, la app corre con gunicorn en varios workers: cualquier contador en memoria de un worker ignora lo que hicieron los otros. Mi respuesta fue un middleware a nivel de MIDDLEWARE de Django, el primer eslabón después del routing, con dos niveles de almacenamiento: una ventana por IP en memoria del proceso y los bans en django.core.cache, compartidos entre workers.

El middleware en producción

La estructura es una ventana deslizante simple: por cada IP guardo timestamps de la última minuto y si supera el límite, baneo la IP en cache por una hora. El banco de constantes quedó así:

# TTL del ban en segundos (1 hora)
BAN_TTL = 3600
# Ventana de rate limit en segundos (60 segundos)
RATE_WINDOW = 60
# Límite por defecto de requests por ventana
DEFAULT_REQUEST_LIMIT = 50

Cincuenta requests por minuto por IP es generoso para un humano navegando un directorio y suficientemente estrecho para un scraper serial. El ban de una hora es la parte que da músculo: no basta con frenar la ráfaga, el abusador queda fuera durante 3600 segundos y ese estado sobrevive a reload del proceso:

# Verificar ban en cache (compartido entre workers, con TTL)
if cache.get(f"rl:ban:{client_ip}"):
    return HttpResponse("Forbidden due to excessive requests", status=429)

# Rate limit por IP
now = time.time()
timestamps = self.clients[client_ip]
request_limit = int(os.environ.get("REQUEST_LIMIT", DEFAULT_REQUEST_LIMIT))

# Eliminar timestamps fuera de la ventana
while timestamps and timestamps[0] < now - RATE_WINDOW:
    timestamps.pop(0)

if len(timestamps) > request_limit:
    # Banear en cache con TTL de 1 hora
    cache.set(f"rl:ban:{client_ip}", True, BAN_TTL)
    return HttpResponse("Forbidden due to excessive requests", status=403)

timestamps.append(now)
return self.get_response(request)

El orden de las excepciones es el diseño real

El __call__ del middleware es una cadena de excepciones en orden estricto, y ahí vive la mayor parte del aprendizaje. Primero localhost y la whitelist de IPs de entornos de monitoreo; después los user-agents baneados por configuración; recién entonces la ventana por IP. Un detalle que me salvó de banear mi propia infraestructura: los user-agents bloqueados se evalúan antes del skip de IPs de Cloudflare. Un bot con UA identificable que llega desde una IP de Cloudflare se rechaza por UA, pero la IP de infraestructura no entra al conteo ni se banea:

# Bloquear user-agents baneados ANTES del skip de Cloudflare
if any(bot in user_agent for bot in self.banned_user_agents):
    return HttpResponse("Too Many Requests", status=429)

# Saltar rate-limit y bans para IPs de Cloudflare (infraestructura).
# La IP real del cliente se obtiene de X-Forwarded-For en ip_utils.
if client_ip and es_ip_cloudflare(client_ip):
    return self.get_response(request)

Como todo el tráfico llega por Cloudflare, la IP que gunicorn ve es del edge; la real está en X-Forwarded-For. Verificar que la IP vista pertenece al rango de Cloudflare es mi control de integridad: si alguien golpea el origen directamente (bypass del edge), esa IP cae en la ventana y se banea como cualquier otra.

Lo que rompió: el monitor de uptime baneado

El primer falso positivo fue el más incómodo: mi propio monitor de disponibilidad. El servicio de uptime sondea cada ~5 minutos (12 requests/hora) pero sale por IPs de egress compartidas por miles de usuarios del mismo servicio. Cuando una de esas IPs compartidas acumuló tráfico de otros clientes del servicio de monitoreo, mi middleware la baneó una hora y el alertador marcó el sitio caído aunque estaba perfectamente sano. El fix fue doble: whitelist explícita por user-agent y por IP, con un comentario en el código que explica por qué ese sondeo nunca debe caer en el límite:

# Permitir monitores de disponibilidad (UpTimeRobot) y whitelist de IPs.
# Sondea cada ~5 min (12/h); jamás debe caer en rate limit,
# aunque su IP de egress sea compartida por muchos monitores.
if "uptimerobot" in user_agent or client_ip in self.whitelist_ips:
    return self.get_response(request)

Googlebot también pasa sin restricciones: no le voy a poner fricción al crawler que más me conviene que visite. La whitelist por user-agent es fácil de falsificar, pero el trade-off me parece correcto: prefiero un scraper que se disfraza de Googlebot a perder indexación real por un límite demasiado agresivo.

Gestión de memoria: el buffer local no puede crecer sin techo

La ventana por IP vive en un defaultdict(list) del proceso. Sin control, cada IP única del mundo que pasa por el sitio deja una lista ahí para siempre. Agregé una limpieza periódica cada 5 minutos que descarta IPs sin actividad reciente en la ventana:

def _cleanup_if_needed(self, now: float) -> None:
    """Limpia entradas viejas de self.clients cada 5 minutos."""
    if now - self._last_cleanup < 300:
        return
    self._last_cleanup = now
    cutoff = now - RATE_WINDOW
    stale = [ip for ip, ts_list in self.clients.items()
             if not ts_list or ts_list[-1] < cutoff]
    for ip in stale:
        del self.clients[ip]

El trade-off aceptado: el estado de la ventana es local por worker, así que un atacante distribuido en 4 workers de gunicorn puede multiplicar su cuota por 4. Los bans en cache compensan a escala: cuando cualquiera de los workers detecta el exceso, la IP queda baneada para todos. Para el perfil de amenaza de un directorio público (bots seriales, no DDoS distribuido coordinado) ese compromiso me parece bien pagado: cero dependencias nuevas, sin Redis, usando el backend de cache que ya tenía.

Lo que aprendí

  • Los parámetros que quedaron en producción: 50 requests/minuto por IP, ban de 3600 segundos, limpieza del buffer cada 300 segundos. El límite es configurable por entorno con REQUEST_LIMIT, así que puedo endurecerlo sin deploy.
  • El falso positivo más caro no es el bot: es tu monitor de uptime. Cualquier rate limiting necesita whitelist de monitores antes de activarse; si no, tu primera alerta de caída será un auto-enganche.
  • El orden de excepciones es parte del contrato: UA baneado antes de skip Cloudflare, skip Cloudflare antes de ventana por IP. Cambiar el orden de dos líneas cambia el comportamiento del sistema completo.
  • Cache compartido > memoria local para estado de castigo: la ventana por IP puede ser local, pero el ban debe ser global entre workers y con TTL. Sin eso, cada worker aprende el abusador por su cuenta.
  • Verificar la IP del edge es un control, no un detalle: distinguir "IP de Cloudflare" de "IP del cliente" me permite dejar pasar la infraestructura legítima y al mismo tiempo banear quién intenta el bypass directo al origen.

El middleware completo vive en el código de producción del directorio y ha mantenido a raya scrapers y probes sin un solo falso positivo desde el incidente del monitor. Si trabajas con Django, AWS o Cloudflare y quieres ver más de este trabajo, pasa por danieltini.dev o mira el código en github.com/daniel-alejandro-t.


¿Te gustó este artículo?

Si estás lidiando con estos desafíos en tu empresa, hablemos. Sin compromiso. 30 minutos para entender tu situación.

Agenda una llamada gratuita →