Saltar al contenido principal

Límites de tasa

Hay dos límites independientes, y se aplican en este orden:

  1. Por IP de cliente — 100 requests / 60 s, verificado antes siquiera de leer tu clave de API.
  2. Por tenant, por endpoint — verificado después de la autenticación.

Exceder cualquiera de los dos devuelve 429 con el código de error rate_limited. La mayoría de los integradores solo lee la segunda tabla y después no puede explicar sus 429, así que empezá por la primera.

Capa 1 — por IP

AlcanceLímiteVentana
Una IP de cliente, a lo largo de todas las rutas con límite100 requests60 s

Aplica a todos los endpoints que aceptan tráfico desde el dispositivo del usuario:

  • POST /v1/verify/init
  • PUT /v1/verify/upload/:verificationId/:role
  • GET /v1/verify/challenge/:verificationId/next
  • POST /v1/verify/submit
  • GET /v1/verify/:id

No aplica a GET /health ni a las requests de preflight OPTIONS.

La IP se toma del header cf-connecting-ip de Cloudflare — la dirección real del cliente, no la de cualquier proxy que pongas delante de tu propia app.

Por qué esta capa corre antes de la auth

Autenticar cuesta una lectura de KV. Si una avalancha no autenticada tuviera que autenticarse antes de poder ser rechazada, esa avalancha igual nos costaría el lookup. Por eso el contador por IP corre primero.

La consecuencia para vos vale la pena decirla sin vueltas: un 429 puede llegar en una request cuya clave de API nunca fue verificada. No es evidencia de que tu clave sea válida, y no es atribuible a ningún tenant. Si estás depurando un 429 y tus números por tenant están lejísimos de los límites de abajo, esta es la capa que estás tocando.

Capa 2 — por tenant, por endpoint

EndpointLímiteVentana
POST /v1/verify/init60 requests60 s
POST /v1/verify/submit30 requests60 s
GET /v1/verify/:id600 requests60 s

Cada endpoint tiene su propio contador. Gastar tu presupuesto de init no toca tu presupuesto de status.

PUT /v1/verify/upload/... y GET /v1/verify/challenge/.../next no tienen ningún límite por tenant — se autentican con un token de subida por verificación, no con una clave de API, así que no hay tenant contra el cual contar. Se rigen únicamente por el límite por IP. Justamente por eso el límite por IP importa más de lo que parece.

Hoy estos límites no son ajustables por tenant

Los números de arriba están fijos en las definiciones de rutas del Worker. No hay campo de override por tenant, así que "te subimos el límite" no es algo que soporte pueda hacer sin desplegar. Si tu volumen genuinamente necesita más, decilo temprano — pero planificá contra estos números, no contra una excepción prometida.

La ventana es fija, no deslizante

El contador agrupa por minuto de reloj (floor(unix_seconds / 60)), no por 60 segundos rodantes desde tu primera request. Dos consecuencias prácticas:

  • El reset es al comienzo del minuto. Si tocás el límite a las 12:04:59, quedás desbloqueado un segundo después, no 60 segundos después.
  • Una ráfaga puede cruzar el borde. 100 requests a las 12:04:59 más 100 a las 12:05:00 se permiten ambas — 200 requests en dos segundos, todas legales. No armes una prueba de carga que concluya que el límite es 200; no armes un cliente que dependa de poder hacer eso.

Cómo se ve un 429

{
"error": "rate_limited",
"message": "Rate limit exceeded — retry later",
"requestId": "9f511d92ac11236d-SJC",
"detail": {
"retry_after": 60
}
}

El mismo valor está en el header de respuesta Retry-After, en segundos. Leé el header en lugar de hardcodear una demora:

if (response.status === 429) {
const waitSec = Number(response.headers.get('Retry-After') ?? 60);
await new Promise(r => setTimeout(r, waitSec * 1000));
// después reintentá — ver más abajo la nota sobre qué capa tocaste
}

rate_limited es uno de los pocos errores de Veridia que genuinamente vale la pena reintentar. Los otros son backend_unavailable (503) e internal_error (500). Todo lo demás es un error del cliente y reintentarlo solo lo reproduce. Ver Errores.

El problema de CGNAT — leé esto antes de salir a producción con móvil

Este es el modo de falla que más vemos, y no es obvio a partir de las tablas de arriba.

Una verificación no es una request. Contá lo que un solo usuario realmente gasta del presupuesto por IP:

FlujoRequests desde el dispositivo del usuario
Estándar (frente del documento + selfie)~4: init, 2 subidas, submit
Estándar con dorso del documento~5
Con activeLiveness: true~26: init, 3 subidas de documento/selfie, 1 subida de ancla de prueba de vida, 16 subidas de frames de prueba de vida (4 pasos x 4 frames), ~4 llamadas de reto a /next, submit

