← Zurück zum Blog

Microsoft Graph API — Was die Doku dir über OneNote-Ratenbegrenzung verschweigt

Ensky Lin6 min read

Seit einiger Zeit entwickle ich Note Bridge — ein Tool, das OneNote-Notebooks nach Notion migriert. Anfangs dachte ich, die Inhaltskonvertierung wäre die größte Herausforderung — das komplexe HTML aus OneNote korrekt in Notion-Blöcke zu übersetzen. Tatsächlich hat mich die Anfragebegrenzung von Microsoft am Ende deutlich mehr Zeit gekostet.

Das hier ist kein „kopier dir diesen Backoff-Snippet"-Artikel. Die Graph API hat genug Sonderfälle, dass eine generische Retry-Schleife dich nicht rettet — irgendwann musst du das Limit-System modellieren, statt nur auf 429er zu reagieren. Hier ist, was ich gelernt habe.

1. Anfragebegrenzung in 3 × 2 Dimensionen

Die Anfragebegrenzung bei OneNote ist keine einfache Obergrenze. Sie umfasst drei Dimensionen — pro Minute, pro Stunde und gleichzeitige Anfragen — und jede wird auf zwei Ebenen durchgesetzt: pro Nutzer und pro Anwendung [1]. Multipliziert ergibt das sechs separate Limits, die alle eingehalten werden müssen — sonst folgt sofort ein 429-Fehler.

Das bedeutet: Eine herkömmliche polynomiale Backoff-Strategie reicht nicht aus. Man kann unter dem Minutenlimit liegen und trotzdem das Stundenlimit überschreiten. Oder ein einzelner Nutzer ist unauffällig, aber die Summe aller Nutzer sprengt das anwendungsweite Limit. Jede Dimension braucht ihre eigene Buchführung.

In der Praxis zeigt sich allerdings, dass die tatsächlichen Grenzen großzügiger sind als in der Dokumentation angegeben. Trotzdem programmieren wir strikt nach Spezifikation — falls Microsoft eines Tages strenger durchsetzt, wollen wir vorbereitet sein.

2. Kein Retry-After-Header

Gängiges API-Design liefert bei 429-Antworten einen Retry-After-Header mit, damit der Client weiß, wann er gefahrlos erneut anfragen kann. Ob es an der Komplexität des Mechanismus liegt, ist unklar — jedenfalls bietet die OneNote-Graph-API diesen Header nicht an.

Ohne Retry-After greifen einfache Backoff-Strategien zu kurz. Man kann die Wartezeit nach jedem 429 erhöhen, hat aber keine Gewähr, dass der nächste Versuch nicht ebenfalls gedrosselt wird. Für eine Produktionsanwendung ist das ein ernsthaftes Problem. Die einzige zuverlässige Lösung ist ein eigener Rate-Limiter nach Microsoft-Spezifikation — wir haben ihn mit Cloudflare Durable Objects umgesetzt, erfassen die Nutzung über alle Dimensionen hinweg und stellen einen eigenen Retry-After-Wert bereit, den sowohl das Frontend als auch das Backend nutzen.

3. Die zwei Header, die fast niemand nutzt

Nachdem wir den Limiter gebaut hatten, entdeckten wir, dass Graph tatsächlich zwei hilfreiche Response-Header mitliefert — sie liegen nur nicht auf dem 429-Pfad und werden daher leicht übersehen.

  • x-ms-throttle-limit-percentage — erscheint in erfolgreichen Antworten. Er sagt dir, wie nah du am Limit bist (0,0–1,0). Ab etwa 0,8 ist gelbe Zone; bei 1,0 wird die nächste Anfrage sehr wahrscheinlich ein 429.
  • x-ms-throttle-scope — erscheint in 429-Antworten. Werte sind User, Application oder beides. Er verrät dir, welcher Scope ausgelöst wurde.

Beide Header haben unser Design verändert. Der erste lässt uns einen Soft Circuit Breaker auslösen, bevor wir ein 429 bekommen — statt darauf zu warten, dass das System es uns sagt. Der zweite erlaubt uns, 429er an den richtigen Breaker zu routen — bei einem Application-Scope-Trip hilft es nichts, nur einen Nutzer zu drosseln, alle müssen runter. Bei einem User-Scope-Trip dürfen andere Mandanten ungebremst weiterlaufen.

Wer nur auf 429-Statuscodes reagiert, betreibt seinen spezifikationskonformen Rate-Limiter im Halbblindflug.

4. Die Batch-API bringt wenig

Microsoft Graph bietet eine Batch-Funktion, um mehrere Anfragen in einem einzigen HTTP-Aufruf zu bündeln. Man würde erwarten, dass das als eine Anfrage zählt — tut es aber nicht. Jede Einzelanfrage im Batch wird separat gegen die Limits gezählt [2].

Damit ist $batch für unseren Anwendungsfall nahezu wertlos — bestenfalls spart man ein wenig Netzwerk-Latenz.

