Microsoft Graph API — 見落としがちな OneNote API の制限
しばらくの間、Note Bridge を開発しています。OneNote のノートブックを Notion に移行するツールです。最初はコンテンツ変換が一番大変だろうと思っていました。OneNote の複雑な HTML を正確に Notion に変換する作業です。結果的に、Microsoft の rate limit の処理の方がさらに時間を取られました。以下は、その過程で学んだことです。
1. 3 × 2 次元の rate limit
OneNote の rate limit は単純ではありません。三つの次元——毎分、毎時、並列リクエスト数——がそれぞれ二つのスコープで適用されます:ユーザーごととアプリごと [1]。掛け合わせると六種類の制限を全部守らないといけません。守れないと 429 が即座に返ってきます。
つまり、よく使われる多項式バックオフ戦略だけでは不十分です。毎分の制限には引っかかっていないのに、毎時の上限に触れてしまうことがある。一人のユーザーは問題なくても、全ユーザーの合計がアプリ全体の上限に達してしまう。各次元にはそれぞれ独自の計量ロジックが必要です。
ただ、実戦経験では実際の制限はドキュメントの仕様より緩いようです。それでも仕様通りに実装しています——いつか Microsoft が厳格に適用し始めたら困るので。
2. Retry-After ヘッダーがない
主流の API 設計では 429 を返す際に Retry-After ヘッダーも一緒に付けて、次の安全なリトライ時間を教えてくれます。メカニズムが複雑すぎるからかもしれませんが、OneNote Graph API はこれをサポートしていません。
Retry-After がないと、単純なバックオフ戦略では対応できません。429 ごとに待ち時間を増やすことはできますが、次回制限されない保証はありません。本番環境のアプリケーションにとって、これは深刻な問題です。唯一信頼できる方法は、Microsoft の仕様通りに rate limiter を自分で実装することです——私たちは Cloudflare Durable Objects を使って各次元の使用状況を追跡し、フロントエンドとバックエンドの両方が参照できる独自の Retry-After も併せて実装しました。
3. 二つの x-ms- ヘッダー
rate limiter を作り終えてから気づいたのですが、Graph は実は役に立つレスポンスヘッダーを二つ返してくれます——ただ 429 のパスには出てこないので見落としやすいのです。
x-ms-throttle-limit-percentage——成功時のレスポンスに含まれます。現在の制限への近さを 0.0–1.0 で示します。0.8 前後を超えるとイエローゾーン、1.0 に達すると次の Request はほぼ確実に 429 になります。x-ms-throttle-scope——429 レスポンスに含まれます。値はUser、Application、またはその両方。どのレベルの制限に引っかかったかを教えてくれます。
この二つのヘッダーが設計を変えました。一つ目のおかげで、429 を受ける 前に ソフトサーキットブレーカーを発動できます——システムに教えてもらうのを待つ必要はありません。二つ目のおかげで、429 を正しいブレーカーに振り分けられます——アプリケーション全体が引っかかっているなら、一人のユーザーだけ減速しても意味がないので全員減速する必要があります。ユーザーレベルなら、他のユーザーはそのまま走り続けて大丈夫です。
429 のステータスコードだけ見て反応していると、rate limiter は実質的に半分目を閉じた状態で動いていることになります。
4. Batch API はあまり意味がない
Microsoft Graph には、複数のリクエストを一つの HTTP リクエストにまとめる batch 機能があります。直感的にはこれが 1 リクエストとしてカウントされるはずですが、そうではありません。batch 内の各内部 Request は個別に rate limit にカウントされます [2]。
結果として $batch を使う意味はあまりなく、せいぜいラウンドトリップ時間を少し節約できる程度です。
もう一つ、もっと隠れた落とし穴があります。$batch POST が外側で HTTP 200 を返しつつ、個別の sub-response が 429、502、503 を返すことがあります。あるとき本番の移行で内側の結果が二つ静かに抜け落ちました——リトライループが外側しか retry しておらず、sub-response の 5xx エラーがそのまま消えてしまったのです。$batch を使うなら、リトライ戦略は response 全体ではなく各 sub-response に対して動かさなければなりません。
5. デススパイラル
経験した中でもっと痛かったのは 429 そのものではなく、429 が 起きた後 のことでした。あるユーザーが 1,300 ページの移行を開始しました。最初の 200 ページは順調。それから 429 が一つ来て、私たちの consumer がきちんと受け止めて、Cloudflare Queues に 30 秒後のリトライを依頼しました。ここまでは妥当です。
しかし残りの 1,100 件の message はすでに全部キューに入っていたので、30 秒後にほぼ同時に目覚めて、揃って rate limit に突っ込み、揃って 429 を食らいました。429 の波が来るたびに throttle ウィンドウがさらに延長されました。グラフ上では綺麗な自己強化ループが描かれていました:rate limit → retry → rate limit → さらに retry。30 分間、一ページも前進しませんでした。
最終的に効いたのは二つの対策でした:
- 本物のサーキットブレーカーは呼び出し側ごとではなく rate limiter 層に置くこと。 上流が継続的に 429 を返しているとき、rate limiter 自身が受け止めて、すべての caller がローカルで一定のクールダウン期間 429 を受け取ります。これで N 個の独立したリトライループが一つの共有待機に収束します——N 人の caller がそれぞれ 30 回 × 5 分のリトライをするのではなく。
- フォアグラウンドとバックグラウンドの分離。 Note Bridge には二種類のタスクがあります:フォアグラウンドのノートブックスキャン(ユーザーが待っている)とバックグラウンドの移行ジョブ(誰も画面を見ていない)。1,300 ページの移行が rate limit の予算を食い尽くすと、ノートブックをスキャンしたいユーザーはフォアグラウンドで足止めされます。私たちの解決策は rate limiter を二つに分け、バックグラウンド用の上限を予算の約 50% に抑えること。フォアグラウンドは常に優先されます。
6. 微妙な retryAfterMs の問題
これは気づくのに少し時間がかかったので、特に取り上げる価値があります。rate limiter が二層(per-user と per-app)をチェックしていて、両層とも満杯のとき、呼び出し元にどちらの retryAfterMs を返すべきでしょうか?
私たちは最初 Math.min(userRetry, appRetry) を返していました——短い方の待ち時間を返す、親切に聞こえます。でも間違いでした。user-scope が「2 秒待て」、app-scope が「30 秒待て」と言っているとき、2 秒後に本当にリトライすると即座にまた 429 を食らいます。正解は Math.max(userRetry, appRetry)——両層 に余裕があるときだけ、その Request は本当に送り出せるのです。
まとめ
rate limit のチュートリアルの多くは「指数バックオフ + リトライ」で終わります。Microsoft Graph にはそれだけでは全然足りません。必要なのは:
- 3 × 2 の制限マトリクスをモデル化する。
x-ms-throttle-limit-percentageを読み、429 になる前にバックオフする。x-ms-throttle-scopeを使って 429 を正しいレイヤーに限定し、ユーザーレベルとアプリレベルの制限がお互いに影響しないようにする。- retry は
$batchの sub-response 単位で行う。payload 全体ではなく。 - 共有 サーキットブレーカーを使う。さもないとキューが無効な retry で埋まり続ける。
- フォアグラウンドとバックグラウンドを分けて、インタラクティブな UX がバックグラウンドジョブに飢えさせられないようにする。
Microsoft Graph に Retry-After がないのは表面的な問題で、より深い教訓は「rate limit はヘッダー一つではなくシステムである。システムとして向き合って設計しないと正しく解決できない」ということです。
Note Bridge は OneNote のノートブックを Notion に移行します。