← Volver al blog

Microsoft Graph API — Lo que la documentación no te cuenta sobre el rate limiting de OneNote

Ensky Lin7 min read

Llevo un tiempo desarrollando Note Bridge, una herramienta que migra cuadernos de OneNote a Notion. Al principio pensé que lo más difícil sería la conversión de contenido — traducir correctamente el HTML complejo de OneNote al formato de Notion. Al final, gestionar la limitación de tasa de Microsoft terminó llevándome aún más tiempo.

Este no es un artículo del estilo "pega este snippet de backoff y listo". La Graph API tiene tantos casos límite que un bucle de reintento genérico no te salva: tarde o temprano necesitas modelar el sistema de límites, no solo reaccionar a los 429. Aquí va lo que aprendí.

1. Limitación de tasa en 3 × 2 dimensiones

La limitación de tasa de OneNote no es un simple tope. Tiene tres dimensiones — por minuto, por hora y solicitudes simultáneas — y cada una se aplica en dos niveles: por usuario y por aplicación [1]. Multiplicadas, son seis límites distintos que hay que respetar; de lo contrario, los errores 429 aparecen de inmediato.

Esto significa que la típica estrategia de retroceso polinomial no basta: puedes estar dentro del límite por minuto pero superar el tope por hora. O un solo usuario puede ir bien, pero la suma de todos excede el límite global de la aplicación. Cada dimensión requiere su propia contabilidad.

Dicho esto, en la práctica los límites reales parecen más generosos que los documentados. Aun así, programamos según la especificación — si Microsoft decide aplicarla estrictamente algún día, no queremos que nada se rompa.

2. Sin cabecera Retry-After

Un diseño de API bien pensado incluye una cabecera Retry-After en las respuestas 429, para que el cliente sepa cuándo puede reintentar con seguridad. No queda claro si se debe a la complejidad del mecanismo, pero la API de OneNote en Graph no ofrece esta cabecera.

Sin Retry-After, las estrategias simples de retroceso se quedan cortas. Se puede aumentar el tiempo de espera tras cada 429, pero no hay ninguna garantía de que el siguiente intento no sea rechazado también. Para una aplicación en producción, esto es un problema serio. La única solución fiable es implementar un limitador de tasa conforme a la especificación de Microsoft — construimos el nuestro con Cloudflare Durable Objects, registrando el consumo en cada dimensión y generando nuestro propio Retry-After para que lo usen tanto el frontend como el backend.

3. Las dos cabeceras que casi nadie usa

Tras construir el limitador, descubrimos que Graph en realidad sí devuelve dos cabeceras de respuesta útiles — solo que es fácil pasarlas por alto porque no aparecen en la ruta de los 429.

  • x-ms-throttle-limit-percentage — presente en respuestas exitosas. Indica lo cerca que estás de un límite (0,0–1,0). Por encima de ~0,8 es zona amarilla; en 1,0 lo más probable es que la siguiente petición se lleve un 429.
  • x-ms-throttle-scope — presente en respuestas 429. Sus valores son User, Application o ambos. Te dice cuál de los dos ámbitos se ha tocado.

Estas dos cabeceras cambiaron el diseño. La primera nos permite activar un disyuntor suave antes de recibir un 429, en lugar de esperar a que el sistema nos avise. La segunda nos permite enrutar el 429 al disyuntor correcto: si es un disparo de ámbito application, ralentizar a un solo usuario no sirve, hay que ralentizar a todos. Si es de ámbito user, los demás inquilinos pueden seguir a su ritmo.

Si solo reaccionas al código de estado 429, estás operando tu limitador conforme a la especificación medio a ciegas.

4. La API de lotes no ayuda mucho

Microsoft Graph ofrece un mecanismo de lotes para agrupar varias solicitudes en una sola llamada HTTP. Intuitivamente debería contar como una única solicitud, pero no es así: cada solicitud interna del lote se contabiliza de forma individual contra los límites de tasa [2].

Esto convierte a $batch en algo prácticamente inútil para nuestro caso de uso — a lo sumo ahorra un poco de latencia de red.

