Microsoft Graph API — 你可能會忽略的 OneNote API 限制
過去一段時間裡,我一直在開發 Note Bridge,一個把 OneNote 筆記本搬移到 Notion 的工具。一開始我以為最難的部分是內容轉換——把 OneNote 的複雜 HTML 正確轉換到 Notion 中。結果實作過程中,關於 Microsoft 的 rate limit 機制處理反而花了我更多的時間。以下是我學到的東西:
1. 3 x 2 維度的 rate limit
OneNote 的 rate limit 並不簡單:它有三個維度——每分鐘、每小時和平行 Request 數——而上述限制又分別在兩個範圍:每個使用者和每個應用程式[1]。乘起來就是六種不同的限制都要被遵守,不然 429 就會馬上來找你。
這表示我們常見的 Polynomial backoff 策略並不夠:你可能沒踩到每分鐘限制,但戳到每小時上限。也可能單一使用者沒遇到上限,但全部使用者總和卻碰到了整個應用程式的上限。每個維度都需要各自的計算標準。
不過,實戰經驗顯示實際的限制似乎比文件規格還寬鬆,但我們還是照規格來,否則哪天 Microsoft 突然嚴格執行了就會有問題。
2. 沒有 Retry-After header
主流的 API 設計都會在回 429 的時候順便帶 Retry-After header,就可以知道下次重試的安全時間。不確定是不是因為機制過於複雜,OneNote Graph API 並沒有支援。
沒有 Retry-After 導致我們沒辦法用簡單的 backoff 策略。只能在每次 429 後增加等待時間,但這不保證下次就不會再被限制。這點對於生產環境的應用程式來說是個大問題。唯一可靠的方法就是照著 Microsoft 的規格來實作 rate limiter -- 我們使用 Cloudflare Durable Objects 實現,追蹤每個維度的使用情況,並實作了我們自己的 Retry-After 給前後端使用。
3. 兩個 x-ms- Header
在我們把 rate limit 器做出來之後,才發現 Graph 其實有兩個能幫上忙的 response header——只是因為它們不在 429 那條路徑上,很容易被忽略。
x-ms-throttle-limit-percentage——出現在 成功 的回應上。它告訴你目前離限制有多近(0.0–1.0)。大約 0.8 以上就是黃燈區;到 1.0 的時候下一個 Request 很可能就會 429。x-ms-throttle-scope——出現在 429 的回應上。值會是User、Application或兩者都有,告訴你 是哪一個層級 的限制被打到。
這兩個 header 改變了我們的設計。第一個讓我們可以在 還沒被 429 之前 就先觸發 soft circuit breaker,而不是等系統主動告訴我們。第二個讓我們可以把 429 導到正確的 breaker——如果是整個應用程式被打到,把單一使用者降速沒有意義,得讓所有使用者一起降;如果是使用者層級,則其他使用者就可以繼續正常跑。
如果你只看 429 status code 來做反應,那你的 rate limiter 其實算是半盲狀態在運作。
4. Batch API 沒太大意義
Microsoft Graph 有提供 batch 機制,讓你把多個 Request 打包成一個 HTTP 請求。直覺上這應該要算成一次 Request,但並不是這樣。batch 中的每個內部 Request 都會個別計算 Rate limit[2]。
這導致 $batch 的使用意義變得不太大,可能只能用他來省一點點 round-trip 時間。
還有一個更隱性的陷阱。$batch POST 在外層可能回 HTTP 200,但裡面的各別 sub-response 卻可能回 429、502、或 503。我們曾經看到一次正式環境的搬移悄悄漏掉了兩個內層結果,因為我們的 retry loop 只 retry 外層——sub-response 的 5xx 錯誤就這樣消失了。如果你要用 $batch,重試策略一定要建立在每個 sub-response 上,而不是整個 response 上。
5. 死亡螺旋
我們曾經遇過更糟糕的狀況並不是 429 本身,而是 429 發生之後 的事。有個使用者啟動了一個 1,300 頁的搬移任務。前 200 頁都很順利。然後來了一個 429,被我們的 consumer 順利接住了,就跟 Cloudflare Queues 要求 30 秒後重試。聽起來很合理。
但因為剩下 1,100 個 message 已經全部 enqueue 進去了,30 秒過後它們幾乎同一時間醒過來,一起撞上 rate limit,又全部 429。每一波 429 又把 throttle 視窗往後延長。從圖上看就是一個漂亮的自我強化迴圈:rate limit → retry → rate limit → 更多 retry。整整半小時沒有任何一頁前進。
後來真正解決問題的有兩件事:
- 真正的 circuit breaker 要做在 rate limiter 層,不是每個呼叫點。 當上游持續回 429 的時候,rate limiter 自己接起來,每個 caller 在本地就會直接被 429 一段冷卻時間。這樣就把 N 個各自獨立的 retry loop 收斂成一個共享的等待,而不是 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)——只有當 兩層 都有 capacity 的時候,這個 Request 才真的可以送出去。
結論
大部分 rate limit 的教學文章寫到「用 exponential backoff + 重試」就結束了。對 Microsoft Graph 來說,這遠遠不夠。你需要:
- 把 3 × 2 的限制矩陣建出來。
- 讀
x-ms-throttle-limit-percentage,在還沒 429 之前就先 backoff。 - 用
x-ms-throttle-scope把 429 限制到對的層級,這樣使用者層級和 app 層級的限制就不會互相影響。 - Retry 要做在
$batch的 sub-response 上,而不是整包 payload 上。 - 用一個 共享 的 circuit breaker,不然你的 queue 會一直被無效的 retry 塞滿。
- 把前景和背景分開,互動式的 UX 才不會被背景任務餓死。
Microsoft Graph 沒有 Retry-After 是表面上的問題,我們學到的更深的教訓是:rate limit 是一個系統,不僅僅只是一個 header,用對付系統的方式去設計他,才可以正確解決問題。
Note Bridge 把 OneNote 筆記本搬到 Notion。