Microsoft Graph in Practice #7: Operations — Throttling, Pagination, Delta Queries, Change Notifications
The series closes with operations. What parts 4–6 built was code that “works.” This part turns it into code that runs for months without silently truncating data or stalling — through four topics: throttling, pagination, delta queries, and change notifications. It’s also where the operational instincts from the infrastructure posts carry over to an API.
Throttling — 429 is a contract, not an error #
Graph enforces per-service call limits and returns 429 Too Many Requests beyond them. Three rules matter.
- Wait the seconds given in the
Retry-Afterheader, then retry. That’s the fastest recovery. Ignoring it and retrying immediately still counts against your usage and extends the throttle. - Limits aren’t one global number — they’re per service, per tenant, per app (the Outlook, SharePoint, and Teams families all differ). Rather than memorizing numbers, encode the behavior: back off when you hit a 429.
- A few resources don’t return Retry-After; fall back to exponential backoff there.
The good news: the SDK already does this. msgraph-sdk’s default middleware retries 429s and 5xxs honoring Retry-After (or exponential backoff without it). So the practical focus shifts from implementing retries to designs that get throttled less:
- Shrink responses with
$select(the habit from part 1). - Replace polling with delta queries or change notifications (below).
- Cap concurrency on bulk traversals. Firing hundreds of asyncio requests at once is a 429 factory; bounding to 4–8 concurrent with an
asyncio.Semaphoreis a sane starting point.
Pagination — skip nextLink and your data silently truncates #
List APIs don’t return everything at once: you get one page (tens to hundreds of items) plus @odata.nextLink, the URL of the next page — and if you don’t follow it, the rest silently vanishes. “Why are there only 100 users?” — this is almost always the cause.
async def all_pages(client, first_response):
"""Iterate every page of any list response."""
page = first_response
while page:
for item in page.value or []:
yield item
if not page.odata_next_link:
break
page = await client.users.with_url(page.odata_next_link).get()
# usage
result = await client.users.get()
async for user in all_pages(client, result):
print(user.display_name)with_url() is the SDK’s channel for requesting an already-complete URL like nextLink. The original query ($select and friends) is preserved inside the nextLink, so just follow it. Keeping one such helper and making every list automation use it is the structural fix for truncation accidents.
Delta queries — managing “only what changed” as state #
Polling — re-reading the whole mailbox every hour to find new mail — is slow, invites throttling, and is mostly waste (change is rare). Graph’s answer is the delta query: the first call returns full state plus an @odata.deltaLink (a bookmark) to store; subsequent calls to the deltaLink return only what changed since.
async def sync_users(client, saved_delta_link: str | None):
if saved_delta_link:
page = await client.users.with_url(saved_delta_link).get()
else:
page = await client.users.delta.get() # initial full sync
changes = []
while page:
changes.extend(page.value or [])
if page.odata_next_link: # changes paginate too
page = await client.users.with_url(page.odata_next_link).get()
else:
new_delta_link = page.odata_delta_link
break
# process changes: deletions arrive flagged with @removed
return changes, new_delta_link # persist the new bookmarkThree operating rules: the deltaLink is state — persist it (file or DB); deletions arrive as @removed annotations and must be handled; and if the deltaLink has aged out (410 Gone), restart with a full sync. Supported resources are the high-sync-demand ones: users, groups, messages, calendarView, driveItem, Teams chatMessage. “Periodic batch + delta” cuts call volume by orders of magnitude versus polling — and on the SharePoint side, delta-with-token requests are even billed at a lower throttling cost.
Change notifications — flipping to push #
If even a batch interval is too slow (reacting to new mail within seconds), flip the direction: change notifications (subscriptions/webhooks) have Graph push change events to your HTTPS endpoint.
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI, Request, Response
from msgraph.generated.models.subscription import Subscription
app = FastAPI()
@app.post("/graph/notify")
async def notify(request: Request):
# ① validation at subscription creation: echo validationToken
# as text/plain within 10 seconds
token = request.query_params.get("validationToken")
if token:
return Response(content=token, media_type="text/plain")
# ② real notifications: verify clientState, enqueue, return 202 fast
payload = await request.json()
for note in payload.get("value", []):
if note.get("clientState") != EXPECTED_STATE:
continue
enqueue(note["resource"]) # detail fetches happen in a worker
return Response(status_code=202)
async def subscribe(client):
sub = Subscription(
change_type="created",
notification_url="https://automation.contoso.com/graph/notify",
resource="users/{id}/mailFolders('inbox')/messages",
expiration_date_time=datetime.now(timezone.utc) + timedelta(days=2),
client_state=EXPECTED_STATE, # secret for telling forgeries apart
)
return await client.subscriptions.post(sub)The operationally important properties:
- Subscriptions expire. Maximum lifetimes vary by resource (mail/calendar under 7 days, driveItem under 30, Teams messages down to hour-scale under certain conditions), so scheduling the renewal (PATCH) is part of the implementation. Stop renewing and the notifications stop, silently.
- By default, notifications carry only that something changed, plus the resource path; the receiver re-queries for content. Combine that re-query with delta (notification as trigger, delta for consistency) and the structure survives lost notifications.
- Some resources (Teams among them) require a lifecycleNotificationUrl — a separate channel for “subscription removed / reauthorization needed” events. Even where optional, adding it improves resilience.
- The endpoint must be public HTTPS; for intranet-only environments, a thin receiver such as an Azure Function in front is the common shape.
From polling → batch+delta → notifications+delta, immediacy rises while moving parts (endpoint, renewal schedule, state) accumulate. Choose the simplest tier that meets the required reaction time — most internal automation ends at batch+delta.
The last tool — JSON batching #
$batch bundles up to 20 independent requests into one HTTP call — worth knowing for fan-outs like “fetch 20 users’ profiles.” But batching reduces round trips, while throttling still counts every inner request: it’s a remedy for the round-trip multiplication problem, not for 429s. Keep that distinction exact.
Summary — closing the series #
- Honor Retry-After on 429 (the SDK does by default). The real work is designs that get throttled less: $select, bounded concurrency, polling replaced.
- Lists aren’t complete until nextLink is followed to the end. One enforced all-pages helper structurally prevents truncation.
- Recurring syncs become delta queries. Persist the deltaLink, handle @removed, full-resync on 410 — that’s the whole operating manual.
- For real-time, add change notifications: validation handshake, clientState checks, and a renewal schedule as one package. Use notifications as triggers and delta for consistency.
- $batch cuts round trips, not throttling.
The whole series folds into one sentence: decide the app registration and permission type precisely (part 2), stack scenarios (parts 4–6) on the SDK skeleton (part 3), add the four operational pieces (part 7) — and any workplace automation on Microsoft 365 is built the same way. Natural extensions from here: Entra ID account automation (on/offboarding), report generation via the Excel APIs, and dividing labor with Power Automate.