← Quay lại blog

Microsoft Graph API — Những điều tài liệu không nói về rate limiting của OneNote

Ensky Lin8 min read

Tôi đã phát triển Note Bridge được một thời gian — một công cụ di chuyển sổ tay OneNote sang Notion. Khi bắt đầu, tôi nghĩ phần khó nhất sẽ là chuyển đổi nội dung — dịch chính xác HTML phức tạp của OneNote sang Notion. Hóa ra, việc xử lý rate limiting của Microsoft lại tốn nhiều thời gian hơn.

Đây không phải bài viết kiểu "đây là đoạn code backoff, dán vào là xong". Graph API có đủ nhiều trường hợp biên khiến một vòng lặp retry chung chung không thể cứu bạn — cuối cùng bạn cần mô hình hóa hệ thống giới hạn, chứ không chỉ phản ứng với 429. Đây là những gì tôi đã học được.

1. Rate limits trên 3 × 2 chiều

Rate limiting của OneNote không phải là một giới hạn đơn giản. Nó có ba chiều — theo phút, theo giờ, và số request đồng thời — và mỗi chiều được áp dụng ở hai phạm vi: theo người dùng và theo ứng dụng [1]. Nhân lên ta có sáu giới hạn riêng biệt cần tuân thủ, nếu không 429 sẽ xuất hiện ngay lập tức.

Điều này có nghĩa là chiến lược backoff đa thức thông thường là chưa đủ: bạn có thể không chạm giới hạn theo phút, nhưng lại vượt trần theo giờ. Hoặc một người dùng thì ổn, nhưng tổng tất cả người dùng lại chạm giới hạn toàn ứng dụng. Mỗi chiều cần có cơ chế tính toán riêng.

Tuy nhiên, kinh nghiệm thực tế cho thấy các giới hạn thực sự có vẻ rộng rãi hơn so với tài liệu đặc tả. Nhưng chúng tôi vẫn lập trình theo đặc tả — nếu Microsoft quyết định áp dụng nghiêm ngặt, chúng tôi không muốn mọi thứ hỏng.

2. Không có header Retry-After

Hầu hết các API được thiết kế tốt đều bao gồm header Retry-After trong phản hồi 429 để bạn biết chính xác khi nào có thể retry an toàn. Không rõ có phải vì cơ chế quá phức tạp hay không, nhưng OneNote Graph API không hỗ trợ điều này.

Không có Retry-After, các chiến lược backoff đơn giản không đủ. Bạn có thể tăng thời gian chờ sau mỗi 429, nhưng không có gì đảm bảo lần thử tiếp theo không bị throttle. Đối với ứng dụng production, đây là vấn đề nghiêm trọng. Giải pháp bền vững duy nhất là xây dựng một rate limiter tuân theo đặc tả của Microsoft — chúng tôi xây dựng của mình bằng Cloudflare Durable Objects, theo dõi mức sử dụng trên mọi chiều và triển khai Retry-After riêng cho cả frontend và backend sử dụng.

3. Hai header mà hầu như không ai dùng

Sau khi xây dựng limiter, chúng tôi phát hiện Graph thực sự gửi hai response header hữu ích — chúng chỉ dễ bị bỏ qua vì không nằm trên đường dẫn 429.

  • x-ms-throttle-limit-percentage — có mặt trong các phản hồi thành công. Cho biết bạn gần giới hạn đến mức nào (0,0–1,0). Trên ~0,8 là vùng cảnh báo; ở 1,0, request tiếp theo có lẽ sẽ nhận 429.
  • x-ms-throttle-scope — có mặt trong phản hồi 429. Giá trị là User, Application, hoặc cả hai. Cho biết phạm vi nào bạn đã chạm.

Hai header này thay đổi thiết kế. Header đầu tiên cho phép chúng tôi kích hoạt circuit breaker mềm trước khi nhận 429, thay vì chờ hệ thống thông báo. Header thứ hai cho phép chúng tôi định tuyến 429 đến đúng breaker — nếu là application-scope, việc làm chậm một người dùng không giúp gì; phải làm chậm tất cả. Nếu là user-scope, các tenant khác vẫn có thể tiếp tục bình thường.

Nếu bạn chỉ phản ứng với mã trạng thái 429, bạn đang vận hành rate limiter trong tình trạng nửa mù.

4. Batch API không thực sự giúp ích

Microsoft Graph cung cấp cơ chế batch để gộp nhiều request vào một lần gọi HTTP. Theo trực giác, điều này nên được tính là một request duy nhất, nhưng không phải vậy. Mỗi request bên trong batch được tính riêng lẻ vào rate limits [2].

Điều này khiến $batch gần như vô dụng cho trường hợp sử dụng của chúng tôi — nhiều nhất là tiết kiệm một chút thời gian round-trip.

