← Torna al blog

Microsoft Graph API — Quello che la documentazione non dice sul rate limiting di OneNote

Ensky Lin7 min read

Sviluppo Note Bridge da un po' di tempo — uno strumento che migra i blocchi note di OneNote su Notion. All'inizio pensavo che la parte più difficile sarebbe stata la conversione dei contenuti — tradurre correttamente l'HTML complesso di OneNote in Notion. Alla fine, gestire il rate limiting di Microsoft ha richiesto ancora più tempo.

Questo non è un articolo del tipo "ecco uno snippet di backoff, incollalo e vai". La Graph API presenta abbastanza casi limite che un generico loop di retry non basta — alla fine bisogna modellare il sistema di limiti, non solo reagire ai 429. Ecco cosa ho imparato.

1. Rate limits su 3 × 2 dimensioni

Il rate limiting di OneNote non è un semplice tetto. Ha tre dimensioni — al minuto, all'ora e richieste simultanee — e ciascuna è applicata su due ambiti: per utente e per applicazione [1]. Moltiplicando si ottengono sei limiti separati da rispettare, altrimenti i 429 arrivano immediatamente.

Questo significa che la comune strategia di backoff polinomiale non è sufficiente: potresti non raggiungere il limite al minuto, ma superare il tetto orario. Oppure un singolo utente va bene, ma il totale di tutti gli utenti supera il limite globale dell'applicazione. Ogni dimensione richiede la propria contabilità.

Detto questo, l'esperienza sul campo mostra che i limiti reali sembrano più generosi di quanto specificato nella documentazione. Ma programmiamo comunque secondo la specifica — se Microsoft decidesse di applicarla rigorosamente, non vogliamo che le cose si rompano.

2. Nessun header Retry-After

La maggior parte delle API ben progettate include un header Retry-After nelle risposte 429 in modo da sapere esattamente quando è sicuro riprovare. Non è chiaro se sia dovuto alla complessità del meccanismo, ma l'API OneNote di Graph non lo supporta.

Senza Retry-After, le strategie semplici di backoff non bastano. Si può aumentare il tempo di attesa dopo ogni 429, ma non c'è garanzia che il tentativo successivo non venga anch'esso throttlato. Per un'applicazione in produzione, questo è un problema serio. L'unica soluzione robusta è implementare un rate limiter conforme alla specifica di Microsoft — abbiamo costruito il nostro con Cloudflare Durable Objects, tracciando l'utilizzo su ogni dimensione e implementando il nostro Retry-After per frontend e backend.

3. I due header che quasi nessuno usa

Dopo aver costruito il limiter, abbiamo scoperto che Graph in realtà invia due header di risposta utili — sono solo facili da non notare perché non compaiono nel percorso dei 429.

  • x-ms-throttle-limit-percentage — presente nelle risposte riuscite. Indica quanto si è vicini a un limite (0,0–1,0). Sopra ~0,8 è zona gialla; a 1,0 la prossima richiesta probabilmente riceverà un 429.
  • x-ms-throttle-scope — presente nelle risposte 429. I valori sono User, Application, o entrambi. Indica quale dei due ambiti è stato raggiunto.

Questi hanno cambiato il design. Il primo ci permette di attivare un circuit breaker soft prima di ricevere un 429, invece di aspettare che il sistema ce lo dica. Il secondo ci permette di instradare il 429 al breaker corretto — se è un'attivazione a livello application, rallentare un singolo utente non serve; bisogna rallentare tutti. Se è a livello user, gli altri tenant possono continuare normalmente.

Se si reagisce solo ai codici di stato 429, si sta operando il rate limiter mezzo alla cieca.

4. La Batch API non aiuta davvero

Microsoft Graph offre un meccanismo di batch per raggruppare più richieste in una singola chiamata HTTP. Intuitivamente dovrebbe contare come una singola richiesta, ma non è così. Ogni richiesta interna nel batch viene contata individualmente ai fini dei rate limits [2].

Questo rende $batch sostanzialmente inutile per il nostro caso d'uso — al massimo risparmia un po' di tempo di round-trip.

