How-to
Automating Reporting WorkflowsDeep dive

Handle Microsoft Graph Throttling in Excel Automation

Keep Excel Online automation running when Microsoft Graph pushes back — honour Retry-After on 429 and 503 responses, back off with jitter on 504s, never blindly retry non-idempotent writes, run one worker per workbook, and handle throttled requests inside JSON batches. Includes a retry client you can test without a network.

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.

What to do with each Graph response A 2xx response is returned. A 429 or 503 is retried after the Retry-After delay, or an exponential backoff when the header is missing. A 504 is retried with backoff, but only automatically for idempotent requests. A 401 triggers a token refresh and one retry. A 400, 403 or 404 fails immediately with the response body in the error. What did Graph answer? 429, 503 wait and retry honour Retry-After 504 retry if idempotent check writes first 401 refresh token retry once 400, 403, 404 fail fast will fail again

Prerequisites

Bash
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:

Python
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:

Python
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.

The retry loop for one Graph request Send the request. On success return it. On 401 refresh the token once and resend. On 429 or 503, and on 504 for idempotent requests, compute the wait from Retry-After or backoff with jitter, stop if the deadline would be exceeded, otherwise sleep and resend. Any other error fails immediately. Send method + url Classify status 2xx, 401, 429… Compute wait Retry-After or jitter Check deadline give up cleanly Sleep and resend attempt + 1

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:

Python
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.

Python
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:

Python
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:

Python
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.

Many small requests or fewer large ones Sending one request per cell or row, in parallel against the same workbook, maximises throttling and conflicts. Reading and writing whole ranges sequentially per workbook, and batching independent requests across workbooks, keeps request counts low and throttling rare. Many small requests one call per cell or row parallel writes to one file throttling in bursts conflicting edits Fewer, larger requests whole ranges per call one writer per workbook $batch across workbooks throttling becomes rare

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.

More on excel online & microsoft graph

More on excel online & microsoft graph →