Es gibt noch eine subtilere Falle. Ein $batch-POST kann auf der äußeren Ebene HTTP 200 zurückgeben, während einzelne Sub-Responses innen 429, 502 oder 503 liefern. In einer produktiven Migration sind uns dadurch unbemerkt zwei Sections verloren gegangen — unsere Retry-Schleife wiederholte nur den äußeren Batch, die 5xx-Fehler der Sub-Responses fielen unter den Tisch. Wer $batch nutzt, muss seine Retry-Logik auf Ebene der einzelnen Sub-Response ansetzen, nicht auf dem Envelope.

5. Die Todesspirale

Der schmerzhafteste Vorfall, den wir hatten, war nicht ein 429 — sondern das, was nach den 429ern passierte. Ein Nutzer startete eine Migration mit 1.300 Seiten. Die ersten 200 Seiten liefen sauber durch. Dann ein 429. Unser Consumer fing ihn ab und bat Cloudflare Queues um einen Retry nach 30 Sekunden. Bis hierher vernünftig.

Aber weil die verbleibenden 1.100 Messages bereits alle in der Queue lagen, wachten sie nach den 30 Sekunden fast gleichzeitig auf, knallten gemeinsam ins Rate Limit und kassierten reihenweise 429er. Jede neue 429-Welle dehnte das Drosselungsfenster weiter aus. Im Diagramm sah man eine schöne selbstverstärkende Schleife: Rate Limit → Retries → Rate Limit → mehr Retries. Eine halbe Stunde lang ging keine einzige Seite voran.

Zwei Fixes machten am Ende den Unterschied:

  1. Ein echter Circuit Breaker im Limiter, nicht an jeder Aufrufstelle. Wenn das Upstream-System anhaltend 429er liefert, trippt der Limiter selbst und jeder Caller bekommt für ein Cooldown-Fenster lokal einen 429. Damit kollabieren N unabhängige Retry-Schleifen zu einem gemeinsamen Warten — statt dass N Caller jeweils 30 Retries × 5 Minuten durchlaufen.
  2. Trennung von Vordergrund und Hintergrund. Note Bridge hat zwei Workloads, die Graph nutzen: interaktive Notebook-Scans (ein Nutzer wartet) und Hintergrund-Migrationsjobs (niemand starrt auf den Bildschirm). Frisst eine 1.300-Seiten-Migration das gesamte Rate-Budget auf, wartet der Nutzer, der gerade sein Notebook scannen will, ewig. Unsere Lösung: zwei Rate-Limiter-„Lanes", wobei die Hintergrund-Lane auf etwa 50 % des Budgets gedeckelt ist. Der Vordergrund hat immer Spielraum.

6. Der subtile retryAfterMs-Bug

Den möchte ich extra hervorheben, weil wir lange gebraucht haben, ihn zu sehen. Wenn der Limiter zwei Schichten prüft (per-user und per-app) und beide sind ausgereizt, welchen retryAfterMs soll er dem Caller zurückgeben?

Wir hatten Math.min(userRetry, appRetry) — die kürzere Wartezeit zurückgeben, klingt freundlich. War falsch. Sagt User-Scope „warte 2 Sekunden" und App-Scope „warte 30 Sekunden", liefert ein Retry nach 2 Sekunden sofort den nächsten 429. Die richtige Antwort ist Math.max(userRetry, appRetry) — die Anfrage ist nur dann sicher sendbar, wenn beide Schichten Kapazität haben. Eine Ein-Zeichen-Korrektur, die eine ganze Kategorie von Phantom-Retries beendet hat.

Fazit

Die meisten Rate-Limiting-Tutorials enden bei „Retry mit exponentiellem Backoff und Jitter". Für Microsoft Graph reicht das nicht. Du brauchst:

  • Modelliere die 3 × 2-Limit-Matrix, statt zu raten.
  • Lies x-ms-throttle-limit-percentage und löse einen Soft Breaker aus, bevor du ein 429 bekommst.
  • Route 429er per x-ms-throttle-scope an die richtige Schicht, damit Nutzer-Trips nicht die ganze App bestrafen (und umgekehrt).
  • Wiederhole innerhalb der $batch-Sub-Responses, nicht nur auf der Hülle.
  • Nutze einen gemeinsamen Circuit Breaker — sonst überdauern deine Retry-Stürme das eigentliche Drosselungsfenster.
  • Trenne Vordergrund und Hintergrund, damit die interaktive UX nicht hungert.

Der fehlende Retry-After-Header bei Microsoft Graph ist der offensichtliche Ärger — die tiefere Lektion: Rate Limiting ist ein System, kein Header. Bau es entsprechend.

Note Bridge migriert OneNote-Notebooks nach Notion.

Bereit, deine Notizen zu migrieren?

Teste Note Bridge kostenlos — migriere bis zu 20 Seiten ohne Kreditkarte.

Kostenlos starten