C'è anche una trappola più sottile. Un POST $batch può avere successo con HTTP 200 a livello esterno, mentre le singole sub-response interne restituiscono 429, 502 o 503. Abbiamo visto una migrazione in produzione perdere silenziosamente due sezioni perché il nostro loop di retry riprovava solo il batch esterno — gli errori 5xx delle sub-response andavano persi. Se si usa $batch, la strategia di retry deve operare su ogni sub-response, non sull'involucro.

5. La spirale della morte

L'incidente più doloroso che abbiamo affrontato non sono stati i 429 — è stato quello che è successo dopo i 429. Un utente ha avviato una migrazione di 1.300 pagine. Le prime 200 sono passate senza problemi. Poi un 429. Il nostro consumer l'ha intercettato e ha chiesto a Cloudflare Queues un retry dopo 30 secondi. Fin qui ragionevole.

Ma poiché tutti i 1.100 messaggi rimanenti erano già in coda, si sono tutti risvegliati quasi contemporaneamente dopo quei 30 secondi, hanno colpito di nuovo il rate limit e hanno ricevuto un altro 429. Ogni nuova ondata di 429 allungava ulteriormente la finestra di throttle. Il grafico mostrava un bel loop auto-rinforzante: rate limit → retry → rate limit → più retry. Per mezz'ora, nessuna pagina ha fatto progressi.

Due correzioni hanno fatto la differenza:

  1. Un circuit breaker vero nel limiter, non in ogni punto di chiamata. Quando l'upstream restituisce 429 in modo sostenuto, il limiter si attiva e tutti i caller ricevono un 429 locale per una finestra di raffreddamento. Questo riduce N loop di retry indipendenti in una singola attesa condivisa, invece di N caller ciascuno con 30 retry × 5 minuti.
  2. Separazione foreground e background. Note Bridge ha due carichi che accedono a Graph: analisi interattive dei blocchi note (un utente sta aspettando) e lavori di migrazione in background (nessuno guarda lo schermo). Se una migrazione di 1.300 pagine esaurisce il budget di rate, l'utente che cerca di analizzare il proprio blocco note aspetta all'infinito. La nostra soluzione sono due "corsie" di rate limiter con la corsia background limitata a ~50% del budget. Il foreground ha sempre margine.

6. Il sottile bug di retryAfterMs

Vale la pena segnalarlo separatamente perché ci è voluto un po' per notarlo. Quando il limiter controlla due livelli (per-user e per-app) ed entrambi sono saturi, quale retryAfterMs dovrebbe restituire al caller?

Avevamo Math.min(userRetry, appRetry) — restituire l'attesa più breve, sembra gentile. Era sbagliato. Se l'ambito user dice "aspetta 2 secondi" ma l'ambito app dice "aspetta 30 secondi", riprovare dopo 2 secondi produce un altro 429 istantaneo. La risposta corretta è Math.max(userRetry, appRetry) — la richiesta è sicura solo quando entrambi i livelli hanno capacità. Una correzione di un carattere che ha eliminato un'intera categoria di retry fantasma.

Conclusione

La maggior parte dei tutorial sul rate limiting finisce con "riprova con backoff esponenziale e jitter". Per Microsoft Graph non basta. Serve:

  • Modellare la matrice di limiti 3 × 2 invece di tirare a indovinare.
  • Leggere x-ms-throttle-limit-percentage e attivare un breaker soft prima di ricevere un 429.
  • Instradare i 429 usando x-ms-throttle-scope in modo che un'attivazione utente non penalizzi tutta l'app (e viceversa).
  • Fare retry dentro le sub-response di $batch, non solo sull'involucro.
  • Usare un circuit breaker condiviso, altrimenti le tempeste di retry dureranno più della finestra di throttle stessa.
  • Separare foreground e background affinché la UX interattiva non resti a secco.

La mancanza di Retry-After in Microsoft Graph è il problema più evidente, ma la lezione più profonda è che il rate limiting è un sistema, non un header. Costruiscilo come tale.

Note Bridge migra i blocchi note di OneNote su Notion.

Pronto a migrare le tue note?

Prova Note Bridge gratis — migra fino a 20 pagine senza carta di credito.

Inizia gratis