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.
| Response | Meaning | Action |
|---|---|---|
2xx | Success | Return the response |
429 | Too many requests (rate limited) | Retry, ideally after Retry-After |
408, 5xx | Timeout or server error — transient | Retry with backoff |
Other 4xx | Bad request, auth failure, not found | Do 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
4xxerrors. A400or403is permanent. Retrying it wastes quota and time, then fails anyway. Only429is the retryable client error. - Forgetting
muteHttpExceptions. Without it, a5xxresponse 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 ** iwith no ceiling reaches multi-minute waits quickly. Cap it so a single retry cannot blow the execution limit. - Silent exhaustion. Returning
nullafter the last attempt hides the failure. Throw, so the problem shows up where someone will see it.