Skip to content
// Apps Script

Retry UrlFetchApp with exponential backoff in Apps Script.

How to retry UrlFetchApp calls on 429 and 5xx errors using exponential backoff with jitter and Retry-After header support in Google Apps Script.

I keep hitting 429 rate-limit errors from UrlFetchApp and my script just dies instead of waiting and retrying.

The script

copy · paste · trigger
fetchWithRetry.gs
Apps Script
// Retry UrlFetchApp with exponential backoff + jitter
// Honors Retry-After header when present (common on 429 responses)
function fetchWithRetry(url, options, maxAttempts) {
  maxAttempts = maxAttempts || 5;
  options = options || {};
  options.muteHttpExceptions = true;

  var attempt = 0;
  while (attempt < maxAttempts) {
    var response = UrlFetchApp.fetch(url, options);
    var code = response.getResponseCode();
    if (code < 400) return response;

    var retryable = (code === 429 || code >= 500);
    if (!retryable || attempt === maxAttempts - 1) {
      throw new Error('Request failed with status ' + code + ': ' + response.getContentText());
    }

    var retryAfter = response.getHeaders()['Retry-After'];
    var baseDelay = retryAfter ? parseInt(retryAfter, 10) * 1000 : Math.pow(2, attempt) * 1000;
    var jitter = Math.floor(Math.random() * 1000);
    Utilities.sleep(baseDelay + jitter);
    attempt++;
  }
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

Why muteHttpExceptions changes everything

By default, UrlFetchApp throws a JavaScript exception on any 4xx or 5xx response. That exception carries almost no useful information, and there is no way to inspect the response body or headers to decide what to do next. Setting muteHttpExceptions: true tells Apps Script to return the response object regardless of status code, which means you can call getResponseCode(), read the Retry-After header, and decide whether to retry or surface the real error message to your logs.

The tradeoff is that you now own the error-handling path completely. A 404 and a 429 both come back as normal return values. That is why the function above explicitly distinguishes retryable codes (429 and 5xx) from client errors like 400 or 403, which should not be retried because a retry will produce the same result.

Honoring Retry-After instead of guessing

Most rate-limited APIs include a Retry-After header on 429 responses. It contains either a number of seconds to wait or an HTTP-date. The code above handles the seconds form, which is by far the more common one. When the header is present, use it directly rather than the exponential formula. The API is telling you exactly how long it needs; ignoring that in favor of your own schedule is friction you are adding for no reason.

When Retry-After is absent, the fallback is Math.pow(2, attempt) * 1000: 1s, 2s, 4s, 8s, 16s across five attempts. That covers most transient 5xx hiccups. The first time I hit a Sheets API quota wall with a fleet of ten time-based triggers all firing at the same minute, pure exponential backoff was not enough because all ten scripts woke up, waited the same 2 seconds, and hit the API simultaneously again. That is what jitter fixes.

Jitter and synchronized triggers

Apps Script time-based triggers are not millisecond-precise, but when you have several scripts set to run every 5 minutes, they tend to cluster. Add to that the fact that exponential backoff with a fixed base produces identical sleep durations across all instances, and you get a thundering-herd pattern where the retry wave is almost as synchronized as the original wave.

Adding Math.floor(Math.random() * 1000) spreads each retry by up to a full second. On a small fleet (under 20 triggers), that is usually enough to break the lock-step. For larger deployments, increase the jitter window to match the number of concurrent callers. A value of Math.floor(Math.random() * maxAttempts * 500) scales the spread with the retry depth, which keeps later retries from re-clustering after the early ones succeed.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Does muteHttpExceptions:true suppress network errors like DNS failures or timeouts?
No. muteHttpExceptions only affects HTTP-level error status codes. A genuine network failure, a timeout, or a DNS resolution problem still throws a JavaScript exception that you need to catch separately with a try/catch around the UrlFetchApp.fetch call.
Can I use this with UrlFetchApp.fetchAll for parallel requests?
fetchAll does not support muteHttpExceptions per-request in the same way. You pass a single options object that applies to all requests, and it throws on the first error by default. For retry logic across parallel requests you generally have to fall back to sequential fetchWithRetry calls, or use fetchAll without retry and re-queue the failures.
The Retry-After header is sometimes a date string, not a number. Does parseInt handle that?
No. parseInt on an HTTP-date string like 'Wed, 11 Jun 2026 14:00:00 GMT' returns NaN, which makes the baseDelay calculation produce NaN and Utilities.sleep receives NaN (treated as 0). If you hit APIs that use the date form, parse it with new Date(retryAfter).getTime() - Date.now() and clamp the result to a positive value.
What happens when maxAttempts is exhausted on a 429?
The function throws an Error with the status code and the raw response body. In a trigger-driven script, that exception surfaces in the Apps Script execution log and sends a failure notification email if you have those enabled under Project Settings. It does not silently swallow the failure.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.