Handling Microsoft Graph Throttling: Why Naive Retries Make 429s Worse
Microsoft Graph 429 throttling gets worse with naive retries. Learn the Retry-After-aware backoff pattern, why you shouldn't double-wrap SDK retries, and how to cut request volume instead.
30 Aug 2025, 12:57 UTC

Your sync job ran fine for weeks, then started failing in bursts: HTTP 429 responses from Microsoft Graph, retries firing immediately, and the failures getting worse the harder the code retried. This is the classic throttling trap — and the fix is not "retry more," it's "retry when the service tells you to."
The thesis: Microsoft Graph throttling is dynamic and per-workload, so the only reliable strategy is honoring the Retry-After header, bounding your attempts, and reducing request volume up front. Anything else is guessing against a moving target.
What a 429 actually means
When Graph throttles you, it returns HTTP 429 (Too Many Requests) — occasionally 503 — usually with a Retry-After header containing the number of seconds to wait before your next attempt. Throttling is evaluated per application and per tenant, and the limits are not published as fixed numbers. They vary by resource (Teams messages, Outlook mail, SharePoint files all behave differently) and by your request pattern.
That last point kills the naive approach. If you retry immediately — or on a fixed 1-second timer — you keep hitting the service inside the throttle window, which typically extends it. A tight retry loop can turn a 5-second cooldown into minutes of sustained failures.
A Retry-After-aware retry pattern
The pattern that works combines three rules: wait exactly what Retry-After says, fall back to exponential backoff with jitter when the header is absent, and cap total attempts so a real outage doesn't get masked by an infinite loop.
// Conceptual C# pattern — for raw HttpClient calls, NOT needed with the Graph SDK
async Task<HttpResponseMessage> SendWithRetryAsync(
Func<Task<HttpResponseMessage>> send, int maxAttempts = 5)
{
for (int attempt = 1; ; attempt++)
{
var response = await send();
if (response.IsSuccessStatusCode)
return response;
int status = (int)response.StatusCode;
bool retryable = status == 429 || status == 503;
// Fail fast on client errors — retrying a 401/403 never helps
if (!retryable || attempt == maxAttempts)
return response;
TimeSpan delay = response.Headers.RetryAfter?.Delta
?? TimeSpan.FromSeconds(Math.Pow(2, attempt))
+ TimeSpan.FromMilliseconds(Random.Shared.Next(0, 500)); // jitter
await Task.Delay(delay);
}
}Run this wherever your HTTP calls originate; it needs no special permissions beyond what the Graph calls themselves require. The jitter matters when many instances of your app get throttled simultaneously — without it, they all wake up at the same second and re-throttle each other.
Don't double-wrap the SDK's retry handler
If you're using the Microsoft Graph .NET SDK (or the Java/JS equivalents), it already ships a retry handler that respects Retry-After by default. Stacking a custom Polly policy on top produces compounding delays — your policy waits, then the SDK waits, and a single request can stall for far longer than intended.
Instead, tune the SDK's own handler. In the .NET SDK you can configure RetryHandlerOption (max retry count, max delay) on the HttpClient pipeline. Verify the behavior in a unit test by mocking a 429 response with a Retry-After header and asserting the handler waits the specified duration and stops at your attempt cap.
The cheapest throttle fix is fewer requests
Retries are a safety net, not a strategy. The most effective throttle reduction comes from sending less traffic:
- Use
$selectto trim payloads — smaller responses are cheaper for the service and for you. - Use delta queries for sync scenarios instead of re-reading entire collections.
- Batch with
$batchwhere the workload supports it (up to 20 requests per batch), keeping in mind batched requests still count toward throttling individually. - Log every 429 with the endpoint and
Retry-Aftervalue. A rising throttle count is an early warning that your request pattern needs redesign, not just better retries.
Limitations and how to verify
Throttle behavior is workload-specific and can change without notice — never hardcode an assumed requests-per-second quota. Some 503 responses arrive without Retry-After, which is why the backoff fallback exists. And bounded retries mean some requests will ultimately fail; your callers need to handle that as a first-class outcome, not an exception nobody catches.
To verify your handler works, point it at a test tenant and fire a burst of rapid requests (for example, repeated GET /users calls). Confirm your logs show 429s being captured, the wait matching the Retry-After value, and the loop terminating at your attempt cap. Also review the current Microsoft Graph throttling guidance for your specific workload before shipping — per-endpoint limits and headers evolve, and this behavior is worth re-checking against the documentation at implementation time.
The actionable takeaway: if you use the SDK, configure its retry handler and write a unit test against a mocked 429. If you call Graph over raw HTTP, implement the pattern above. Either way, treat throttle logs as a signal to cut request volume — that's the fix that actually scales.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.