← Назад к блогу

Microsoft Graph API — о чём не расскажет документация по rate limiting OneNote

Ensky Lin6 min read

Я уже довольно давно работаю над Note Bridge — инструментом, который мигрирует записные книжки OneNote в Notion. Начиная проект, я думал, что самое сложное будет конвертация контента — корректный перевод сложного HTML из OneNote в формат Notion. Оказалось, что борьба с rate limiting от Microsoft отняла ещё больше времени.

Это не пост из серии «вот вам сниппет с backoff, вставляйте и пользуйтесь». У Graph API столько крайних случаев, что универсальный цикл повторных попыток вас не спасёт — рано или поздно придётся моделировать систему лимитов, а не просто реагировать на 429-е. Вот что я узнал.

1. Rate limits в 3 × 2 измерениях

Rate limiting в OneNote — это не простой потолок. Он имеет три измерения — лимит в минуту, лимит в час и количество одновременных запросов — и каждое из них применяется в двух областях: на пользователя и на приложение [1]. Перемножьте — и получите шесть отдельных лимитов, которые нужно соблюдать, иначе 429-е посыплются моментально.

Это значит, что распространённая стратегия полиномиального backoff недостаточна: возможно, вы не упираетесь в поминутный лимит, но превышаете почасовой потолок. Или один пользователь вписывается в норму, но суммарная нагрузка всех пользователей превышает лимит приложения. Каждое измерение требует собственного учёта.

При этом практический опыт показывает, что реальные лимиты, похоже, щедрее, чем указано в документации. Но мы всё равно пишем код по спецификации — если Microsoft решит применять лимиты строго, мы не хотим, чтобы всё сломалось.

2. Нет заголовка Retry-After

Большинство хорошо спроектированных API включают заголовок Retry-After в ответы 429, чтобы вы точно знали, когда можно повторить запрос. Не знаю, связано ли это со сложностью механизма, но OneNote Graph API этого не поддерживает.

Без Retry-After простые стратегии backoff не спасают. Можно увеличивать время ожидания после каждого 429, но нет никакой гарантии, что следующая попытка тоже не будет отклонена. Для production-приложения это серьёзная проблема. Единственное надёжное решение — реализовать rate limiter, следующий спецификации Microsoft. Мы построили свой на Cloudflare Durable Objects, отслеживая использование по каждому измерению и реализовав собственный Retry-After для потребления как фронтендом, так и бэкендом.

3. Два заголовка, которые почти никто не использует

После того как мы построили лимитер, мы обнаружили, что Graph на самом деле отправляет два заголовка ответа, которые помогают — их просто легко пропустить, потому что они приходят не в ответах 429.

  • x-ms-throttle-limit-percentage — присутствует в успешных ответах. Показывает, насколько вы близки к лимиту (0.0–1.0). Выше ~0.8 — жёлтая зона; при 1.0 следующий запрос, скорее всего, получит 429.
  • x-ms-throttle-scope — присутствует в ответах 429. Значения: User, Application или оба. Сообщает, какую из двух областей вы превысили.

Эти заголовки изменили наш дизайн. Первый позволяет сработать «мягкому» circuit breaker до получения 429, вместо того чтобы ждать, пока система скажет «стоп». Второй позволяет направить 429 в нужный breaker — если сработал лимит на уровне приложения, замедление одного пользователя не поможет: нужно замедлить всех. Если сработал лимит на уровне пользователя, остальные тенанты могут продолжать работу.

Если вы реагируете только на статус-код 429, вы летите вполслепую даже при rate limiter, соответствующем спецификации.

4. Batch API не особо помогает

Microsoft Graph предлагает механизм батчинга для объединения нескольких запросов в один HTTP-вызов. Интуитивно это должно считаться как один запрос, но нет. Каждый внутренний запрос в батче учитывается индивидуально в счёт rate limits [2].

Это делает $batch практически бесполезным для нашего случая — в лучшем случае он экономит немного времени на round-trip.