Hay también una trampa más sutil. Un POST a $batch puede devolver HTTP 200 en el nivel exterior, mientras que sub-respuestas individuales internas devuelven 429, 502 o 503. Vimos cómo una migración en producción perdió en silencio dos secciones porque nuestro bucle de reintento solo reintentaba el lote exterior — los errores 5xx de las sub-respuestas se quedaban por el camino. Si usas $batch, la lógica de reintento debe operar sobre cada sub-respuesta, no sobre la envoltura.

5. La espiral de la muerte

El incidente más doloroso que sufrimos no fue por los 429, sino por lo que pasó después de los 429. Un usuario lanzó una migración de 1.300 páginas. Las primeras 200 fueron sin problema. Luego, un 429. Nuestro consumidor lo capturó y pidió a Cloudflare Queues un reintento en 30 segundos. Hasta aquí razonable.

Pero como los 1.100 mensajes restantes ya estaban encolados, todos despertaron casi a la vez tras esos 30 segundos, golpearon de nuevo el límite y volvieron a recibir 429. Cada nueva oleada de 429 alargaba aún más la ventana de throttling. La gráfica mostraba un bonito bucle autorreforzado: límite → reintentos → límite → más reintentos. Durante media hora, ni una sola página avanzó.

Dos ajustes terminaron marcando la diferencia:

  1. Un disyuntor de verdad en el limitador, no en cada punto de llamada. Cuando el upstream devuelve 429 de forma sostenida, el limitador se dispara y todos los llamadores reciben localmente un 429 durante una ventana de enfriamiento. Esto colapsa N bucles de reintento independientes en una sola espera compartida, en lugar de tener N llamadores ejecutando cada uno 30 reintentos × 5 minutos.
  2. Separación de primer plano y segundo plano. Note Bridge tiene dos cargas que llaman a Graph: escaneos interactivos de cuadernos (hay un usuario esperando) y trabajos de migración en segundo plano (nadie mira la pantalla). Si una migración de 1.300 páginas se come todo el presupuesto, el usuario que intenta escanear su cuaderno espera para siempre. Nuestra solución son dos "carriles" de limitador con el carril de segundo plano topado en torno al 50 % del presupuesto. El primer plano siempre tiene margen.

6. El sutil bug de retryAfterMs

Vale la pena mencionarlo aparte porque tardamos en verlo. Cuando el limitador comprueba dos capas (per-user y per-app) y ambas están saturadas, ¿qué retryAfterMs debe devolver al llamador?

Teníamos Math.min(userRetry, appRetry) — devolver la espera más corta, suena amable. Era incorrecto. Si el ámbito user dice "espera 2 segundos" y el ámbito app dice "espera 30 segundos", reintentar pasados 2 segundos te gana otro 429 al instante. La respuesta correcta es Math.max(userRetry, appRetry) — la solicitud solo es segura cuando ambas capas tienen capacidad. Una corrección de un carácter que acabó con toda una categoría de reintentos fantasma.

Conclusión

La mayoría de los tutoriales de rate limiting terminan en "reintenta con backoff exponencial y jitter". Para Microsoft Graph no basta. Necesitas:

  • Modelar la matriz de límites 3 × 2 en lugar de adivinar.
  • Leer x-ms-throttle-limit-percentage y disparar un disyuntor suave antes de recibir un 429.
  • Enrutar los 429 con x-ms-throttle-scope para que un disparo de un usuario no castigue a toda la app (y viceversa).
  • Reintentar dentro de las sub-respuestas de $batch, no solo en la envoltura.
  • Usar un disyuntor compartido o tus tormentas de reintentos durarán más que la propia ventana de throttling.
  • Separar primer plano y segundo plano para que la UX interactiva no se quede sin oxígeno.

La falta de Retry-After en Microsoft Graph es la queja más visible, pero la lección de fondo es que la limitación de tasa es un sistema, no una cabecera. Constrúyela como tal.

Note Bridge migra cuadernos de OneNote a Notion.

¿Listo para migrar tus notas?

Prueba Note Bridge gratis — migra hasta 20 páginas sin tarjeta de crédito.

Empieza gratis