DEV Community

Alexis Román
Alexis Román

Posted on

El contador que sobrevive a su ventana

Un PR en LiteLLM destapó un defecto de metrología —un sensor cuya marca de tiempo no
declara su origen— y de paso algo peor: los dos únicos veredictos que recibió fueron de bots.

Medición: 2026-11-10 · sujeto BerriAI/litellm · PR #50808 @ 18:09Z


1. El defecto

parallel_request_limiter_v3.py mantiene un espejo local de los contadores de rate limit,
además de Redis. El espejo guarda la entrada con un TTL anclado en la escritura, no en el
inicio de la ventana. Consecuencia: la entrada sobrevive a su propia ventana hasta
window_size segundos.

Leída tal cual, esa cifra sigue respondiendo OVER_LIMIT después de que la ventana giró.
El código original hacía exactamente eso, en tres sitios:

# antes
return self._local_counter_states(counters)
Enter fullscreen mode Exit fullscreen mode

El parche introduce una lectura que declara ausente al contador de una ventana ya expirada:

expired = window_start is not None and (now_int - int(window_start)) >= self.window_size
states.append((window_start, None) if expired else (window_start, counter_value))
Enter fullscreen mode Exit fullscreen mode

Y lo aplica en los tres caminos: la comprobación local previa dentro de
should_rate_limit, y los dos fallbacks a espejo local de
_read_counter_values_without_incrementing (sin Redis, y cuando el script Lua falla).

La clave del arreglo es qué no hace: no resetea nada. El giro de ventana pertenece al script
Lua y a _in_memory_cache_sliding_window; el espejo local sólo lee. Un espejo que además
reseteara tendría dos dueños del mismo estado, y ese es otro bug esperando su turno.

2. Por qué esto es metrología, no lógica

El contador no mentía. El número era correcto: habían entrado 2000 tokens. Lo que estaba mal era
la ventana a la que el número se atribuía. Dos orígenes de tiempo distintos —la escritura y el
inicio de ventana— colapsados en una sola variable.

Es la misma clase de defecto que un instrumento de laboratorio que marca la hora del muestreo en
lugar de la hora del evento: la lectura es exacta, el eje está desplazado, y toda conclusión que se
saque de ella está desplazada también.

El sentido del fallo importa. Un contador rancio no deja pasar tráfico que debería bloquear:
bloquea tráfico que ya está dentro de cuota. Es un falso rojo que te auto-deniega el servicio, con
la agravante de que el cliente recibe un 429 con una ventana que ya expiró. Nadie va a poder
reproducirlo mirando el 429: el estado que lo causó ya no existe cuando llega el reclamo.

3. La prueba que lo falsa (y la que lo habría dejado pasar)

tests/unit/proxy/hooks/test_parallel_request_limiter_v3.py añade
test_stale_local_counter_does_not_block_after_window_roll. Tres momentos:

momento aserción qué falsa
tras consumir 2000 de 1000 en ventana de 60 s read_only → OVER_LIMIT que el arreglo sea «ignorar el espejo local»
advance(61) overall_code == "OK" que el contador rancio siga bloqueando
después del giro limit_remaining == 999 y contador local == 1 que el giro efectivamente reseteó y admitió una unidad

La primera fila es la que casi nadie escribe. Un arreglo que devolviera [] en el espejo local
pasaría las filas 2 y 3 —y también habría roto el rate limiting por completo—. La aserción
OVER_LIMIT dentro de la ventana es la que exige que la deuda se cobre antes de condonarla.

Regla general: toda prueba de un arreglo de «esto ya no debería pasar» necesita además una
aserción de que lo que sí debía pasar sigue pasando. Sin ella, el test verde es compatible con
haber borrado la funcionalidad.

4. El estado del PR, medido con fecha

campo lectura @ 2026-11-10T18:09Z
estado OPEN, base main
edad 15 h 52 min (a las 11:38Z eran 9 h 21 min)
diff +60 / −5, 2 archivos, 1 commit
labels 0
reviews 0
review requests 0
comentarios 2 — CLAassistant @02:17:01Z y veria-ai[bot] @02:17:08Z
humanos que comentaron 0
mergeable true @11:38Z → UNKNOWN @18:09Z

Dos detalles de esa tabla merecen nombrarse.

El primero: la edad del PR es una medición que caduca. Cualquier titular que escriba «el parche
de 9 horas» es verdad durante unas horas y falso para siempre. Si un número no se puede volver a
medir igual, va con su marca de tiempo o no va.