El número de la prueba de vida activa es el que muerde. El reto son 4 pasos de pose con una ráfaga de 4 frames cada uno, y cada frame es su propio PUT. Eso es por diseño — los frames son la base sobre la que se construye la verificación anti-inyección — pero significa que una verificación con prueba de vida cuesta aproximadamente un cuarto del presupuesto por IP.

Ahora poné varios usuarios detrás de una misma IP de salida. Ese es el caso normal en Latinoamérica: el carrier-grade NAT (CGNAT) pone a miles de abonados móviles detrás de un puñado de direcciones públicas compartidas. Las oficinas corporativas, las redes universitarias y el Wi-Fi público hacen lo mismo.

La aritmética:

  • Flujo estándar: alrededor de 20 usuarios concurrentes por minuto por IP compartida antes de que alguien vea un 429.
  • Prueba de vida activa: alrededor de 3 usuarios concurrentes por minuto por IP compartida.

Cuando pasa, falla en medio de la captura — el usuario ya fotografió su documento y le están diciendo que algo salió mal. Y como es la capa por IP, le va a pasar a usuarios que comparten dirección con la sesión de otra persona, lo que hace que parezca aleatorio.

Preferimos que lo sepas a que lo descubras. Es un techo real del diseño actual, no una perilla de ajuste que nos olvidamos de subir.

Qué hacer al respecto

  • No reintentes las subidas de forma agresiva. Un cliente que reintenta tres veces un PUT fallido convierte el 429 de un usuario en cuatro, y empuja a la IP compartida más allá del límite. Reintentá una vez, con la demora de Retry-After, y después mostrá el error.
  • Manejá rate_limited en el evento de error del widget y mostrá un mensaje tipo "la red está ocupada, probá de nuevo en un momento" en lugar de una falla genérica. El próximo intento del usuario muy probablemente funcione, porque la ventana se resetea al comienzo del minuto.
  • Activá activeLiveness donde justifique su costo, no en todos lados. Es la señal anti-inyección más fuerte disponible, y también es 6x el volumen de requests. Onboarding de alto valor: sí. Reverificación de bajo riesgo: probablemente no.
  • Hacé polling desde tu servidor, no desde el navegador. GET /v1/verify/:id requiere una clave secreta de todos modos, así que ya es del lado del servidor — lo que significa que su presupuesto de 600/minuto se gasta desde la IP de tu servidor, no desde la de tus usuarios. Mantenelo así.
  • Contanos la forma de tu tráfico antes del lanzamiento si esperás volumen móvil concentrado. Hoy no podemos subir el límite por tenant, pero preferimos planificar el despliegue con vos a leerlo en un incidente.

¿Qué capa toqué?

El body del 429 es idéntico para las dos, así que usá esto en su lugar:

SíntomaCapa
Estás muy por debajo de 60 init/min para todo el tenant, pero fallan usuarios individualesPor IP. Varios usuarios comparten una dirección de salida.
429 en PUT .../upload/... o en el endpoint de retoPor IP, siempre — esas rutas no tienen límite de tenant.
El 429 llega incluso con una clave de API revocada o malformadaPor IP — la clave nunca fue verificada.
Tu propio servidor, una IP, llamando a GET /v1/verify/:id en un loop de polling apretadoPodría ser cualquiera de las dos. 100/min por IP muerde mucho antes que 600/min por tenant.
init masivo desde tu backend, por encima de 60/minPor tenant.

Prestá atención a la cuarta fila: si hacés polling desde un solo servidor, el límite por IP de 100 es el techo efectivo del polling, no los 600 de la tabla de tenant. Un intervalo de polling de 500 ms son 120 requests/minuto y va a ser limitado. Hacé polling a 1 s o más lento, o usá webhooks y dejá de hacer polling.

Mantenerse por debajo de los límites

  • Preferí webhooks antes que polling. Un webhook son cero requests. Un polling de 30 segundos a 1 Hz son 30.
  • Aplicá backoff al intervalo de polling. Empezá en ~1 s y crecé; un veredicto típicamente llega en 2-3 segundos, así que un loop fijo y apretado gasta presupuesto mayormente en la cola.
  • Nunca reintentes un 4xx que no sea 429. invalid_body, verification_not_found, secret_key_required e insufficient_credits van a devolver la misma respuesta siempre, y cada reintento igual cuenta contra los dos límites.
  • No llames a /init especulativamente. Llamalo cuando el usuario realmente arranca el flujo. Un init por vista de página es una forma fácil de gastar 60/minuto en gente que nunca abre la cámara.

Qué sigue

  • Errores — el catálogo completo de errores, incluyendo cuáles códigos son reintentables
  • Autenticación — tipos de clave, y por qué el endpoint de resultados es solo del lado del servidor
  • Webhooks — la forma de dejar de hacer polling por completo