[{"data":1,"prerenderedAt":2492},["ShallowReactive",2],{"doc:\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel":3,"surround:\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel":2460,"ancestors:\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel":2469,"children:\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel":2474,"related:\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel":2475},{"id":4,"title":5,"body":6,"dateModified":2425,"datePublished":2425,"description":2426,"extension":2427,"faq":2428,"keyword":2437,"kind":2438,"meta":2439,"navigation":294,"path":2448,"seo":2449,"slug":2452,"stem":2453,"topics":2454,"type":2458,"__hash__":2459},"docs\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel\u002Findex.md","Handle Microsoft Graph Throttling in Excel Automation",{"type":7,"value":8,"toc":2413},"minimark",[9,24,197,202,226,233,237,242,603,606,610,613,1043,1057,1206,1210,1220,1485,1488,1601,1604,1608,1611,1894,1902,1906,1909,2164,2174,2301,2305,2338,2342,2348,2352,2358,2364,2370,2376,2380,2409],[10,11,12,13,17,18,23],"p",{},"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 ",[14,15,16],"code",{},"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 ",[19,20,22],"a",{"href":21},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002F","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.",[25,26,34,35,34,39,34,43,34,51,34,61,34,72,34,78,34,83,34,87,34,90,34,95,34,104,34,109,34,117,34,122,34,127,34,130,34,133,34,137,34,140,34,145,34,149,34,152,34,155,34,158,34,162,34,165,34,168,34,171,34,174,34,176,34,179,34,183,34,186,34,190,34,194],"svg",{"viewBox":27,"role":28,"ariaLabelledBy":29,"xmlns":32,"style":33},"4 0 752 238","img",[30,31],"gt-status-t","gt-status-d","http:\u002F\u002Fwww.w3.org\u002F2000\u002Fsvg","width:100%;max-width:752px;height:auto;display:block;margin:1.5rem auto;font-family:Inter,ui-sans-serif,system-ui,sans-serif","\n  ",[36,37,38],"title",{"id":30},"What to do with each Graph response",[40,41,42],"desc",{"id":31},"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.",[44,45],"rect",{"x":46,"y":47,"width":48,"height":49,"fill":50},"4","0","752","238","#ffffff",[44,52],{"x":53,"y":54,"width":55,"height":56,"rx":57,"fill":58,"stroke":59,"style":60},"240.0","16.0","280.0","75.5","12","#f0f4ff","var(--brand,#5b5cf0)","stroke-width:2px",[62,63,65],"g",{"transform":64},"translate(368.0,29.0) scale(1.000)",[66,67],"path",{"d":68,"fill":69,"stroke":70,"style":71},"M8 7l-5 5 5 5 M16 7l5 5-5 5 M14 4l-4 16","none","var(--brand-strong,#4338ca)","stroke-width:1.80px;stroke-linecap:round;stroke-linejoin:round",[73,74,77],"text",{"x":75,"y":56,"style":76},"380.0","font-size:13.5px;font-weight:700;fill:var(--brand-strong,#4338ca);text-anchor:middle","What did Graph answer?",[79,80],"line",{"x1":75,"y1":81,"x2":75,"y2":82,"stroke":59,"style":60},"91.5","111.5",[79,84],{"x1":85,"y1":82,"x2":86,"y2":82,"stroke":59,"style":60},"104.0","656.0",[79,88],{"x1":85,"y1":82,"x2":85,"y2":89,"stroke":59,"style":60},"157.5",[91,92],"polygon",{"points":93,"fill":94},"104.0,161.5 99.0,152.5 109.0,152.5","#5b5cf0",[44,96],{"x":97,"y":98,"width":99,"height":100,"rx":101,"fill":50,"stroke":102,"style":103},"68.0","121.5","72.0","21.0","10","var(--line,#cdd5e6)","stroke-width:1.2px",[73,105,108],{"x":85,"y":106,"style":107},"136.0","font-size:11.5px;font-weight:700;fill:var(--muted,#5b6780);text-anchor:middle","429, 503",[44,110],{"x":111,"y":112,"width":113,"height":114,"rx":57,"fill":115,"stroke":116,"style":60},"20.0","163.5","168.0","59.0","#fdefd8","var(--gold,#b4740a)",[73,118,121],{"x":85,"y":119,"style":120},"189.0","font-size:13.5px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","wait and retry",[73,123,126],{"x":85,"y":124,"style":125},"208.5","font-size:11.5px;font-weight:400;fill:var(--text,#172033);text-anchor:middle","honour Retry-After",[79,128],{"x1":129,"y1":82,"x2":129,"y2":89,"stroke":59,"style":60},"288.0",[91,131],{"points":132,"fill":94},"288.0,161.5 283.0,152.5 293.0,152.5",[44,134],{"x":135,"y":98,"width":136,"height":100,"rx":101,"fill":50,"stroke":102,"style":103},"267.1","41.8",[73,138,139],{"x":129,"y":106,"style":107},"504",[44,141],{"x":142,"y":112,"width":113,"height":114,"rx":57,"fill":143,"stroke":144,"style":60},"204.0","#fee8f2","var(--accent,#f43f8f)",[73,146,148],{"x":129,"y":119,"style":147},"font-size:13.5px;font-weight:700;fill:var(--accent-ink,#be185d);text-anchor:middle","retry if idempotent",[73,150,151],{"x":129,"y":124,"style":125},"check writes first",[79,153],{"x1":154,"y1":82,"x2":154,"y2":89,"stroke":59,"style":60},"472.0",[91,156],{"points":157,"fill":94},"472.0,161.5 467.0,152.5 477.0,152.5",[44,159],{"x":160,"y":98,"width":161,"height":100,"rx":101,"fill":50,"stroke":102,"style":103},"452.2","39.5",[73,163,164],{"x":154,"y":106,"style":107},"401",[44,166],{"x":167,"y":112,"width":113,"height":114,"rx":57,"fill":58,"stroke":59,"style":60},"388.0",[73,169,170],{"x":154,"y":119,"style":76},"refresh token",[73,172,173],{"x":154,"y":124,"style":125},"retry once",[79,175],{"x1":86,"y1":82,"x2":86,"y2":89,"stroke":59,"style":60},[91,177],{"points":178,"fill":94},"656.0,161.5 651.0,152.5 661.0,152.5",[44,180],{"x":181,"y":98,"width":182,"height":100,"rx":101,"fill":50,"stroke":102,"style":103},"603.6","104.8",[73,184,185],{"x":86,"y":106,"style":107},"400, 403, 404",[44,187],{"x":188,"y":112,"width":113,"height":114,"rx":57,"fill":189,"stroke":102,"style":60},"572.0","#e7ebef",[73,191,193],{"x":86,"y":119,"style":192},"font-size:13.5px;font-weight:700;fill:var(--text,#172033);text-anchor:middle","fail fast",[73,195,196],{"x":86,"y":124,"style":125},"will fail again",[198,199,201],"h2",{"id":200},"prerequisites","Prerequisites",[203,204,209],"pre",{"className":205,"code":206,"language":207,"meta":208,"style":208},"language-bash shiki shiki-themes github-light github-dark-high-contrast","pip install requests\n","bash","",[14,210,211],{"__ignoreMap":208},[212,213,215,219,223],"span",{"class":79,"line":214},1,[212,216,218],{"class":217},"sMTad","pip",[212,220,222],{"class":221},"srMev"," install",[212,224,225],{"class":221}," requests\n",[10,227,228,229,232],{},"The client below takes the function that sends a request as an argument, so it can wrap a ",[14,230,231],{},"requests.Session"," in production and a fake in tests. Everything in this guide runs offline.",[198,234,236],{"id":235},"step-1-read-retry-after-correctly","Step 1 — Read Retry-After correctly",[10,238,239,241],{},[14,240,16],{}," 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:",[203,243,247],{"className":244,"code":245,"language":246,"meta":208,"style":208},"language-python shiki shiki-themes github-light github-dark-high-contrast","import email.utils\nimport random\nimport time\nfrom datetime import datetime, timezone\n\ndef retry_after_seconds(headers, now=None):\n    value = headers.get(\"Retry-After\")\n    if value is None:\n        return None\n    if value.strip().isdigit():\n        return float(value)\n    moment = email.utils.parsedate_to_datetime(value)\n    now = now or datetime.now(timezone.utc)\n    return max(0.0, (moment - now).total_seconds())\n\ndef backoff(attempt, base=1.0, cap=60.0):\n    return random.uniform(0, min(cap, base * 2 ** attempt))       # \"full jitter\"\n\nprint(retry_after_seconds({\"Retry-After\": \"12\"}))                 # 12.0\nfixed_now = datetime(2026, 9, 23, 12, 0, 0, tzinfo=timezone.utc)\nprint(retry_after_seconds({\"Retry-After\": \"Wed, 23 Sep 2026 12:00:30 GMT\"}, now=fixed_now))   # 30.0\n","python",[14,248,249,259,267,275,289,296,319,336,354,363,371,382,393,410,434,439,465,500,505,528,575],{"__ignoreMap":208},[212,250,251,255],{"class":79,"line":214},[212,252,254],{"class":253},"s-kum","import",[212,256,258],{"class":257},"skGVy"," email.utils\n",[212,260,262,264],{"class":79,"line":261},2,[212,263,254],{"class":253},[212,265,266],{"class":257}," random\n",[212,268,270,272],{"class":79,"line":269},3,[212,271,254],{"class":253},[212,273,274],{"class":257}," time\n",[212,276,278,281,284,286],{"class":79,"line":277},4,[212,279,280],{"class":253},"from",[212,282,283],{"class":257}," datetime ",[212,285,254],{"class":253},[212,287,288],{"class":257}," datetime, timezone\n",[212,290,292],{"class":79,"line":291},5,[212,293,295],{"emptyLinePlaceholder":294},true,"\n",[212,297,299,302,306,309,312,316],{"class":79,"line":298},6,[212,300,301],{"class":253},"def",[212,303,305],{"class":304},"s_Opv"," retry_after_seconds",[212,307,308],{"class":257},"(headers, now",[212,310,311],{"class":253},"=",[212,313,315],{"class":314},"sP0c6","None",[212,317,318],{"class":257},"):\n",[212,320,322,325,327,330,333],{"class":79,"line":321},7,[212,323,324],{"class":257},"    value ",[212,326,311],{"class":253},[212,328,329],{"class":257}," headers.get(",[212,331,332],{"class":221},"\"Retry-After\"",[212,334,335],{"class":257},")\n",[212,337,339,342,345,348,351],{"class":79,"line":338},8,[212,340,341],{"class":253},"    if",[212,343,344],{"class":257}," value ",[212,346,347],{"class":253},"is",[212,349,350],{"class":314}," None",[212,352,353],{"class":257},":\n",[212,355,357,360],{"class":79,"line":356},9,[212,358,359],{"class":253},"        return",[212,361,362],{"class":314}," None\n",[212,364,366,368],{"class":79,"line":365},10,[212,367,341],{"class":253},[212,369,370],{"class":257}," value.strip().isdigit():\n",[212,372,374,376,379],{"class":79,"line":373},11,[212,375,359],{"class":253},[212,377,378],{"class":314}," float",[212,380,381],{"class":257},"(value)\n",[212,383,385,388,390],{"class":79,"line":384},12,[212,386,387],{"class":257},"    moment ",[212,389,311],{"class":253},[212,391,392],{"class":257}," email.utils.parsedate_to_datetime(value)\n",[212,394,396,399,401,404,407],{"class":79,"line":395},13,[212,397,398],{"class":257},"    now ",[212,400,311],{"class":253},[212,402,403],{"class":257}," now ",[212,405,406],{"class":253},"or",[212,408,409],{"class":257}," datetime.now(timezone.utc)\n",[212,411,413,416,419,422,425,428,431],{"class":79,"line":412},14,[212,414,415],{"class":253},"    return",[212,417,418],{"class":314}," max",[212,420,421],{"class":257},"(",[212,423,424],{"class":314},"0.0",[212,426,427],{"class":257},", (moment ",[212,429,430],{"class":253},"-",[212,432,433],{"class":257}," now).total_seconds())\n",[212,435,437],{"class":79,"line":436},15,[212,438,295],{"emptyLinePlaceholder":294},[212,440,442,444,447,450,452,455,458,460,463],{"class":79,"line":441},16,[212,443,301],{"class":253},[212,445,446],{"class":304}," backoff",[212,448,449],{"class":257},"(attempt, base",[212,451,311],{"class":253},[212,453,454],{"class":314},"1.0",[212,456,457],{"class":257},", cap",[212,459,311],{"class":253},[212,461,462],{"class":314},"60.0",[212,464,318],{"class":257},[212,466,468,470,473,475,478,481,484,487,490,493,496],{"class":79,"line":467},17,[212,469,415],{"class":253},[212,471,472],{"class":257}," random.uniform(",[212,474,47],{"class":314},[212,476,477],{"class":257},", ",[212,479,480],{"class":314},"min",[212,482,483],{"class":257},"(cap, base ",[212,485,486],{"class":253},"*",[212,488,489],{"class":314}," 2",[212,491,492],{"class":253}," **",[212,494,495],{"class":257}," attempt))       ",[212,497,499],{"class":498},"s-wDw","# \"full jitter\"\n",[212,501,503],{"class":79,"line":502},18,[212,504,295],{"emptyLinePlaceholder":294},[212,506,508,511,514,516,519,522,525],{"class":79,"line":507},19,[212,509,510],{"class":314},"print",[212,512,513],{"class":257},"(retry_after_seconds({",[212,515,332],{"class":221},[212,517,518],{"class":257},": ",[212,520,521],{"class":221},"\"12\"",[212,523,524],{"class":257},"}))                 ",[212,526,527],{"class":498},"# 12.0\n",[212,529,531,534,536,539,542,544,547,549,552,554,556,558,560,562,564,566,570,572],{"class":79,"line":530},20,[212,532,533],{"class":257},"fixed_now ",[212,535,311],{"class":253},[212,537,538],{"class":257}," datetime(",[212,540,541],{"class":314},"2026",[212,543,477],{"class":257},[212,545,546],{"class":314},"9",[212,548,477],{"class":257},[212,550,551],{"class":314},"23",[212,553,477],{"class":257},[212,555,57],{"class":314},[212,557,477],{"class":257},[212,559,47],{"class":314},[212,561,477],{"class":257},[212,563,47],{"class":314},[212,565,477],{"class":257},[212,567,569],{"class":568},"sa561","tzinfo",[212,571,311],{"class":253},[212,573,574],{"class":257},"timezone.utc)\n",[212,576,578,580,582,584,586,589,592,595,597,600],{"class":79,"line":577},21,[212,579,510],{"class":314},[212,581,513],{"class":257},[212,583,332],{"class":221},[212,585,518],{"class":257},[212,587,588],{"class":221},"\"Wed, 23 Sep 2026 12:00:30 GMT\"",[212,590,591],{"class":257},"}, ",[212,593,594],{"class":568},"now",[212,596,311],{"class":253},[212,598,599],{"class":257},"fixed_now))   ",[212,601,602],{"class":498},"# 30.0\n",[10,604,605],{},"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.",[198,607,609],{"id":608},"step-2-a-retry-loop-that-knows-which-calls-are-safe","Step 2 — A retry loop that knows which calls are safe",[10,611,612],{},"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:",[203,614,616],{"className":244,"code":615,"language":246,"meta":208,"style":208},"RETRY_ALWAYS = {429, 503}\n\nclass GraphError(RuntimeError):\n    pass\n\ndef call_graph(send, method, url, *, idempotent, refresh_token=None,\n               deadline_s=300, sleep=time.sleep, clock=time.monotonic, **kwargs):\n    start, attempt, refreshed = clock(), 0, False\n    while True:\n        response = send(method, url, **kwargs)\n        status = response.status_code\n        if status \u003C 400:\n            return response\n        if status == 401 and refresh_token and not refreshed:\n            refresh_token()\n            refreshed = True\n            continue\n        retryable = status in RETRY_ALWAYS or (status == 504 and idempotent)\n        if not retryable:\n            raise GraphError(f\"{method} {url} failed with {status}: {response.text[:200]}\")\n        wait = retry_after_seconds(response.headers) or backoff(attempt)\n        if clock() - start + wait > deadline_s:\n            raise GraphError(f\"{method} {url} still throttled after {attempt + 1} attempts\")\n        sleep(wait)\n        attempt += 1\n",[14,617,618,640,644,659,664,668,690,719,736,746,761,771,787,795,822,827,837,842,873,882,942,957,982,1025,1031],{"__ignoreMap":208},[212,619,620,623,626,629,632,634,637],{"class":79,"line":214},[212,621,622],{"class":314},"RETRY_ALWAYS",[212,624,625],{"class":253}," =",[212,627,628],{"class":257}," {",[212,630,631],{"class":314},"429",[212,633,477],{"class":257},[212,635,636],{"class":314},"503",[212,638,639],{"class":257},"}\n",[212,641,642],{"class":79,"line":261},[212,643,295],{"emptyLinePlaceholder":294},[212,645,646,649,652,654,657],{"class":79,"line":269},[212,647,648],{"class":253},"class",[212,650,651],{"class":217}," GraphError",[212,653,421],{"class":257},[212,655,656],{"class":314},"RuntimeError",[212,658,318],{"class":257},[212,660,661],{"class":79,"line":277},[212,662,663],{"class":253},"    pass\n",[212,665,666],{"class":79,"line":291},[212,667,295],{"emptyLinePlaceholder":294},[212,669,670,672,675,678,680,683,685,687],{"class":79,"line":298},[212,671,301],{"class":253},[212,673,674],{"class":304}," call_graph",[212,676,677],{"class":257},"(send, method, url, ",[212,679,486],{"class":253},[212,681,682],{"class":257},", idempotent, refresh_token",[212,684,311],{"class":253},[212,686,315],{"class":314},[212,688,689],{"class":257},",\n",[212,691,692,695,697,700,703,705,708,710,713,716],{"class":79,"line":321},[212,693,694],{"class":257},"               deadline_s",[212,696,311],{"class":253},[212,698,699],{"class":314},"300",[212,701,702],{"class":257},", sleep",[212,704,311],{"class":253},[212,706,707],{"class":257},"time.sleep, clock",[212,709,311],{"class":253},[212,711,712],{"class":257},"time.monotonic, ",[212,714,715],{"class":253},"**",[212,717,718],{"class":257},"kwargs):\n",[212,720,721,724,726,729,731,733],{"class":79,"line":338},[212,722,723],{"class":257},"    start, attempt, refreshed ",[212,725,311],{"class":253},[212,727,728],{"class":257}," clock(), ",[212,730,47],{"class":314},[212,732,477],{"class":257},[212,734,735],{"class":314},"False\n",[212,737,738,741,744],{"class":79,"line":356},[212,739,740],{"class":253},"    while",[212,742,743],{"class":314}," True",[212,745,353],{"class":257},[212,747,748,751,753,756,758],{"class":79,"line":365},[212,749,750],{"class":257},"        response ",[212,752,311],{"class":253},[212,754,755],{"class":257}," send(method, url, ",[212,757,715],{"class":253},[212,759,760],{"class":257},"kwargs)\n",[212,762,763,766,768],{"class":79,"line":373},[212,764,765],{"class":257},"        status ",[212,767,311],{"class":253},[212,769,770],{"class":257}," response.status_code\n",[212,772,773,776,779,782,785],{"class":79,"line":384},[212,774,775],{"class":253},"        if",[212,777,778],{"class":257}," status ",[212,780,781],{"class":253},"\u003C",[212,783,784],{"class":314}," 400",[212,786,353],{"class":257},[212,788,789,792],{"class":79,"line":395},[212,790,791],{"class":253},"            return",[212,793,794],{"class":257}," response\n",[212,796,797,799,801,804,807,810,813,816,819],{"class":79,"line":412},[212,798,775],{"class":253},[212,800,778],{"class":257},[212,802,803],{"class":253},"==",[212,805,806],{"class":314}," 401",[212,808,809],{"class":253}," and",[212,811,812],{"class":257}," refresh_token ",[212,814,815],{"class":253},"and",[212,817,818],{"class":253}," not",[212,820,821],{"class":257}," refreshed:\n",[212,823,824],{"class":79,"line":436},[212,825,826],{"class":257},"            refresh_token()\n",[212,828,829,832,834],{"class":79,"line":441},[212,830,831],{"class":257},"            refreshed ",[212,833,311],{"class":253},[212,835,836],{"class":314}," True\n",[212,838,839],{"class":79,"line":467},[212,840,841],{"class":253},"            continue\n",[212,843,844,847,849,851,854,857,860,863,865,868,870],{"class":79,"line":502},[212,845,846],{"class":257},"        retryable ",[212,848,311],{"class":253},[212,850,778],{"class":257},[212,852,853],{"class":253},"in",[212,855,856],{"class":314}," RETRY_ALWAYS",[212,858,859],{"class":253}," or",[212,861,862],{"class":257}," (status ",[212,864,803],{"class":253},[212,866,867],{"class":314}," 504",[212,869,809],{"class":253},[212,871,872],{"class":257}," idempotent)\n",[212,874,875,877,879],{"class":79,"line":507},[212,876,775],{"class":253},[212,878,818],{"class":253},[212,880,881],{"class":257}," retryable:\n",[212,883,884,887,890,893,896,900,903,906,908,911,913,916,918,921,923,925,927,930,933,936,938,940],{"class":79,"line":530},[212,885,886],{"class":253},"            raise",[212,888,889],{"class":257}," GraphError(",[212,891,892],{"class":253},"f",[212,894,895],{"class":221},"\"",[212,897,899],{"class":898},"sSjpA","{",[212,901,902],{"class":257},"method",[212,904,905],{"class":898},"}",[212,907,628],{"class":898},[212,909,910],{"class":257},"url",[212,912,905],{"class":898},[212,914,915],{"class":221}," failed with ",[212,917,899],{"class":898},[212,919,920],{"class":257},"status",[212,922,905],{"class":898},[212,924,518],{"class":221},[212,926,899],{"class":898},[212,928,929],{"class":257},"response.text[:",[212,931,932],{"class":314},"200",[212,934,935],{"class":257},"]",[212,937,905],{"class":898},[212,939,895],{"class":221},[212,941,335],{"class":257},[212,943,944,947,949,952,954],{"class":79,"line":577},[212,945,946],{"class":257},"        wait ",[212,948,311],{"class":253},[212,950,951],{"class":257}," retry_after_seconds(response.headers) ",[212,953,406],{"class":253},[212,955,956],{"class":257}," backoff(attempt)\n",[212,958,960,962,965,967,970,973,976,979],{"class":79,"line":959},22,[212,961,775],{"class":253},[212,963,964],{"class":257}," clock() ",[212,966,430],{"class":253},[212,968,969],{"class":257}," start ",[212,971,972],{"class":253},"+",[212,974,975],{"class":257}," wait ",[212,977,978],{"class":253},">",[212,980,981],{"class":257}," deadline_s:\n",[212,983,985,987,989,991,993,995,997,999,1001,1003,1005,1008,1010,1013,1015,1018,1020,1023],{"class":79,"line":984},23,[212,986,886],{"class":253},[212,988,889],{"class":257},[212,990,892],{"class":253},[212,992,895],{"class":221},[212,994,899],{"class":898},[212,996,902],{"class":257},[212,998,905],{"class":898},[212,1000,628],{"class":898},[212,1002,910],{"class":257},[212,1004,905],{"class":898},[212,1006,1007],{"class":221}," still throttled after ",[212,1009,899],{"class":898},[212,1011,1012],{"class":257},"attempt ",[212,1014,972],{"class":253},[212,1016,1017],{"class":314}," 1",[212,1019,905],{"class":898},[212,1021,1022],{"class":221}," attempts\"",[212,1024,335],{"class":257},[212,1026,1028],{"class":79,"line":1027},24,[212,1029,1030],{"class":257},"        sleep(wait)\n",[212,1032,1034,1037,1040],{"class":79,"line":1033},25,[212,1035,1036],{"class":257},"        attempt ",[212,1038,1039],{"class":253},"+=",[212,1041,1042],{"class":314}," 1\n",[10,1044,1045,1048,1049,1052,1053,1056],{},[14,1046,1047],{},"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 ",[14,1050,1051],{},"sleep"," and ",[14,1054,1055],{},"clock"," in makes the loop testable at full speed.",[25,1058,34,1063,34,1066,34,1069,34,1072,34,1076,34,1082,34,1087,34,1091,34,1096,34,1099,34,1102,34,1108,34,1113,34,1116,34,1120,34,1124,34,1127,34,1130,34,1136,34,1139,34,1142,34,1145,34,1149,34,1153,34,1156,34,1159,34,1166,34,1170,34,1173,34,1176,34,1180,34,1183,34,1188,34,1195,34,1200,34,1203],{"viewBox":1059,"role":28,"ariaLabelledBy":1060,"xmlns":32,"style":33},"4 0 752 160",[1061,1062],"gt-loop-t","gt-loop-d",[36,1064,1065],{"id":1061},"The retry loop for one Graph request",[40,1067,1068],{"id":1062},"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.",[44,1070],{"x":46,"y":47,"width":48,"height":1071,"fill":50},"160",[44,1073],{"x":111,"y":54,"width":1074,"height":1075,"rx":57,"fill":58,"stroke":59,"style":60},"115.2","128.0",[62,1077,1079],{"transform":1078},"translate(65.6,46.5) scale(1.000)",[66,1080],{"d":1081,"fill":69,"stroke":70,"style":71},"M3 11l18-8-8 18-2-8z",[73,1083,1086],{"x":1084,"y":1085,"style":76},"77.6","93.0","Send",[73,1088,1090],{"x":1084,"y":1089,"style":125},"112.5","method + url",[79,1092],{"x1":1093,"y1":1094,"x2":1095,"y2":1094,"stroke":59,"style":60},"140.2","80.0","162.2",[91,1097],{"points":1098,"fill":94},"166.2,80.0 157.2,85.0 157.2,75.0",[44,1100],{"x":1101,"y":54,"width":1074,"height":1075,"rx":57,"fill":58,"stroke":59,"style":60},"171.2",[62,1103,1105],{"transform":1104},"translate(216.8,37.8) scale(1.000)",[66,1106],{"d":1107,"fill":69,"stroke":70,"style":71},"M3 5h18l-7 8v6l-4 2v-8z",[73,1109,1112],{"x":1110,"y":1111,"style":76},"228.8","84.2","Classify",[73,1114,920],{"x":1110,"y":1115,"style":76},"101.8",[73,1117,1119],{"x":1110,"y":1118,"style":125},"121.2","2xx, 401, 429…",[79,1121],{"x1":1122,"y1":1094,"x2":1123,"y2":1094,"stroke":59,"style":60},"291.4","313.4",[91,1125],{"points":1126,"fill":94},"317.4,80.0 308.4,85.0 308.4,75.0",[44,1128],{"x":1129,"y":54,"width":1074,"height":1075,"rx":57,"fill":115,"stroke":116,"style":60},"322.4",[62,1131,1132],{"transform":64},[66,1133],{"d":1134,"fill":69,"stroke":1135,"style":71},"M12 3a9 9 0 1 0 .01 0z M12 7v5l3.5 2","var(--gold-ink,#7a4e06)",[73,1137,1138],{"x":75,"y":56,"style":120},"Compute",[73,1140,1141],{"x":75,"y":1085,"style":120},"wait",[73,1143,1144],{"x":75,"y":1089,"style":125},"Retry-After or",[73,1146,1148],{"x":75,"y":1147,"style":125},"130.0","jitter",[79,1150],{"x1":1151,"y1":1094,"x2":1152,"y2":1094,"stroke":59,"style":60},"442.6","464.6",[91,1154],{"points":1155,"fill":94},"468.6,80.0 459.6,85.0 459.6,75.0",[44,1157],{"x":1158,"y":54,"width":1074,"height":1075,"rx":57,"fill":143,"stroke":144,"style":60},"473.6",[62,1160,1162],{"transform":1161},"translate(519.2,37.8) scale(1.000)",[66,1163],{"d":1164,"fill":69,"stroke":1165,"style":71},"M12 3.5l9.5 17h-19z M12 10v5 M12 17.8v.4","var(--accent-ink,#be185d)",[73,1167,1169],{"x":1168,"y":1111,"style":147},"531.2","Check",[73,1171,1172],{"x":1168,"y":1115,"style":147},"deadline",[73,1174,1175],{"x":1168,"y":1118,"style":125},"give up cleanly",[79,1177],{"x1":1178,"y1":1094,"x2":1179,"y2":1094,"stroke":59,"style":60},"593.8","615.8",[91,1181],{"points":1182,"fill":94},"619.8,80.0 610.8,85.0 610.8,75.0",[44,1184],{"x":1185,"y":54,"width":1074,"height":1075,"rx":57,"fill":1186,"stroke":1187,"style":60},"624.8","#d9f4f1","var(--teal,#0f9488)",[62,1189,1191],{"transform":1190},"translate(670.4,37.8) scale(1.000)",[66,1192],{"d":1193,"fill":69,"stroke":1194,"style":71},"M20 11a8 8 0 1 0-2.3 5.7 M20 4v7h-7","var(--teal-ink,#0b6157)",[73,1196,1199],{"x":1197,"y":1111,"style":1198},"682.4","font-size:13.5px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Sleep and",[73,1201,1202],{"x":1197,"y":1115,"style":1198},"resend",[73,1204,1205],{"x":1197,"y":1118,"style":125},"attempt + 1",[198,1207,1209],{"id":1208},"step-3-test-it-with-a-fake-transport","Step 3 — Test it with a fake transport",[10,1211,1212,1213,1216,1217,1219],{},"A fake ",[14,1214,1215],{},"send"," returns scripted responses, and a fake clock records the waits. The test proves the loop honours ",[14,1218,16],{},", then succeeds:",[203,1221,1223],{"className":244,"code":1222,"language":246,"meta":208,"style":208},"class FakeResponse:\n    def __init__(self, status_code, headers=None, text=\"\"):\n        self.status_code, self.headers, self.text = status_code, headers or {}, text\n\ndef scripted(*responses):\n    queue = list(responses)\n    calls = []\n    def send(method, url, **kwargs):\n        calls.append((method, url))\n        return queue.pop(0)\n    return send, calls\n\nwaits = []\nsend, calls = scripted(FakeResponse(429, {\"Retry-After\": \"7\"}), FakeResponse(503), FakeResponse(200))\nresult = call_graph(send, \"GET\", \"\u002Fworkbook\u002Fworksheets\", idempotent=True,\n                    sleep=waits.append, clock=lambda: 0.0)\nprint(result.status_code, len(calls), waits[0])        # 200 3 7.0\n",[14,1224,1225,1234,1259,1288,1292,1306,1319,1329,1343,1348,1359,1366,1370,1379,1414,1443,1464],{"__ignoreMap":208},[212,1226,1227,1229,1232],{"class":79,"line":214},[212,1228,648],{"class":253},[212,1230,1231],{"class":217}," FakeResponse",[212,1233,353],{"class":257},[212,1235,1236,1239,1242,1245,1247,1249,1252,1254,1257],{"class":79,"line":261},[212,1237,1238],{"class":253},"    def",[212,1240,1241],{"class":314}," __init__",[212,1243,1244],{"class":257},"(self, status_code, headers",[212,1246,311],{"class":253},[212,1248,315],{"class":314},[212,1250,1251],{"class":257},", text",[212,1253,311],{"class":253},[212,1255,1256],{"class":221},"\"\"",[212,1258,318],{"class":257},[212,1260,1261,1264,1267,1270,1273,1275,1278,1280,1283,1285],{"class":79,"line":269},[212,1262,1263],{"class":314},"        self",[212,1265,1266],{"class":257},".status_code, ",[212,1268,1269],{"class":314},"self",[212,1271,1272],{"class":257},".headers, ",[212,1274,1269],{"class":314},[212,1276,1277],{"class":257},".text ",[212,1279,311],{"class":253},[212,1281,1282],{"class":257}," status_code, headers ",[212,1284,406],{"class":253},[212,1286,1287],{"class":257}," {}, text\n",[212,1289,1290],{"class":79,"line":277},[212,1291,295],{"emptyLinePlaceholder":294},[212,1293,1294,1296,1299,1301,1303],{"class":79,"line":291},[212,1295,301],{"class":253},[212,1297,1298],{"class":304}," scripted",[212,1300,421],{"class":257},[212,1302,486],{"class":253},[212,1304,1305],{"class":257},"responses):\n",[212,1307,1308,1311,1313,1316],{"class":79,"line":298},[212,1309,1310],{"class":257},"    queue ",[212,1312,311],{"class":253},[212,1314,1315],{"class":314}," list",[212,1317,1318],{"class":257},"(responses)\n",[212,1320,1321,1324,1326],{"class":79,"line":321},[212,1322,1323],{"class":257},"    calls ",[212,1325,311],{"class":253},[212,1327,1328],{"class":257}," []\n",[212,1330,1331,1333,1336,1339,1341],{"class":79,"line":338},[212,1332,1238],{"class":253},[212,1334,1335],{"class":304}," send",[212,1337,1338],{"class":257},"(method, url, ",[212,1340,715],{"class":253},[212,1342,718],{"class":257},[212,1344,1345],{"class":79,"line":356},[212,1346,1347],{"class":257},"        calls.append((method, url))\n",[212,1349,1350,1352,1355,1357],{"class":79,"line":365},[212,1351,359],{"class":253},[212,1353,1354],{"class":257}," queue.pop(",[212,1356,47],{"class":314},[212,1358,335],{"class":257},[212,1360,1361,1363],{"class":79,"line":373},[212,1362,415],{"class":253},[212,1364,1365],{"class":257}," send, calls\n",[212,1367,1368],{"class":79,"line":384},[212,1369,295],{"emptyLinePlaceholder":294},[212,1371,1372,1375,1377],{"class":79,"line":395},[212,1373,1374],{"class":257},"waits ",[212,1376,311],{"class":253},[212,1378,1328],{"class":257},[212,1380,1381,1384,1386,1389,1391,1394,1396,1398,1401,1404,1406,1409,1411],{"class":79,"line":412},[212,1382,1383],{"class":257},"send, calls ",[212,1385,311],{"class":253},[212,1387,1388],{"class":257}," scripted(FakeResponse(",[212,1390,631],{"class":314},[212,1392,1393],{"class":257},", {",[212,1395,332],{"class":221},[212,1397,518],{"class":257},[212,1399,1400],{"class":221},"\"7\"",[212,1402,1403],{"class":257},"}), FakeResponse(",[212,1405,636],{"class":314},[212,1407,1408],{"class":257},"), FakeResponse(",[212,1410,932],{"class":314},[212,1412,1413],{"class":257},"))\n",[212,1415,1416,1419,1421,1424,1427,1429,1432,1434,1436,1438,1441],{"class":79,"line":436},[212,1417,1418],{"class":257},"result ",[212,1420,311],{"class":253},[212,1422,1423],{"class":257}," call_graph(send, ",[212,1425,1426],{"class":221},"\"GET\"",[212,1428,477],{"class":257},[212,1430,1431],{"class":221},"\"\u002Fworkbook\u002Fworksheets\"",[212,1433,477],{"class":257},[212,1435,1047],{"class":568},[212,1437,311],{"class":253},[212,1439,1440],{"class":314},"True",[212,1442,689],{"class":257},[212,1444,1445,1448,1450,1453,1455,1458,1460,1462],{"class":79,"line":441},[212,1446,1447],{"class":568},"                    sleep",[212,1449,311],{"class":253},[212,1451,1452],{"class":257},"waits.append, ",[212,1454,1055],{"class":568},[212,1456,1457],{"class":253},"=lambda",[212,1459,518],{"class":257},[212,1461,424],{"class":314},[212,1463,335],{"class":257},[212,1465,1466,1468,1471,1474,1477,1479,1482],{"class":79,"line":467},[212,1467,510],{"class":314},[212,1469,1470],{"class":257},"(result.status_code, ",[212,1472,1473],{"class":314},"len",[212,1475,1476],{"class":257},"(calls), waits[",[212,1478,47],{"class":314},[212,1480,1481],{"class":257},"])        ",[212,1483,1484],{"class":498},"# 200 3 7.0\n",[10,1486,1487],{},"The same harness checks the dangerous case: a 504 on a non-idempotent POST must not be resent automatically.",[203,1489,1491],{"className":244,"code":1490,"language":246,"meta":208,"style":208},"send, calls = scripted(FakeResponse(504, text=\"gateway timeout\"))\ntry:\n    call_graph(send, \"POST\", \"\u002Ftables\u002FSales\u002Frows\u002Fadd\", idempotent=False, sleep=lambda s: None)\nexcept GraphError as exc:\n    print(len(calls), \"call:\", str(exc)[:43])        # 1 call: POST \u002Ftables\u002FSales\u002Frows\u002Fadd failed with 504\n",[14,1492,1493,1514,1521,1556,1570],{"__ignoreMap":208},[212,1494,1495,1497,1499,1501,1503,1505,1507,1509,1512],{"class":79,"line":214},[212,1496,1383],{"class":257},[212,1498,311],{"class":253},[212,1500,1388],{"class":257},[212,1502,139],{"class":314},[212,1504,477],{"class":257},[212,1506,73],{"class":568},[212,1508,311],{"class":253},[212,1510,1511],{"class":221},"\"gateway timeout\"",[212,1513,1413],{"class":257},[212,1515,1516,1519],{"class":79,"line":261},[212,1517,1518],{"class":253},"try",[212,1520,353],{"class":257},[212,1522,1523,1526,1529,1531,1534,1536,1538,1540,1543,1545,1547,1549,1552,1554],{"class":79,"line":269},[212,1524,1525],{"class":257},"    call_graph(send, ",[212,1527,1528],{"class":221},"\"POST\"",[212,1530,477],{"class":257},[212,1532,1533],{"class":221},"\"\u002Ftables\u002FSales\u002Frows\u002Fadd\"",[212,1535,477],{"class":257},[212,1537,1047],{"class":568},[212,1539,311],{"class":253},[212,1541,1542],{"class":314},"False",[212,1544,477],{"class":257},[212,1546,1051],{"class":568},[212,1548,1457],{"class":253},[212,1550,1551],{"class":257}," s: ",[212,1553,315],{"class":314},[212,1555,335],{"class":257},[212,1557,1558,1561,1564,1567],{"class":79,"line":277},[212,1559,1560],{"class":253},"except",[212,1562,1563],{"class":257}," GraphError ",[212,1565,1566],{"class":253},"as",[212,1568,1569],{"class":257}," exc:\n",[212,1571,1572,1575,1577,1579,1582,1585,1587,1590,1593,1596,1598],{"class":79,"line":291},[212,1573,1574],{"class":314},"    print",[212,1576,421],{"class":257},[212,1578,1473],{"class":314},[212,1580,1581],{"class":257},"(calls), ",[212,1583,1584],{"class":221},"\"call:\"",[212,1586,477],{"class":257},[212,1588,1589],{"class":314},"str",[212,1591,1592],{"class":257},"(exc)[:",[212,1594,1595],{"class":314},"43",[212,1597,1481],{"class":257},[212,1599,1600],{"class":498},"# 1 call: POST \u002Ftables\u002FSales\u002Frows\u002Fadd failed with 504\n",[10,1602,1603],{},"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.",[198,1605,1607],{"id":1606},"step-4-make-non-idempotent-writes-safe-to-repeat","Step 4 — Make non-idempotent writes safe to repeat",[10,1609,1610],{},"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:",[203,1612,1614],{"className":244,"code":1613,"language":246,"meta":208,"style":208},"def append_rows_safely(send, table_url, rows, batch_key):\n    payload = {\"values\": [row + [batch_key] for row in rows]}\n    try:\n        return call_graph(send, \"POST\", f\"{table_url}\u002Frows\u002Fadd\", idempotent=False, json=payload)\n    except GraphError:\n        existing = call_graph(send, \"GET\", f\"{table_url}\u002Fcolumns('BatchKey')\u002Frange\", idempotent=True)\n        if batch_key in str(existing.text):\n            return existing                           # the first attempt did land\n        return call_graph(send, \"POST\", f\"{table_url}\u002Frows\u002Fadd\", idempotent=False, json=payload)\n\nsend, calls = scripted(FakeResponse(504), FakeResponse(200, text='{\"values\": [[\"BatchKey\"], [\"2026-09-23-a\"]]}'))\nappend_rows_safely(send, \"\u002Ftables\u002FSales\", [[\"North\", 120]], \"2026-09-23-a\")\nprint([c[0] for c in calls])                          # ['POST', 'GET'] — not sent twice\n",[14,1615,1616,1626,1657,1664,1706,1714,1750,1765,1775,1813,1817,1842,1869],{"__ignoreMap":208},[212,1617,1618,1620,1623],{"class":79,"line":214},[212,1619,301],{"class":253},[212,1621,1622],{"class":304}," append_rows_safely",[212,1624,1625],{"class":257},"(send, table_url, rows, batch_key):\n",[212,1627,1628,1631,1633,1635,1638,1641,1643,1646,1649,1652,1654],{"class":79,"line":261},[212,1629,1630],{"class":257},"    payload ",[212,1632,311],{"class":253},[212,1634,628],{"class":257},[212,1636,1637],{"class":221},"\"values\"",[212,1639,1640],{"class":257},": [row ",[212,1642,972],{"class":253},[212,1644,1645],{"class":257}," [batch_key] ",[212,1647,1648],{"class":253},"for",[212,1650,1651],{"class":257}," row ",[212,1653,853],{"class":253},[212,1655,1656],{"class":257}," rows]}\n",[212,1658,1659,1662],{"class":79,"line":269},[212,1660,1661],{"class":253},"    try",[212,1663,353],{"class":257},[212,1665,1666,1668,1670,1672,1674,1676,1678,1680,1683,1685,1688,1690,1692,1694,1696,1698,1701,1703],{"class":79,"line":277},[212,1667,359],{"class":253},[212,1669,1423],{"class":257},[212,1671,1528],{"class":221},[212,1673,477],{"class":257},[212,1675,892],{"class":253},[212,1677,895],{"class":221},[212,1679,899],{"class":898},[212,1681,1682],{"class":257},"table_url",[212,1684,905],{"class":898},[212,1686,1687],{"class":221},"\u002Frows\u002Fadd\"",[212,1689,477],{"class":257},[212,1691,1047],{"class":568},[212,1693,311],{"class":253},[212,1695,1542],{"class":314},[212,1697,477],{"class":257},[212,1699,1700],{"class":568},"json",[212,1702,311],{"class":253},[212,1704,1705],{"class":257},"payload)\n",[212,1707,1708,1711],{"class":79,"line":291},[212,1709,1710],{"class":253},"    except",[212,1712,1713],{"class":257}," GraphError:\n",[212,1715,1716,1719,1721,1723,1725,1727,1729,1731,1733,1735,1737,1740,1742,1744,1746,1748],{"class":79,"line":298},[212,1717,1718],{"class":257},"        existing ",[212,1720,311],{"class":253},[212,1722,1423],{"class":257},[212,1724,1426],{"class":221},[212,1726,477],{"class":257},[212,1728,892],{"class":253},[212,1730,895],{"class":221},[212,1732,899],{"class":898},[212,1734,1682],{"class":257},[212,1736,905],{"class":898},[212,1738,1739],{"class":221},"\u002Fcolumns('BatchKey')\u002Frange\"",[212,1741,477],{"class":257},[212,1743,1047],{"class":568},[212,1745,311],{"class":253},[212,1747,1440],{"class":314},[212,1749,335],{"class":257},[212,1751,1752,1754,1757,1759,1762],{"class":79,"line":321},[212,1753,775],{"class":253},[212,1755,1756],{"class":257}," batch_key ",[212,1758,853],{"class":253},[212,1760,1761],{"class":314}," str",[212,1763,1764],{"class":257},"(existing.text):\n",[212,1766,1767,1769,1772],{"class":79,"line":338},[212,1768,791],{"class":253},[212,1770,1771],{"class":257}," existing                           ",[212,1773,1774],{"class":498},"# the first attempt did land\n",[212,1776,1777,1779,1781,1783,1785,1787,1789,1791,1793,1795,1797,1799,1801,1803,1805,1807,1809,1811],{"class":79,"line":356},[212,1778,359],{"class":253},[212,1780,1423],{"class":257},[212,1782,1528],{"class":221},[212,1784,477],{"class":257},[212,1786,892],{"class":253},[212,1788,895],{"class":221},[212,1790,899],{"class":898},[212,1792,1682],{"class":257},[212,1794,905],{"class":898},[212,1796,1687],{"class":221},[212,1798,477],{"class":257},[212,1800,1047],{"class":568},[212,1802,311],{"class":253},[212,1804,1542],{"class":314},[212,1806,477],{"class":257},[212,1808,1700],{"class":568},[212,1810,311],{"class":253},[212,1812,1705],{"class":257},[212,1814,1815],{"class":79,"line":365},[212,1816,295],{"emptyLinePlaceholder":294},[212,1818,1819,1821,1823,1825,1827,1829,1831,1833,1835,1837,1840],{"class":79,"line":373},[212,1820,1383],{"class":257},[212,1822,311],{"class":253},[212,1824,1388],{"class":257},[212,1826,139],{"class":314},[212,1828,1408],{"class":257},[212,1830,932],{"class":314},[212,1832,477],{"class":257},[212,1834,73],{"class":568},[212,1836,311],{"class":253},[212,1838,1839],{"class":221},"'{\"values\": [[\"BatchKey\"], [\"2026-09-23-a\"]]}'",[212,1841,1413],{"class":257},[212,1843,1844,1847,1850,1853,1856,1858,1861,1864,1867],{"class":79,"line":384},[212,1845,1846],{"class":257},"append_rows_safely(send, ",[212,1848,1849],{"class":221},"\"\u002Ftables\u002FSales\"",[212,1851,1852],{"class":257},", [[",[212,1854,1855],{"class":221},"\"North\"",[212,1857,477],{"class":257},[212,1859,1860],{"class":314},"120",[212,1862,1863],{"class":257},"]], ",[212,1865,1866],{"class":221},"\"2026-09-23-a\"",[212,1868,335],{"class":257},[212,1870,1871,1873,1876,1878,1881,1883,1886,1888,1891],{"class":79,"line":395},[212,1872,510],{"class":314},[212,1874,1875],{"class":257},"([c[",[212,1877,47],{"class":314},[212,1879,1880],{"class":257},"] ",[212,1882,1648],{"class":253},[212,1884,1885],{"class":257}," c ",[212,1887,853],{"class":253},[212,1889,1890],{"class":257}," calls])                          ",[212,1892,1893],{"class":498},"# ['POST', 'GET'] — not sent twice\n",[10,1895,1896,1897,1901],{},"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 ",[19,1898,1900],{"href":1899},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fappend-rows-to-an-excel-online-table-with-microsoft-graph\u002F","Append Rows to an Excel Online Table with Microsoft Graph",".",[198,1903,1905],{"id":1904},"step-5-fewer-requests-one-writer-per-workbook-and-batching","Step 5 — Fewer requests: one writer per workbook, and batching",[10,1907,1908],{},"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:",[203,1910,1912],{"className":244,"code":1911,"language":246,"meta":208,"style":208},"def batch_body(requests_):\n    return {\"requests\": [{\"id\": str(i), \"method\": m, \"url\": u} for i, (m, u) in enumerate(requests_, 1)]}\n\ndef throttled_items(batch_response_json):\n    return [r[\"id\"] for r in batch_response_json[\"responses\"] if r[\"status\"] in (429, 503)]\n\nbody = batch_body([(\"GET\", \"\u002Fme\u002Fdrive\u002Fitems\u002FA\u002Fworkbook\u002Fworksheets\"),\n                   (\"GET\", \"\u002Fme\u002Fdrive\u002Fitems\u002FB\u002Fworkbook\u002Fworksheets\")])\nreply = {\"responses\": [{\"id\": \"1\", \"status\": 200}, {\"id\": \"2\", \"status\": 429, \"headers\": {\"Retry-After\": \"5\"}}]}\nprint(len(body[\"requests\"]), throttled_items(reply))          # 2 ['2']\n",[14,1913,1914,1924,1977,1981,1991,2042,2046,2066,2081,2145],{"__ignoreMap":208},[212,1915,1916,1918,1921],{"class":79,"line":214},[212,1917,301],{"class":253},[212,1919,1920],{"class":304}," batch_body",[212,1922,1923],{"class":257},"(requests_):\n",[212,1925,1926,1928,1930,1933,1936,1939,1941,1943,1946,1949,1952,1955,1958,1960,1963,1965,1968,1971,1974],{"class":79,"line":261},[212,1927,415],{"class":253},[212,1929,628],{"class":257},[212,1931,1932],{"class":221},"\"requests\"",[212,1934,1935],{"class":257},": [{",[212,1937,1938],{"class":221},"\"id\"",[212,1940,518],{"class":257},[212,1942,1589],{"class":314},[212,1944,1945],{"class":257},"(i), ",[212,1947,1948],{"class":221},"\"method\"",[212,1950,1951],{"class":257},": m, ",[212,1953,1954],{"class":221},"\"url\"",[212,1956,1957],{"class":257},": u} ",[212,1959,1648],{"class":253},[212,1961,1962],{"class":257}," i, (m, u) ",[212,1964,853],{"class":253},[212,1966,1967],{"class":314}," enumerate",[212,1969,1970],{"class":257},"(requests_, ",[212,1972,1973],{"class":314},"1",[212,1975,1976],{"class":257},")]}\n",[212,1978,1979],{"class":79,"line":269},[212,1980,295],{"emptyLinePlaceholder":294},[212,1982,1983,1985,1988],{"class":79,"line":277},[212,1984,301],{"class":253},[212,1986,1987],{"class":304}," throttled_items",[212,1989,1990],{"class":257},"(batch_response_json):\n",[212,1992,1993,1995,1998,2000,2002,2004,2007,2009,2012,2015,2017,2020,2023,2026,2028,2030,2033,2035,2037,2039],{"class":79,"line":291},[212,1994,415],{"class":253},[212,1996,1997],{"class":257}," [r[",[212,1999,1938],{"class":221},[212,2001,1880],{"class":257},[212,2003,1648],{"class":253},[212,2005,2006],{"class":257}," r ",[212,2008,853],{"class":253},[212,2010,2011],{"class":257}," batch_response_json[",[212,2013,2014],{"class":221},"\"responses\"",[212,2016,1880],{"class":257},[212,2018,2019],{"class":253},"if",[212,2021,2022],{"class":257}," r[",[212,2024,2025],{"class":221},"\"status\"",[212,2027,1880],{"class":257},[212,2029,853],{"class":253},[212,2031,2032],{"class":257}," (",[212,2034,631],{"class":314},[212,2036,477],{"class":257},[212,2038,636],{"class":314},[212,2040,2041],{"class":257},")]\n",[212,2043,2044],{"class":79,"line":298},[212,2045,295],{"emptyLinePlaceholder":294},[212,2047,2048,2051,2053,2056,2058,2060,2063],{"class":79,"line":321},[212,2049,2050],{"class":257},"body ",[212,2052,311],{"class":253},[212,2054,2055],{"class":257}," batch_body([(",[212,2057,1426],{"class":221},[212,2059,477],{"class":257},[212,2061,2062],{"class":221},"\"\u002Fme\u002Fdrive\u002Fitems\u002FA\u002Fworkbook\u002Fworksheets\"",[212,2064,2065],{"class":257},"),\n",[212,2067,2068,2071,2073,2075,2078],{"class":79,"line":338},[212,2069,2070],{"class":257},"                   (",[212,2072,1426],{"class":221},[212,2074,477],{"class":257},[212,2076,2077],{"class":221},"\"\u002Fme\u002Fdrive\u002Fitems\u002FB\u002Fworkbook\u002Fworksheets\"",[212,2079,2080],{"class":257},")])\n",[212,2082,2083,2086,2088,2090,2092,2094,2096,2098,2101,2103,2105,2107,2109,2112,2114,2116,2119,2121,2123,2125,2127,2129,2132,2135,2137,2139,2142],{"class":79,"line":356},[212,2084,2085],{"class":257},"reply ",[212,2087,311],{"class":253},[212,2089,628],{"class":257},[212,2091,2014],{"class":221},[212,2093,1935],{"class":257},[212,2095,1938],{"class":221},[212,2097,518],{"class":257},[212,2099,2100],{"class":221},"\"1\"",[212,2102,477],{"class":257},[212,2104,2025],{"class":221},[212,2106,518],{"class":257},[212,2108,932],{"class":314},[212,2110,2111],{"class":257},"}, {",[212,2113,1938],{"class":221},[212,2115,518],{"class":257},[212,2117,2118],{"class":221},"\"2\"",[212,2120,477],{"class":257},[212,2122,2025],{"class":221},[212,2124,518],{"class":257},[212,2126,631],{"class":314},[212,2128,477],{"class":257},[212,2130,2131],{"class":221},"\"headers\"",[212,2133,2134],{"class":257},": {",[212,2136,332],{"class":221},[212,2138,518],{"class":257},[212,2140,2141],{"class":221},"\"5\"",[212,2143,2144],{"class":257},"}}]}\n",[212,2146,2147,2149,2151,2153,2156,2158,2161],{"class":79,"line":365},[212,2148,510],{"class":314},[212,2150,421],{"class":257},[212,2152,1473],{"class":314},[212,2154,2155],{"class":257},"(body[",[212,2157,1932],{"class":221},[212,2159,2160],{"class":257},"]), throttled_items(reply))          ",[212,2162,2163],{"class":498},"# 2 ['2']\n",[10,2165,2166,2167,2169,2170,2173],{},"A batch can succeed as a whole while individual requests inside it are throttled, each with its own status and ",[14,2168,16],{},". Retry only those sub-requests, with the same rules as single calls — and keep batches that touch one workbook in order with the ",[14,2171,2172],{},"dependsOn"," property, or better, batch across different workbooks.",[25,2175,34,2180,34,2183,34,2186,34,2189,34,2194,34,2200,34,2206,34,2212,34,2217,34,2224,34,2228,34,2233,34,2237,34,2242,34,2245,34,2250,34,2253,34,2259,34,2264,34,2268,34,2272,34,2277,34,2280,34,2285,34,2288,34,2293,34,2296],{"viewBox":2176,"role":28,"ariaLabelledBy":2177,"xmlns":32,"style":33},"4 0 752 233",[2178,2179],"gt-volume-t","gt-volume-d",[36,2181,2182],{"id":2178},"Many small requests or fewer large ones",[40,2184,2185],{"id":2179},"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.",[44,2187],{"x":46,"y":47,"width":48,"height":2188,"fill":50},"233",[44,2190],{"x":111,"y":54,"width":2191,"height":2192,"rx":2193,"fill":115,"stroke":116,"style":60},"351.0","201.0","14",[62,2195,2197],{"transform":2196},"translate(182.5,29.0) scale(1.083)",[66,2198],{"d":1164,"fill":69,"stroke":1135,"style":2199},"stroke-width:1.66px;stroke-linecap:round;stroke-linejoin:round",[73,2201,2205],{"x":2202,"y":2203,"style":2204},"195.5","78.0","font-size:14px;font-weight:700;fill:var(--gold-ink,#7a4e06);text-anchor:middle","Many small requests",[79,2207],{"x1":2208,"y1":2209,"x2":2210,"y2":2209,"stroke":116,"style":2211},"36.0","91.0","355.0","stroke-width:1px",[73,2213,2216],{"x":462,"y":2214,"style":2215},"111.0","font-size:12px;font-weight:400;fill:var(--text,#172033);text-anchor:start","one call per cell or row",[62,2218,2220],{"transform":2219},"translate(38.5,99.5) scale(0.625)",[66,2221],{"d":2222,"fill":69,"stroke":144,"style":2223},"M6 6l12 12 M18 6L6 18","stroke-width:2.88px;stroke-linecap:round;stroke-linejoin:round",[73,2225,2227],{"x":462,"y":2226,"style":2215},"137.0","parallel writes to one file",[62,2229,2231],{"transform":2230},"translate(38.5,125.5) scale(0.625)",[66,2232],{"d":2222,"fill":69,"stroke":144,"style":2223},[73,2234,2236],{"x":462,"y":2235,"style":2215},"163.0","throttling in bursts",[62,2238,2240],{"transform":2239},"translate(38.5,151.5) scale(0.625)",[66,2241],{"d":2222,"fill":69,"stroke":144,"style":2223},[73,2243,2244],{"x":462,"y":119,"style":2215},"conflicting edits",[62,2246,2248],{"transform":2247},"translate(38.5,177.5) scale(0.625)",[66,2249],{"d":2222,"fill":69,"stroke":144,"style":2223},[44,2251],{"x":2252,"y":54,"width":2191,"height":2192,"rx":2193,"fill":1186,"stroke":1187,"style":60},"389.0",[62,2254,2256],{"transform":2255},"translate(551.5,29.0) scale(1.083)",[66,2257],{"d":2258,"fill":69,"stroke":1194,"style":2199},"M5 12.5l4.5 4.5L19 7",[73,2260,2263],{"x":2261,"y":2203,"style":2262},"564.5","font-size:14px;font-weight:700;fill:var(--teal-ink,#0b6157);text-anchor:middle","Fewer, larger requests",[79,2265],{"x1":2266,"y1":2209,"x2":2267,"y2":2209,"stroke":1187,"style":2211},"405.0","724.0",[73,2269,2271],{"x":2270,"y":2214,"style":2215},"429.0","whole ranges per call",[62,2273,2275],{"transform":2274},"translate(407.5,99.5) scale(0.625)",[66,2276],{"d":2258,"fill":69,"stroke":1187,"style":2223},[73,2278,2279],{"x":2270,"y":2226,"style":2215},"one writer per workbook",[62,2281,2283],{"transform":2282},"translate(407.5,125.5) scale(0.625)",[66,2284],{"d":2258,"fill":69,"stroke":1187,"style":2223},[73,2286,2287],{"x":2270,"y":2235,"style":2215},"$batch across workbooks",[62,2289,2291],{"transform":2290},"translate(407.5,151.5) scale(0.625)",[66,2292],{"d":2258,"fill":69,"stroke":1187,"style":2223},[73,2294,2295],{"x":2270,"y":119,"style":2215},"throttling becomes rare",[62,2297,2299],{"transform":2298},"translate(407.5,177.5) scale(0.625)",[66,2300],{"d":2258,"fill":69,"stroke":1187,"style":2223},[198,2302,2304],{"id":2303},"common-pitfalls","Common pitfalls",[2306,2307,2308,2316,2322,2328],"ul",{},[2309,2310,2311,2315],"li",{},[2312,2313,2314],"strong",{},"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.",[2309,2317,2318,2321],{},[2312,2319,2320],{},"Retrying 400s."," A malformed request fails the same way every time; retries only delay the error and add load.",[2309,2323,2324,2327],{},[2312,2325,2326],{},"Ignoring Retry-After."," Retrying sooner than asked extends the throttling window for your whole application.",[2309,2329,2330,2333,2334,1901],{},[2312,2331,2332],{},"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 ",[19,2335,2337],{"href":2336},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fsend-an-alert-when-an-excel-report-job-fails\u002F","Send an Alert When an Excel Report Job Fails",[198,2339,2341],{"id":2340},"conclusion","Conclusion",[10,2343,2344,2345,2347],{},"Treat Graph throttling as normal operation: retry 429 and 503 after the ",[14,2346,16],{}," 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.",[198,2349,2351],{"id":2350},"frequently-asked-questions","Frequently asked questions",[10,2353,2354,2357],{},[2312,2355,2356],{},"What does a 429 response from Microsoft Graph mean?","\nThe 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.",[10,2359,2360,2363],{},[2312,2361,2362],{},"Which Graph errors should an Excel job retry?","\n429 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.",[10,2365,2366,2369],{},[2312,2367,2368],{},"Is it safe to retry a request that timed out?","\nFor 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.",[10,2371,2372,2375],{},[2312,2373,2374],{},"Does sending requests in parallel make the job faster?","\nNot 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.",[198,2377,2379],{"id":2378},"related","Related",[2306,2381,2382,2388,2395,2402],{},[2309,2383,2384,2385,2387],{},"Up one level: ",[19,2386,22],{"href":21}," — Excel files in OneDrive and SharePoint.",[2309,2389,2390,2394],{},[19,2391,2393],{"href":2392},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fcall-excel-worksheet-functions-through-microsoft-graph\u002F","Call Excel Worksheet Functions Through Microsoft Graph"," — calls that benefit from this retry layer.",[2309,2396,2397,2401],{},[19,2398,2400],{"href":2399},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Frecalculate-excel-online-workbooks-with-graph-sessions\u002F","Recalculate Excel Online Workbooks with Graph Sessions"," — long operations and 504s.",[2309,2403,2404,2408],{},[19,2405,2407],{"href":2406},"\u002Fautomating-reporting-workflows\u002Ferror-handling-and-logging-in-excel-automation\u002Fretry-a-failed-excel-report-job-in-python\u002F","Retry a Failed Excel Report Job in Python"," — retries at the level of the whole job.",[2410,2411,2412],"style",{},"html pre.shiki code .sMTad, html code.shiki .sMTad{--shiki-default:#6F42C1;--shiki-dark:#FFB757}html pre.shiki code .srMev, html code.shiki .srMev{--shiki-default:#032F62;--shiki-dark:#ADDCFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .s-kum, html code.shiki .s-kum{--shiki-default:#D73A49;--shiki-dark:#FF9492}html pre.shiki code .skGVy, html code.shiki .skGVy{--shiki-default:#24292E;--shiki-dark:#F0F3F6}html pre.shiki code .s_Opv, html code.shiki .s_Opv{--shiki-default:#6F42C1;--shiki-dark:#DBB7FF}html pre.shiki code .sP0c6, html code.shiki .sP0c6{--shiki-default:#005CC5;--shiki-dark:#91CBFF}html pre.shiki code .s-wDw, html code.shiki .s-wDw{--shiki-default:#6A737D;--shiki-dark:#BDC4CC}html pre.shiki code .sa561, html code.shiki .sa561{--shiki-default:#E36209;--shiki-dark:#FFB757}html pre.shiki code .sSjpA, html code.shiki .sSjpA{--shiki-default:#005CC5;--shiki-dark:#FF9492}",{"title":208,"searchDepth":261,"depth":261,"links":2414},[2415,2416,2417,2418,2419,2420,2421,2422,2423,2424],{"id":200,"depth":261,"text":201},{"id":235,"depth":261,"text":236},{"id":608,"depth":261,"text":609},{"id":1208,"depth":261,"text":1209},{"id":1606,"depth":261,"text":1607},{"id":1904,"depth":261,"text":1905},{"id":2303,"depth":261,"text":2304},{"id":2340,"depth":261,"text":2341},{"id":2350,"depth":261,"text":2351},{"id":2378,"depth":261,"text":2379},"2026-09-23","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.","md",[2429,2431,2433,2435],{"q":2356,"a":2430},"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.",{"q":2362,"a":2432},"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.",{"q":2368,"a":2434},"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.",{"q":2374,"a":2436},"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.","handle microsoft graph throttling in excel automation",null,{"breadcrumb":2440},[2441,2444,2447],{"name":2442,"item":2443},"Home","\u002F",{"name":2445,"item":2446},"Automating Reporting Workflows with Python and Excel","\u002Fautomating-reporting-workflows\u002F",{"name":22,"item":21},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel",{"title":2450,"description":2451},"Handle Microsoft Graph Throttling (429, Retry-After) for Excel","A robust retry layer for Microsoft Graph Excel calls in Python — which status codes to retry, parsing Retry-After seconds and dates, exponential backoff with jitter and a deadline, safe retries for idempotent calls, one writer per workbook, $batch sub-responses and offline tests.","handle-microsoft-graph-throttling-for-excel","automating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fhandle-microsoft-graph-throttling-for-excel\u002Findex",[2455,2456,2457],"excel-online","reliable-jobs","troubleshooting","how-to","DE06BVz6Et3cjr4etipr3O3yeQYBGaAqGXJYRgCCGGw",[2461,2465],{"title":2462,"path":2463,"stem":2464,"children":-1},"Create a New Workbook in OneDrive with Microsoft Graph","\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fcreate-a-new-workbook-in-onedrive-with-microsoft-graph","automating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fcreate-a-new-workbook-in-onedrive-with-microsoft-graph\u002Findex",{"title":2466,"path":2467,"stem":2468,"children":-1},"Read an Excel File from SharePoint or OneDrive into pandas","\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fread-an-excel-file-from-sharepoint-or-onedrive-into-pandas","automating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fread-an-excel-file-from-sharepoint-or-onedrive-into-pandas\u002Findex",[2470,2472],{"path":2471,"title":22},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph",{"path":2473,"title":2445},"\u002Fautomating-reporting-workflows",[],[2476,2479,2482,2485,2487,2490],{"path":2477,"title":2478,"kind":2458},"\u002Fautomating-reporting-workflows\u002Femailing-excel-reports-with-smtplib\u002Fsend-an-excel-report-with-microsoft-graph-api","Send an Excel Report with the Microsoft Graph API",{"path":2480,"title":2481,"kind":2458},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fadd-a-chart-to-an-excel-online-workbook-with-microsoft-graph","Add a Chart to an Excel Online Workbook with Microsoft Graph",{"path":2483,"title":2484,"kind":2458},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fadd-rename-and-delete-worksheets-with-microsoft-graph","Add, Rename and Delete Worksheets with Microsoft Graph",{"path":2486,"title":1900,"kind":2458},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fappend-rows-to-an-excel-online-table-with-microsoft-graph",{"path":2488,"title":2489,"kind":2458},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fauthenticate-to-microsoft-graph-with-msal-for-excel-automation","Authenticate to Microsoft Graph with MSAL for Excel Automation",{"path":2491,"title":2393,"kind":2458},"\u002Fautomating-reporting-workflows\u002Fautomating-excel-online-with-microsoft-graph\u002Fcall-excel-worksheet-functions-through-microsoft-graph",1790972119008]