← Voltar ao blog

Microsoft Graph API — O que a documentação não diz sobre o rate limiting do OneNote

Ensky Lin7 min read

Estou a desenvolver o Note Bridge há algum tempo — uma ferramenta que migra cadernos do OneNote para o Notion. Inicialmente, assumi que a parte mais difícil seria a conversão de conteúdo — traduzir corretamente o HTML complexo do OneNote para o Notion. No final, lidar com o rate limiting da Microsoft acabou por levar ainda mais tempo.

Este não é um artigo do tipo "aqui está um snippet de backoff, cola e usa". A Graph API tem tantos casos extremos que um loop de reintento genérico não resolve — eventualmente é preciso modelar o sistema de limites, não apenas reagir aos 429. Eis o que aprendi.

1. Rate limits em 3 × 2 dimensões

O rate limiting do OneNote não é um teto simples. Tem três dimensões — por minuto, por hora e pedidos simultâneos — e cada uma é aplicada em dois âmbitos: por utilizador e por aplicação [1]. Multiplicando, são seis limites separados a respeitar, ou os 429 aparecem imediatamente.

Isto significa que a estratégia comum de backoff polinomial não basta: pode não estar a atingir o limite por minuto, mas a ultrapassar o teto por hora. Ou um único utilizador pode estar bem, mas o total de todos os utilizadores excede o limite global da aplicação. Cada dimensão precisa da sua própria contabilidade.

Dito isto, a experiência real mostra que os limites parecem mais generosos do que a documentação especifica. Mesmo assim, programamos conforme a especificação — se a Microsoft decidir aplicar estritamente, não queremos que nada se quebre.

2. Sem cabeçalho Retry-After

A maioria das APIs bem desenhadas inclui um cabeçalho Retry-After nas respostas 429 para que se saiba exatamente quando é seguro reintentar. Não é claro se se deve à complexidade do mecanismo, mas a API do OneNote no Graph não suporta isto.

Sem Retry-After, estratégias simples de backoff ficam aquém. Pode-se aumentar o tempo de espera após cada 429, mas não há garantia de que a próxima tentativa não seja também rejeitada. Para uma aplicação em produção, isto é um problema sério. A única solução robusta é implementar um rate limiter conforme a especificação da Microsoft — construímos o nosso com Cloudflare Durable Objects, rastreando a utilização em cada dimensão e implementando o nosso próprio Retry-After para consumo tanto do frontend como do backend.

3. Os dois cabeçalhos que quase ninguém usa

Depois de construir o limiter, descobrimos que o Graph de facto envia dois cabeçalhos de resposta que ajudam — são apenas fáceis de perder porque não estão no caminho dos 429.

  • x-ms-throttle-limit-percentage — presente em respostas bem-sucedidas. Indica quão perto se está de um limite (0,0–1,0). Acima de ~0,8 é zona amarela; a 1,0, o próximo pedido provavelmente receberá um 429.
  • x-ms-throttle-scope — presente em respostas 429. Os valores são User, Application, ou ambos. Indica qual dos dois âmbitos foi atingido.

Estes mudaram o design. O primeiro permite acionar um circuit breaker suave antes de receber um 429, em vez de esperar que o sistema avise. O segundo permite encaminhar o 429 para o breaker correto — se é um disparo de âmbito application, abrandar um utilizador não ajuda; é preciso abrandar todos. Se é de âmbito user, os outros tenants podem continuar normalmente.

Se apenas se reagir aos códigos de estado 429, está-se a operar meio às cegas num rate limiter conforme a especificação.

4. A API de lotes não ajuda realmente

O Microsoft Graph oferece um mecanismo de lotes para agrupar vários pedidos numa única chamada HTTP. Intuitivamente deveria contar como um único pedido, mas não conta. Cada pedido interno do lote é contabilizado individualmente contra os rate limits [2].

Isto torna o $batch praticamente inútil para o nosso caso de uso — no máximo poupa um pouco de tempo de round-trip.

