A colleague sent me a script last month with a note: “Runs great, ~40 minutes for the whole directory, no errors.” He wanted to know why the same logic against raw Invoke-RestMethod kept dying with 429s. The answer is that his script was getting throttled. He just never saw it.
The Microsoft.Graph PowerShell SDK ships with a retry handler in its HTTP pipeline. When Graph returns a 429 Too Many Requests, the handler reads the Retry-After header, waits exactly that long, and reissues the request — with exponential backoff for transient failures that don’t carry the header. It does this silently, up to a bounded number of attempts. Practical365 walked through the mechanics in detail this week, and the part worth sitting with is the second-order effect: your script’s success is no longer evidence that you’re operating within limits.
Let me play both sides here, because the pushback I get is fair.
“So the SDK handles it. Why do I care what happens under the hood?”
Because time is the thing you’re not measuring. A 40-minute run that spent 12 of those minutes parked in Retry-After waits looks identical to a healthy 40-minute run. Then someone schedules three of these in parallel during a sync window, the tenant-wide budget tightens, backoff stretches, and your “reliable” job blows its maintenance window. You didn’t get slower. You were always this close to the wall — the handler just kept you from feeling it.
“Fine, then I’ll turn the handler off so I can see the failures.”
No. The retry handler is the correct default and it makes your automation more resilient, not less. The goal isn’t to remove it — it’s to make it observable. You want the retries and a record that they happened.
The cheapest way to see them is the SDK’s own debug channel, which surfaces the raw HTTP exchange including the retried requests:
That’s fine for a one-off. For anything running unattended, redirect the debug stream to a log and count 429 occurrences per run — that count is your real throttle signal, and it’s the number that predicts trouble long before a hard failure does.
“And raw Invoke-RestMethod?”
You see everything, because you’re handling everything. There’s no pipeline doing you favors: a 429 is a terminating error unless you catch it, read Retry-After yourself, sleep, and retry. Honest, but you’re reimplementing what the SDK already got right. Use raw calls when you genuinely need the visibility or an endpoint the cmdlets don’t cover — not as a lifestyle.
Which operations actually hit this? Predictable ones. Full user or group enumeration, because -All fans out into page after page. Bulk membership changes across large groups. Anything that overlaps a directory sync cycle, because provisioning is already spending the tenant’s budget. If you’re bulk-modifying, drive it from a reviewed CSV, not a live query, and keep -WhatIf on until you’ve seen the plan:
On the numbers: Microsoft documents throttling limits per service and per endpoint, and they vary by workload and tenant, so I won’t quote a magic requests-per-second here. Read the Graph throttling guidance for the service you’re actually calling.
Last question, because everyone asks it: does /$batch buy you more headroom? No. Batching lets you bundle multiple requests into one HTTP call, but each inner request is still evaluated against its own limits. You’re saving network overhead, not raising the ceiling. Treat it as a latency optimisation, never as a way to outrun a throttle.
Instrument the retries. A run with zero errors and forty 429s is not a healthy run — it’s a warning you chose not to log.