El segundo: mergeable pasó de true a UNKNOWN en siete horas sin que nadie tocara la rama.
Ese campo es una caché de GitHub, no un veredicto. Es, otra vez, un sensor cuyo valor no es estado
del objeto. Quien mergee leyendo ese campo está leyendo la caché.

5. Los dos veredictos

A los 6 segundos del alta, CLAassistant registró el estado de la firma. Es un gate, no una
revisión: tiene nombre y juicio, pero no lee el código.

A los 13 segundos, veria-ai[bot] emitió un veredicto de seguridad sobre el diff. En un PR cuyo
contenido es un cambio de aritmética temporal en un limitador de cuota, un bot certificó «sin
problemas de seguridad» sin inspeccionar la propiedad que el cambio toca: ¿qué pasa si
window_start es None? ¿si el reloj retrocede? ¿si window_size es menor que la latencia entre
las dos lecturas de now_int?

Ninguna de esas preguntas la hace un bot que mira el diff. Y ninguna la hizo ningún humano, porque
no hubo ninguno.

Esto no es un reproche a LiteLLM, que no tiene obligación de revisar el PR de un desconocido en 16
horas. Es una observación sobre el estado de la señal: en un repositorio de ~30 000 estrellas, el
único veredicto automatizado que existe sobre un cambio de lógica es un gate de firma y un
«sin problemas de seguridad» emitido por un servicio cuyo criterio no es auditable desde el PR.
Un verde así no distingue «revisado y limpio» de «no revisado». Vale lo mismo que un 0 que
significa «no se midió».

La consecuencia práctica para quien mantiene esto: el número de revisiones humanas de un PR es
una métrica que conviene medir explícitamente
, porque su valor por defecto ya no es 0 de forma
obvia —está decorado con comentarios, labels y checks—. Un PR sin humanos se ve, en la interfaz,
igual de «atendido» que uno revisado.

6. Nota al pie: por qué no auditamos Gitleaks

El impulso natural, teniendo un censo de umbrales numéricos, es pasárselo a un escáner de secretos:
es el dominio donde un umbral mal puesto cuesta caro. La medición dice que no.

Gitleaks está escrito en Go. Su árbol tiene un solo archivo .py, de 769 bytes: un hook de
pre-commit. El universo del censo —archivos Python— está vacío. Un censo que devuelve cero
hallazgos ahí no midió nada; y publicar ese cero como «limpio» sería el falso verde de manual.

El escaneo de secretos, que sí es agnóstico al lenguaje, tampoco transfiere. Su clase positiva
—cadenas con forma de credencial— es el fixture del propio sujeto: config/gitleaks.toml es
el recetario del detector y coincide con lo que el detector busca. Sobre los objetos de su
historia, 777 de 6 057 blobs casan con el patrón (12,8 %), y el grueso de esos aciertos son las
versiones sucesivas de su propio fichero de reglas. En el repositorio donde el patrón se escribió,
el mismo patrón dispara 0–4 veces.

La lección, transferible: un instrumento sólo es admisible en un sujeto si la clase que busca es
rara en ese sujeto
. Cuando la clase positiva es el fixture, el instrumento mide el recetario.
Y ahí la respuesta correcta es NO_MEDIDO, no «sin hallazgos».

7. Tres reglas

  1. Toda marca de tiempo declara su origen. Un TTL anclado en la escritura y una ventana anclada en el inicio de la ventana son dos relojes. Si el código los usa indistintamente, el error no está en el cálculo: está en el eje.
  2. Un sensor que sobrevive a su sujeto no es un sensor. Si la entrada vive más que el objeto que describe, la lectura correcta de «expirado» es ausente, no cero —y desde luego no el último valor conocido.
  3. Un verde no dice quién lo emitió. Antes de leer un check como señal, pregunta si su autor podía fallar. Un gate de firma y un bot que nunca ha bloqueado nada son, para efectos de confianza, el mismo objeto.

Sujeto: BerriAI/litellm PR #50808 («fix(rate-limit): stop a stale counter blocking after its
window rolled»), issue #47340. Todas las cifras de este texto se midieron el 2026-11-10 y se pueden
volver a medir con gh pr view 50808 --repo BerriAI/litellm y gh pr diff 50808 --repo
BerriAI/litellm
. La edad del PR, mañana, será otra.

Top comments (0)