appscript.dev
Guide Intermediate

Handle API rate limits and retries

Exponential backoff done right for Northwind's outbound HTTP.

Published Sep 29, 2025

Any script that calls an external API will eventually hit a wall: a rate limit, a brief server outage, or a transient network blip. These failures are normal and expected. The difference between a fragile integration and a robust one is whether it treats them as fatal or simply waits a moment and tries again.

The standard answer is exponential backoff with retries — retry the request, but wait progressively longer between each attempt so you do not hammer a struggling server. The detail that separates a correct implementation from a naive one is knowing which failures to retry and how long to wait.

When to retry, and when not to

Not every error deserves a retry. Retrying a permanent failure just wastes time and quota before failing anyway.

ResponseMeaningAction
2xxSuccessReturn the response
429Too many requests (rate limited)Retry, ideally after Retry-After
408, 5xxTimeout or server error — transientRetry with backoff
Other 4xxBad request, auth failure, not foundDo not retry — fail fast

The rule: retry transient failures, fail fast on permanent ones. A 404 or 401 will return exactly the same error on attempt five as on attempt one, so retrying it only delays the inevitable.

The pattern

This wrapper applies the rules above. It retries 429 and 5xx responses with growing delays, honours the server’s own Retry-After hint, and throws immediately on a permanent error.

function fetchWithRetry(url, options = {}, maxAttempts = 5) {
  let lastError;

  for (let i = 0; i < maxAttempts; i++) {
    // muteHttpExceptions keeps a 4xx/5xx from throwing, so we can
    // inspect the status code ourselves rather than catching.
    const res = UrlFetchApp.fetch(url, { ...options, muteHttpExceptions: true });
    const code = res.getResponseCode();

    // Success — return straight away.
    if (code < 400) return res;

    // Permanent client error (but NOT 429): retrying will not help, so
    // fail immediately with a clear message.
    if (code >= 400 && code < 500 && code !== 429) {
      throw new Error('Permanent ' + code + ': ' + res.getContentText());
    }

    // Transient failure (429 or 5xx). Prefer the server's Retry-After
    // header; fall back to exponential backoff capped at 60 seconds.
    const retryAfter = parseInt(res.getHeaders()['Retry-After'] || '0') * 1000;
    const wait = retryAfter || Math.min(60_000, 500 * 2 ** i);
    Utilities.sleep(wait);
    lastError = code;
  }

  // Exhausted every attempt — give up and surface the last status seen.
  throw new Error('Failed after ' + maxAttempts + ': last ' + lastError);
}

The delay formula 500 * 2 ** i produces 0.5s, 1s, 2s, 4s, 8s on successive attempts — each wait double the last. The Math.min(60_000, ...) cap stops the backoff from growing to absurd lengths on a high maxAttempts.

Honour Retry-After

When a server returns 429, it often includes a Retry-After header telling you exactly how long to wait before trying again. The server knows when its limit window resets; your guesswork does not.

Always prefer that header over your own backoff calculation when it is present. Ignoring it means you will likely retry too early, get another 429, and burn an attempt for nothing. The code above does this — retryAfter || ... uses the header if the server sent one, and only falls back to backoff if it did not.

Don’t retry forever

An unbounded retry loop is a trap. If an API is genuinely down or your credentials have been revoked, retrying indefinitely will hang the script until it hits the six-minute execution limit and dies with no useful error.

  • Cap the attempts. Five is a reasonable default; the wrapper above takes it as a parameter so you can tune per call site.
  • Fail loudly when the cap is reached. Throw a clear error so the failure is visible in the execution log, not swallowed.
  • A persistently failing API needs a human, not a tighter loop. Repeated exhaustion is a signal — alert someone rather than retrying harder.

Combine with caching

Retries handle failures; caching prevents requests in the first place. If your script fetches the same data repeatedly, store the response and reuse it instead of calling the API again — the cheapest request is the one you never make.

Caching also cuts how often you brush against the rate limit at all, which means fewer 429s and fewer retries. For the full pattern, see Cache API responses.

Common mistakes

  • Retrying 4xx errors. A 400 or 403 is permanent. Retrying it wastes quota and time, then fails anyway. Only 429 is the retryable client error.
  • Forgetting muteHttpExceptions. Without it, a 5xx response throws before your retry logic ever sees the status code, defeating the whole pattern.
  • Fixed-delay retries. Waiting the same short interval every time hammers a struggling server and can make a brief outage worse. The delay must grow.
  • Ignoring Retry-After. Your backoff guess will usually be wrong; the server’s hint is authoritative. Use it whenever it is present.
  • Uncapped backoff. 500 * 2 ** i with no ceiling reaches multi-minute waits quickly. Cap it so a single retry cannot blow the execution limit.
  • Silent exhaustion. Returning null after the last attempt hides the failure. Throw, so the problem shows up where someone will see it.