Microsoft Graph API — Yang Tidak Diceritakan Dokumentasi Tentang Rate Limiting OneNote
Saya sudah cukup lama mengembangkan Note Bridge — sebuah alat yang memigrasikan buku catatan OneNote ke Notion. Awalnya, saya mengira bagian tersulit adalah konversi konten — menerjemahkan HTML kompleks OneNote ke Notion dengan benar. Ternyata, menangani rate limiting Microsoft justru memakan waktu lebih lama.
Ini bukan artikel "ini snippet backoff, tempel dan pakai". Graph API punya cukup banyak kasus edge yang membuat loop retry generik tidak akan menyelamatkan Anda — pada akhirnya Anda perlu memodelkan sistem limit, bukan sekadar bereaksi terhadap 429. Berikut yang saya pelajari.
1. Rate Limits dalam 3 × 2 Dimensi
Rate limiting OneNote bukan sekadar batas sederhana. Ia memiliki tiga dimensi — per menit, per jam, dan request bersamaan — dan masing-masing diterapkan pada dua cakupan: per pengguna dan per aplikasi [1]. Kalikan dan Anda mendapat enam limit terpisah yang harus dipatuhi, atau 429 langsung datang mengetuk.
Ini berarti strategi backoff polinomial yang umum tidak cukup: Anda mungkin tidak menyentuh limit per menit, tapi melampaui batas per jam. Atau satu pengguna baik-baik saja, tapi total seluruh pengguna menyentuh batas global aplikasi. Setiap dimensi memerlukan pencatatannya sendiri.
Meski begitu, pengalaman di lapangan menunjukkan limit aktual tampak lebih longgar dari yang ditentukan dokumentasi. Tapi kami tetap menulis kode sesuai spesifikasi — jika Microsoft suatu hari memutuskan untuk menerapkan secara ketat, kami tidak ingin semuanya rusak.
2. Tidak Ada Header Retry-After
Kebanyakan API yang dirancang baik menyertakan header Retry-After pada respons 429 sehingga Anda tahu persis kapan aman untuk retry. Tidak jelas apakah karena mekanismenya terlalu kompleks, tapi OneNote Graph API tidak mendukung ini.
Tanpa Retry-After, strategi backoff sederhana tidak memadai. Anda bisa menambah waktu tunggu setelah setiap 429, tapi tidak ada jaminan percobaan berikutnya tidak akan di-throttle juga. Untuk aplikasi production, ini masalah serius. Satu-satunya solusi yang kuat adalah mengimplementasikan rate limiter yang mengikuti spesifikasi Microsoft — kami membangunnya menggunakan Cloudflare Durable Objects, melacak penggunaan di setiap dimensi dan mengimplementasikan Retry-After kami sendiri untuk dikonsumsi frontend maupun backend.
3. Dua Header yang Hampir Tidak Ada yang Pakai
Setelah membangun limiter, kami menemukan Graph sebenarnya mengirimkan dua response header yang membantu — hanya saja mudah terlewat karena tidak muncul di jalur 429.
x-ms-throttle-limit-percentage— ada di respons yang berhasil. Memberi tahu seberapa dekat Anda dengan limit (0,0–1,0). Di atas ~0,8 adalah zona kuning; di 1,0, request berikutnya kemungkinan besar akan mendapat 429.x-ms-throttle-scope— ada di respons 429. NilainyaUser,Application, atau keduanya. Memberi tahu cakupan mana yang Anda langgar.
Kedua header ini mengubah desain. Yang pertama memungkinkan kami memicu circuit breaker lunak sebelum mendapat 429, alih-alih menunggu diberitahu sistem. Yang kedua memungkinkan kami mengarahkan 429 ke breaker yang tepat — jika itu cakupan application, memperlambat satu pengguna tidak membantu; semua pengguna harus diperlambat. Jika cakupan user, tenant lain tetap bisa melanjutkan.
Jika Anda hanya bereaksi terhadap kode status 429, Anda mengoperasikan rate limiter setengah buta.
4. Batch API Tidak Benar-Benar Membantu
Microsoft Graph menawarkan mekanisme batch untuk menggabungkan beberapa request dalam satu panggilan HTTP. Secara intuitif ini seharusnya dihitung sebagai satu request, tapi tidak. Setiap request internal dalam batch dihitung secara individual terhadap rate limits [2].
Ini membuat $batch nyaris tidak berguna untuk kasus penggunaan kami — paling banyak menghemat sedikit waktu round-trip.
Ada juga jebakan yang lebih halus. POST $batch bisa berhasil dengan HTTP 200 di level luar, sementara sub-response individual di dalamnya mengembalikan 429, 502, atau 503. Kami menyaksikan sebuah migrasi di production diam-diam kehilangan dua bagian karena loop retry kami hanya me-retry batch luar — error 5xx sub-response terlewat begitu saja. Jika menggunakan $batch, strategi retry harus beroperasi pada setiap sub-response, bukan pada amplop luarnya.
5. Spiral Kematian
Insiden paling menyakitkan yang kami alami bukan 429 — tapi apa yang terjadi setelah 429. Seorang pengguna memulai migrasi 1.300 halaman. 200 halaman pertama berjalan lancar. Lalu sebuah 429. Consumer kami menangkapnya dan meminta Cloudflare Queues untuk retry 30 detik kemudian. Sejauh ini masuk akal.
Tapi karena semua 1.100 message yang tersisa sudah dalam antrian, semuanya bangun hampir bersamaan setelah 30 detik itu, menabrak rate limit lagi, dan mendapat 429 lagi. Setiap gelombang 429 baru memperpanjang jendela throttle lebih jauh. Grafiknya menunjukkan loop self-reinforcing yang indah: rate limit → retry → rate limit → lebih banyak retry. Selama setengah jam, tidak satu halaman pun membuat kemajuan.
Dua perbaikan yang membuat perbedaan:
- Circuit breaker nyata di limiter, bukan di setiap titik pemanggilan. Ketika upstream mengembalikan 429 secara terus-menerus, limiter aktif dan semua caller mendapat 429 lokal selama jendela pendinginan. Ini menciutkan N loop retry independen menjadi satu tunggu bersama, alih-alih N caller masing-masing melakukan 30 retry × 5 menit.
- Pemisahan foreground dan background. Note Bridge memiliki dua beban kerja yang mengakses Graph: analisis buku catatan interaktif (pengguna sedang menunggu) dan pekerjaan migrasi background (tidak ada yang menatap layar). Jika migrasi 1.300 halaman menghabiskan anggaran rate, pengguna yang mencoba menganalisis buku catatan mereka menunggu selamanya. Solusi kami adalah dua "jalur" rate limiter dengan jalur background dibatasi ~50% dari anggaran. Foreground selalu memiliki ruang.
6. Bug Halus retryAfterMs
Patut disebutkan tersendiri karena kami butuh waktu untuk menyadarinya. Ketika limiter memeriksa dua lapisan (per-user dan per-app) dan keduanya jenuh, retryAfterMs mana yang harus dikembalikan ke caller?
Kami punya Math.min(userRetry, appRetry) — mengembalikan tunggu yang lebih pendek, terdengar ramah. Itu salah. Jika cakupan user berkata "tunggu 2 detik" tapi cakupan app berkata "tunggu 30 detik", retry setelah 2 detik menghasilkan 429 instan lagi. Jawaban yang benar adalah Math.max(userRetry, appRetry) — request hanya aman dikirim ketika kedua lapisan memiliki kapasitas. Perbaikan satu karakter yang mengakhiri seluruh kategori retry hantu.
Kesimpulan
Kebanyakan tutorial rate limiting berakhir di "retry dengan exponential backoff dan jitter". Untuk Microsoft Graph itu tidak cukup. Anda perlu:
- Memodelkan matriks limit 3 × 2 alih-alih menebak.
- Membaca
x-ms-throttle-limit-percentagedan memicu breaker lunak sebelum mendapat 429. - Mengarahkan 429 menggunakan
x-ms-throttle-scopeagar pengguna yang terkena limit tidak menghukum seluruh app (dan sebaliknya). - Retry di dalam sub-response
$batch, bukan hanya pada amplopnya. - Menggunakan circuit breaker bersama, atau badai retry Anda akan berlangsung lebih lama dari jendela throttle yang sebenarnya.
- Memisahkan foreground dari background agar UX interaktif tidak kelaparan.
Ketiadaan Retry-After di Microsoft Graph adalah keluhan yang paling mencolok, tapi pelajaran yang lebih dalam adalah bahwa rate limiting adalah sebuah sistem, bukan sebuah header. Bangunlah seperti itu.
Note Bridge memigrasikan buku catatan OneNote ke Notion.
Siap memigrasikan catatan Anda?
Coba Note Bridge gratis — migrasikan hingga 20 halaman tanpa kartu kredit.
Mulai gratis