Còn có một cái bẫy tinh vi hơn. Một POST $batch có thể thành công với HTTP 200 ở lớp ngoài, trong khi các sub-response riêng lẻ bên trong trả về 429, 502, hoặc 503. Chúng tôi đã thấy một lần di chuyển trên production âm thầm mất hai phần vì vòng lặp retry chỉ retry batch bên ngoài — các lỗi 5xx của sub-response bị bỏ sót. Nếu dùng $batch, chiến lược retry phải hoạt động trên từng sub-response, không phải trên lớp bao ngoài.

5. Vòng xoáy tử thần

Sự cố đau đớn nhất mà chúng tôi gặp phải không phải là 429 — mà là những gì xảy ra sau 429. Một người dùng khởi động di chuyển 1.300 trang. 200 trang đầu tiên đi qua suôn sẻ. Rồi một 429. Consumer của chúng tôi bắt được nó và yêu cầu Cloudflare Queues retry sau 30 giây. Đến đây vẫn hợp lý.

Nhưng vì tất cả 1.100 message còn lại đã nằm trong hàng đợi, tất cả đều thức dậy gần như cùng lúc sau 30 giây, đồng loạt đập vào rate limit, và đồng loạt nhận 429. Mỗi đợt 429 mới lại kéo dài thêm cửa sổ throttle. Biểu đồ cho thấy một vòng lặp tự cường hóa hoàn hảo: rate limit → retry → rate limit → thêm retry. Suốt nửa tiếng, không một trang nào tiến triển.

Hai sửa chữa tạo ra sự khác biệt:

  1. Một circuit breaker thật sự tại limiter, không phải tại mỗi điểm gọi. Khi upstream trả về 429 liên tục, limiter kích hoạt và tất cả caller đều nhận 429 cục bộ trong một cửa sổ hạ nhiệt. Điều này gộp N vòng lặp retry độc lập thành một lần chờ chung, thay vì N caller mỗi người thực hiện 30 lần retry × 5 phút.
  2. Tách biệt foreground và background. Note Bridge có hai loại tải truy cập Graph: phân tích sổ tay tương tác (người dùng đang chờ) và các công việc di chuyển background (không ai nhìn màn hình). Nếu một lần di chuyển 1.300 trang tiêu hết ngân sách rate, người dùng đang cố phân tích sổ tay của họ sẽ chờ mãi. Giải pháp của chúng tôi là hai "lane" rate limiter với lane background giới hạn ở ~50% ngân sách. Foreground luôn có dư.

6. Bug tinh vi của retryAfterMs

Đáng đề cập riêng vì chúng tôi mất một thời gian mới nhận ra. Khi limiter kiểm tra hai lớp (per-user và per-app) và cả hai đều bão hòa, nó nên trả về retryAfterMs nào cho caller?

Chúng tôi đã dùng Math.min(userRetry, appRetry) — trả về thời gian chờ ngắn hơn, nghe có vẻ thân thiện. Nhưng sai. Nếu user-scope nói "chờ 2 giây" nhưng app-scope nói "chờ 30 giây", retry sau 2 giây sẽ cho bạn thêm một 429 tức thì. Đáp án đúng là Math.max(userRetry, appRetry) — request chỉ an toàn khi cả hai lớp đều có dung lượng. Một sửa chữa một ký tự đã kết thúc cả một hạng mục retry ảo.

Kết luận

Hầu hết các hướng dẫn rate limiting kết thúc ở "retry với backoff mũ và jitter". Đối với Microsoft Graph, điều đó chưa đủ. Bạn cần:

  • Mô hình hóa ma trận giới hạn 3 × 2 thay vì đoán.
  • Đọc x-ms-throttle-limit-percentage và kích hoạt breaker mềm trước khi nhận 429.
  • Định tuyến 429 bằng x-ms-throttle-scope để người dùng bị giới hạn không phạt toàn ứng dụng (và ngược lại).
  • Retry bên trong các sub-response của $batch, không chỉ trên lớp bao ngoài.
  • Dùng circuit breaker chung, nếu không các cơn bão retry sẽ kéo dài hơn cả cửa sổ throttle thực tế.
  • Tách biệt foreground và background để UX tương tác không bị thiếu tài nguyên.

Việc Microsoft Graph thiếu Retry-After là phiền toái nổi bật nhất, nhưng bài học sâu hơn là rate limiting là một hệ thống, không phải một header. Hãy xây dựng nó như vậy.

Note Bridge di chuyển sổ tay OneNote sang Notion.

Sẵn sàng di chuyển ghi chú?

Thử Note Bridge miễn phí — di chuyển tối đa 20 trang không cần thẻ tín dụng.

Bắt đầu miễn phí