Handle Microsoft Graph Throttling in Excel Automation
Excel automation through Microsoft Graph works smoothly at low volume and starts failing in bursts as soon as a job does real work: loading thousands of rows, recalculating many workbooks, calling functions in a loop. Graph protects its services by throttling — responses with status 429 and a Retry-After header — and long operations surface as 504 gateway timeouts. A job that treats these as fatal errors fails every few runs; one that retries blindly can double-write data. This guide, part of Automating Excel Online with Microsoft Graph, builds a small retry layer that does the right thing for each case, and tests it without touching the network.
Prerequisites
pip install requests
The client below takes the function that sends a request as an argument, so it can wrap a requests.Session in production and a fake in tests. Everything in this guide runs offline.
Step 1 — Read Retry-After correctly
Retry-After is either a number of seconds or an HTTP date. Parse both, and fall back to exponential backoff with jitter when the header is missing:
import email.utils
import random
import time
from datetime import datetime, timezone
def retry_after_seconds(headers, now=None):
value = headers.get("Retry-After")
if value is None:
return None
if value.strip().isdigit():
return float(value)
moment = email.utils.parsedate_to_datetime(value)
now = now or datetime.now(timezone.utc)
return max(0.0, (moment - now).total_seconds())
def backoff(attempt, base=1.0, cap=60.0):
return random.uniform(0, min(cap, base * 2 ** attempt)) # "full jitter"
print(retry_after_seconds({"Retry-After": "12"})) # 12.0
fixed_now = datetime(2026, 9, 23, 12, 0, 0, tzinfo=timezone.utc)
print(retry_after_seconds({"Retry-After": "Wed, 23 Sep 2026 12:00:30 GMT"}, now=fixed_now)) # 30.0
Jitter — a random delay up to the backoff limit rather than exactly the limit — matters when several jobs are throttled at the same moment: without it, they all retry at the same instant and are throttled again together.
Step 2 — A retry loop that knows which calls are safe
The loop retries 429 and 503 always, retries 504 only for requests marked idempotent, refreshes the token once on 401, and gives up at a deadline rather than after a fixed number of tries, so a job cannot hang for hours:
RETRY_ALWAYS = {429, 503}
class GraphError(RuntimeError):
pass
def call_graph(send, method, url, *, idempotent, refresh_token=None,
deadline_s=300, sleep=time.sleep, clock=time.monotonic, **kwargs):
start, attempt, refreshed = clock(), 0, False
while True:
response = send(method, url, **kwargs)
status = response.status_code
if status < 400:
return response
if status == 401 and refresh_token and not refreshed:
refresh_token()
refreshed = True
continue
retryable = status in RETRY_ALWAYS or (status == 504 and idempotent)
if not retryable:
raise GraphError(f"{method} {url} failed with {status}: {response.text[:200]}")
wait = retry_after_seconds(response.headers) or backoff(attempt)
if clock() - start + wait > deadline_s:
raise GraphError(f"{method} {url} still throttled after {attempt + 1} attempts")
sleep(wait)
attempt += 1
idempotent is the caller's promise that sending the request twice has the same effect as once — true for GETs and for PATCHing a range with fixed values, false for adding table rows. Passing sleep and clock in makes the loop testable at full speed.
Step 3 — Test it with a fake transport
A fake send returns scripted responses, and a fake clock records the waits. The test proves the loop honours Retry-After, then succeeds:
class FakeResponse:
def __init__(self, status_code, headers=None, text=""):
self.status_code, self.headers, self.text = status_code, headers or {}, text
def scripted(*responses):
queue = list(responses)
calls = []
def send(method, url, **kwargs):
calls.append((method, url))
return queue.pop(0)
return send, calls
waits = []
send, calls = scripted(FakeResponse(429, {"Retry-After": "7"}), FakeResponse(503), FakeResponse(200))
result = call_graph(send, "GET", "/workbook/worksheets", idempotent=True,
sleep=waits.append, clock=lambda: 0.0)
print(result.status_code, len(calls), waits[0]) # 200 3 7.0
The same harness checks the dangerous case: a 504 on a non-idempotent POST must not be resent automatically.
send, calls = scripted(FakeResponse(504, text="gateway timeout"))
try:
call_graph(send, "POST", "/tables/Sales/rows/add", idempotent=False, sleep=lambda s: None)
except GraphError as exc:
print(len(calls), "call:", str(exc)[:43]) # 1 call: POST /tables/Sales/rows/add failed with 504
Tests like these run in milliseconds in CI and pin down behaviour that is otherwise only exercised on a bad day in production, when nobody has time to read the retry code.
Step 4 — Make non-idempotent writes safe to repeat
A 504 on "add these rows" is ambiguous: the rows may or may not have been added. Resending blindly risks duplicates. Instead, make the write checkable — give each batch a key that lands in the table, and before resending, read back whether that key is already there:
def append_rows_safely(send, table_url, rows, batch_key):
payload = {"values": [row + [batch_key] for row in rows]}
try:
return call_graph(send, "POST", f"{table_url}/rows/add", idempotent=False, json=payload)
except GraphError:
existing = call_graph(send, "GET", f"{table_url}/columns('BatchKey')/range", idempotent=True)
if batch_key in str(existing.text):
return existing # the first attempt did land
return call_graph(send, "POST", f"{table_url}/rows/add", idempotent=False, json=payload)
send, calls = scripted(FakeResponse(504), FakeResponse(200, text='{"values": [["BatchKey"], ["2026-09-23-a"]]}'))
append_rows_safely(send, "/tables/Sales", [["North", 120]], "2026-09-23-a")
print([c[0] for c in calls]) # ['POST', 'GET'] — not sent twice
The batch key column costs one extra column in the table and turns "maybe added" into a question you can answer. Appending rows in general is covered in Append Rows to an Excel Online Table with Microsoft Graph.
Step 5 — Fewer requests: one writer per workbook, and batching
The best retry is the request you do not send. Two habits cut throttling sharply. First, run requests against one workbook sequentially — one worker per file — and parallelise only across different files; concurrent writes to the same workbook both raise throttling and risk conflicts. Second, combine many small reads into larger ones: one GET of a 2,000-row range instead of 2,000 single-cell GETs, and Graph's JSON batching endpoint, which carries up to 20 requests in one HTTP call:
def batch_body(requests_):
return {"requests": [{"id": str(i), "method": m, "url": u} for i, (m, u) in enumerate(requests_, 1)]}
def throttled_items(batch_response_json):
return [r["id"] for r in batch_response_json["responses"] if r["status"] in (429, 503)]
body = batch_body([("GET", "/me/drive/items/A/workbook/worksheets"),
("GET", "/me/drive/items/B/workbook/worksheets")])
reply = {"responses": [{"id": "1", "status": 200}, {"id": "2", "status": 429, "headers": {"Retry-After": "5"}}]}
print(len(body["requests"]), throttled_items(reply)) # 2 ['2']
A batch can succeed as a whole while individual requests inside it are throttled, each with its own status and Retry-After. Retry only those sub-requests, with the same rules as single calls — and keep batches that touch one workbook in order with the dependsOn property, or better, batch across different workbooks.
Common pitfalls
- Logging only the final failure. Log every throttled response with its status and wait. A rising count of 429s is the early warning that a job needs fewer, larger requests before it starts failing.
- Retrying 400s. A malformed request fails the same way every time; retries only delay the error and add load.
- Ignoring Retry-After. Retrying sooner than asked extends the throttling window for your whole application.
- Unbounded retries. Without a deadline, a job can sit in a sleep loop long after anyone needs the result. Fail with a clear message instead, and let the scheduler alert — see Send an Alert When an Excel Report Job Fails.
Conclusion
Treat Graph throttling as normal operation: retry 429 and 503 after the Retry-After delay, back off with jitter when no delay is given, retry 504 automatically only for idempotent calls, refresh the token once on 401 and fail fast on client errors — all within a deadline. Make non-idempotent writes checkable with a batch key, send requests to one workbook sequentially, read and write whole ranges, batch across workbooks, and test the retry layer offline with a fake transport.
Frequently asked questions
What does a 429 response from Microsoft Graph mean? The service is throttling your application or the workbook because of too many requests in a short time. The response usually carries a Retry-After header saying how many seconds to wait before trying again.
Which Graph errors should an Excel job retry? 429 Too Many Requests, 503 Service Unavailable and 504 Gateway Timeout are transient and worth retrying with a delay. 400, 403 and 404 mean the request itself is wrong and will fail again, so fail fast and report them.
Is it safe to retry a request that timed out? For reads and for writes that set values in place, yes. For requests that add something — appending table rows, adding a sheet — a timeout may hide a request that actually succeeded, so check whether the change happened before sending it again.
Does sending requests in parallel make the job faster? Not against one workbook. Parallel writes to the same file increase throttling and can conflict. Run requests for a given workbook one after another, and parallelise only across different workbooks.
Related
- Up one level: Automating Excel Online with Microsoft Graph — Excel files in OneDrive and SharePoint.
- Call Excel Worksheet Functions Through Microsoft Graph — calls that benefit from this retry layer.
- Recalculate Excel Online Workbooks with Graph Sessions — long operations and 504s.
- Retry a Failed Excel Report Job in Python — retries at the level of the whole job.
More on excel online & microsoft graph
- How-toSend an Excel Report with the Microsoft Graph API
- How-toAdd a Chart to an Excel Online Workbook with Microsoft Graph
- How-toAdd, Rename and Delete Worksheets with Microsoft Graph
- How-toAppend Rows to an Excel Online Table with Microsoft Graph
- How-toAuthenticate to Microsoft Graph with MSAL for Excel Automation
- How-toCall Excel Worksheet Functions Through Microsoft Graph