Há também uma armadilha mais subtil. Um POST $batch pode ter sucesso com HTTP 200 no nível exterior, enquanto sub-respostas individuais internas devolvem 429, 502 ou 503. Observámos uma migração em produção a perder silenciosamente duas seções porque o nosso loop de reintento apenas reintentava o lote exterior — os erros 5xx das sub-respostas caíam no vazio. Se usar $batch, a estratégia de reintento tem de operar em cada sub-resposta, não no envelope.

5. A espiral da morte

O incidente mais doloroso que enfrentámos não foram os 429 — foi o que aconteceu depois dos 429. Um utilizador iniciou uma migração de 1.300 páginas. As primeiras 200 passaram sem problemas. Depois, um 429. O nosso consumer apanhou-o e pediu à Cloudflare Queues um reintento em 30 segundos. Até aqui razoável.

Mas como as 1.100 mensagens restantes já estavam enfileiradas, todas acordaram quase ao mesmo tempo após os 30 segundos, bateram de novo no rate limit e receberam 429. Cada nova vaga de 429 alargava ainda mais a janela de throttle. O gráfico mostrava um belo loop auto-reforçado: rate limit → reintentos → rate limit → mais reintentos. Durante meia hora, nenhuma página progrediu.

Duas correções fizeram a diferença:

  1. Um circuit breaker real no limiter, não em cada ponto de chamada. Quando o upstream devolve 429 de forma sustentada, o limiter dispara e todos os callers recebem um 429 local durante uma janela de arrefecimento. Isto colapsa N loops de reintento independentes numa única espera partilhada, em vez de N callers a fazer cada um 30 reintentos × 5 minutos.
  2. Separação de primeiro plano e segundo plano. O Note Bridge tem duas cargas que acedem ao Graph: análises interativas de cadernos (um utilizador está à espera) e trabalhos de migração em segundo plano (ninguém olha para o ecrã). Se uma migração de 1.300 páginas esgota o orçamento de rate, o utilizador que tenta analisar o seu caderno espera para sempre. A nossa solução são dois "lanes" de rate limiter com o lane de segundo plano limitado a ~50% do orçamento. O primeiro plano tem sempre margem.

6. O subtil bug do retryAfterMs

Vale a pena destacar porque demorámos a perceber. Quando o limiter verifica duas camadas (per-user e per-app) e ambas estão saturadas, que retryAfterMs deve devolver ao caller?

Tínhamos Math.min(userRetry, appRetry) — devolver a espera mais curta, parece simpático. Estava errado. Se o âmbito user diz "espera 2 segundos" mas o âmbito app diz "espera 30 segundos", reintentar após 2 segundos dá outro 429 imediato. A resposta correta é Math.max(userRetry, appRetry) — o pedido só é seguro quando ambas as camadas têm capacidade. Uma correção de um carácter que acabou com toda uma categoria de reintentos fantasma.

Conclusão

A maioria dos tutoriais de rate limiting termina em "reintentar com backoff exponencial e jitter". Para o Microsoft Graph isso não basta. É preciso:

  • Modelar a matriz de limites 3 × 2 em vez de adivinhar.
  • Ler x-ms-throttle-limit-percentage e acionar um breaker suave antes de receber um 429.
  • Encaminhar os 429 usando x-ms-throttle-scope para que disparos de utilizador não penalizem toda a app (e vice-versa).
  • Reintentar dentro das sub-respostas de $batch, não apenas no envelope.
  • Usar um circuit breaker partilhado, ou as tempestades de reintentos vão durar mais que a própria janela de throttle.
  • Separar primeiro plano de segundo plano para que a UX interativa não fique sem recursos.

A falta de Retry-After no Microsoft Graph é a queixa mais visível, mas a lição mais profunda é que rate limiting é um sistema, não um cabeçalho. Construa-o como tal.

Note Bridge migra cadernos do OneNote para o Notion.

Pronto para migrar suas notas?

Experimente o Note Bridge grátis — migre até 20 páginas sem cartão de crédito.

Comece grátis