Transient API failures are normal. Blind retries can stampede a service; exponential backoff spreads load.
fetchWithBackoff retries on 429 and 5xx, doubles wait from 500ms up to 15s, and honors Retry-After seconds when present.
Non-retryable 4xx fail immediately so bad auth or validation errors do not burn the attempt budget.
demoBackoffGet reads FLAKY_URL from properties to exercise the helper.
Need this built? Hire a Google Apps Script developer →
Sheet / project setup
| Item | Value | Purpose |
|---|---|---|
| Helper | fetchWithBackoff | Reusable wrapper |
| maxAttempts | 5 | Default try count |
| Initial wait | 500ms | Doubles each retry |
| Property | FLAKY_URL | Demo endpoint |
| Retry-After | header | Overrides sleep when numeric |
What this script does
The while loop fetches until success or attempts exhaust; retryable codes sleep then continue.
Prerequisites
UrlFetch access; an endpoint that may return 429/503 for testing.
- muteHttpExceptions required
- Decide which codes are retryable
- Cap total sleep vs script runtime
Walkthrough
Point FLAKY_URL at a health endpoint, temporarily force retries by including 503 in tests, inspect Logger.
Edge cases
Retry-After may be an HTTP date — this sample only handles numeric seconds.
- POST retries may duplicate side effects — use idempotency keys
- Jitter can be added with Math.random
How to test
Unit-test by stubbing a function that returns codes; or hit httpstat.us/503.
Hardening for production
Add jitter; classify 408; metrics-log attempt counts to a sheet.
Variations
Reuse for Drive/Docs advanced services that throw rate limit errors.
Full code: fetchWithBackoff()
Call fetchWithBackoff(url, options, maxAttempts) anywhere you currently call UrlFetchApp.fetch.
/**
* Exponential backoff wrapper for flaky HTTP calls.
*/
function fetchWithBackoff(url, options, maxAttempts) {
maxAttempts = maxAttempts || 5;
options = options || {};
options.muteHttpExceptions = true;
let attempt = 0;
let waitMs = 500;
while (attempt < maxAttempts) {
attempt++;
const resp = UrlFetchApp.fetch(url, options);
const code = resp.getResponseCode();
if (code < 400) return resp;
const retryable = code === 429 || code === 500 || code === 502 || code === 503 || code === 504;
if (!retryable || attempt === maxAttempts) {
throw new Error("HTTP " + code + " after " + attempt + " attempt(s): " +
resp.getContentText().slice(0, 300));
}
const retryAfter = Number(resp.getHeaders()["Retry-After"]);
const sleepMs = !isNaN(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : waitMs;
Utilities.sleep(sleepMs);
waitMs = Math.min(waitMs * 2, 15000);
}
}
function demoBackoffGet() {
const url = PropertiesService.getScriptProperties().getProperty("FLAKY_URL");
const resp = fetchWithBackoff(url, { method: "get", headers: { Accept: "application/json" } }, 5);
Logger.log("OK %s bytes", resp.getContentText().length);
}- Line 7: Required so 429/503 return instead of throwing.
- Line 17: Only transient codes enter the sleep path.
- Line 23: Uses server-provided delay when present.
- Line 26: Exponential growth capped at 15 seconds.
- Line 19: Surfaces the last body snippet after exhaustion.
- Line 30: Example caller using Script Properties.
Deploy this example
- 01
Open Apps Script
In the bound spreadsheet: Extensions → Apps Script. For standalone projects, create one at script.google.com and link your Sheet by ID.
- 02
Paste and save
Add a .gs file, paste the code below, rename constants at the top (sheet names, column letters, API property keys), then save.
- 03
Authorize once
Run the main function from the editor. Accept OAuth scopes when prompted — triggers cannot run until authorization succeeds once.
- 04
Add the trigger
Triggers → Add trigger → choose the handler function and event (time-driven, on edit, or on form submit). Delete test triggers before production.
Before you run: backoff helper
- 1Identify retryable status codes for your API
- 2Ensure POSTs are idempotent or keyed
- 3Cap maxAttempts so total sleep fits runtime
- 4Log attempt count for support
- 5Test 401 fails fast (no retry)
- 6Set FLAKY_URL for the demo function
Frequently asked questions
Usually no — 400 means bad request. Fix the payload instead.
Randomizing sleep slightly so many clients do not retry in lockstep.
It consumes execution time, not UrlFetch quota. Still keep sleeps bounded.
Wrap fetch in try/catch for DNS-like failures and apply the same backoff.
Yes — catch errors and inspect messages for rate limit wording, then sleep/retry.
Keeps a 5-attempt loop inside typical interactive runtimes; adjust for longer triggers.
Store consecutive failure counts in CacheService and short-circuit for N minutes after meltdown.