Есть и более коварная ловушка. POST $batch может вернуть HTTP 200 на внешнем уровне, в то время как отдельные под-ответы внутри него возвращают 429, 502 или 503. Мы наблюдали, как production-миграция молча потеряла два раздела, потому что наш цикл повторных попыток ретраил только внешний батч — ошибки 5xx из под-ответов просто терялись. Если вы используете $batch, стратегия повторных попыток должна работать на уровне каждого под-ответа, а не конверта.

5. Спираль смерти

Самый болезненный инцидент, с которым мы столкнулись, были не 429-е — а то, что произошло после них. Пользователь запустил миграцию на 1 300 страниц. Первые 200 страниц прошли без проблем. Потом — 429. Наш consumer перехватил его и попросил Cloudflare Queues повторить через 30 секунд. Пока всё разумно.

Но поскольку все 1 100 оставшихся сообщений уже были в очереди, все они проснулись примерно одновременно после этих 30 секунд, снова врезались в rate limit и получили 429. Каждая новая волна 429-х расширяла окно тротлинга ещё дальше. На графике вырисовывался красивый самоусиливающийся цикл: rate limit → повторные попытки → rate limit → ещё попытки. Ни одна страница не продвинулась вперёд в течение получаса.

Два исправления оказались ключевыми:

  1. Настоящий circuit breaker в лимитере, а не в каждой точке вызова. Когда upstream возвращает устойчивые 429-е, лимитер срабатывает и все вызывающие получают локальный 429 на время окна остывания. Это схлопывает N независимых циклов повторных попыток в одно общее ожидание вместо того, чтобы N вызывающих делали каждый по 30 ретраев × 5 минут.
  2. Разделение foreground и background. Note Bridge имеет две рабочие нагрузки на Graph: интерактивное сканирование записных книжек (пользователь ждёт) и фоновые задачи миграции (никто не смотрит на экран). Если миграция на 1 300 страниц исчерпывает бюджет rate limit, пользователь, пытающийся просканировать свою записную книжку, ждёт вечно. Наше решение — два «lane» в rate limiter, при этом фоновый lane ограничен ~50% бюджета. У foreground всегда есть запас.

6. Коварный баг retryAfterMs

Стоит упомянуть отдельно, потому что мы долго его искали. Когда лимитер проверяет два слоя (per-user и per-app) и оба перегружены, какой retryAfterMs он должен вернуть вызывающему?

У нас стояло Math.min(userRetry, appRetry) — вернуть меньшее ожидание, звучит дружелюбно. Это было неправильно. Если user-scope говорит «подожди 2 секунды», а app-scope говорит «подожди 30 секунд», повторная попытка через 2 секунды даёт очередной мгновенный 429. Правильный ответ — Math.max(userRetry, appRetry) — запрос безопасен только тогда, когда оба слоя имеют ёмкость. Исправление в один символ, которое устранило целую категорию фантомных ретраев.

Итог

Большинство туториалов по rate limiting заканчиваются на «повторяйте с экспоненциальным backoff и jitter». Для Microsoft Graph этого недостаточно. Нужно:

  • Моделировать матрицу лимитов 3 × 2 вместо угадывания.
  • Читать x-ms-throttle-limit-percentage и срабатывать мягким breaker до получения 429.
  • Маршрутизировать 429-е по x-ms-throttle-scope, чтобы срабатывания пользовательского лимита не наказывали всё приложение (и наоборот).
  • Ретраить внутри под-ответов $batch, а не только на уровне конверта.
  • Использовать общий circuit breaker, иначе шторм ретраев продлится дольше, чем само окно тротлинга.
  • Разделять foreground и background, чтобы интерактивный UX не голодал.

Отсутствие Retry-After в Microsoft Graph — это главная боль на поверхности, но более глубокий урок в том, что rate limiting — это система, а не заголовок. Стройте его соответственно.

Note Bridge мигрирует записные книжки OneNote в Notion.

Готовы мигрировать заметки?

Попробуйте Note Bridge бесплатно — перенесите до 20 страниц без кредитной карты.

Начать бесплатно