← 返回博客

Microsoft Graph API — 你可能会忽略的 OneNote API 限制

Ensky Lin9 min read

过去一段时间,我一直在开发 Note Bridge——一个将 OneNote 笔记本迁移到 Notion 的工具。最初以为最大的难点在于内容转换——把 OneNote 的复杂 HTML 正确地转为 Notion 格式。结果实际做下来,处理 Microsoft 的 rate limit 机制反而花了我更多时间。以下是我学到的东西:

1. 3 x 2 维度的 rate limit

OneNote 的 rate limit 并不简单:它包含三个维度——每分钟、每小时、并发请求数——且每个维度分别在两个范围执行:单用户和整个应用程序 [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。整整半小时没有任何一页前进。

后来真正解决问题的有两件事:

  1. 真正的 circuit breaker 要做在 rate limiter 层,不是每个调用点。 当上游持续回 429 的时候,rate limiter 自己接起来,每个 caller 在本地就会直接被 429 一段冷却时间。这样就把 N 个各自独立的 retry loop 收敛成一个共享的等待,而不是 N 个 caller 各自跑 30 次重试 × 5 分钟。
  2. 前台 / 后台分离。 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。

准备好迁移您的笔记了吗?

免费试用 Note Bridge——无需信用卡即可迁移最多 20 页。

免费开始