Build a rate-limit-aware API client
Back off and retry gracefully on 429s — Northwind's robust outbound HTTP pattern.
Published Dec 14, 2025
Northwind’s scripts call a handful of third-party APIs, and every one of them
rate-limits. The naive approach — a bare UrlFetchApp.fetch — works until the
API returns a 429 Too Many Requests, at which point the whole script throws
and the job dies halfway through. A transient 503 does the same.
This automation is a drop-in replacement for UrlFetchApp.fetch that handles
the failures worth retrying. It distinguishes a permanent error (a 404, a bad
request) from a temporary one (a 429, a 500), honours the API’s
Retry-After header when there is one, and otherwise backs off exponentially.
Use it anywhere a script talks to an API you do not control.
What you’ll need
- An Apps Script project that makes outbound HTTP calls with
UrlFetchApp. - The base URL and any headers for the API you are calling — this client wraps the request, it does not replace your endpoint logic.
- Nothing else — there is no Sheet, no key, and no setup beyond pasting the function in.
The script
fetchWithRetry takes the same arguments as UrlFetchApp.fetch, plus a cap on
attempts. It returns the successful response or throws once retries run out.
// Hard ceiling on a single back-off wait, in milliseconds.
const MAX_BACKOFF_MS = 60_000;
// Base delay for exponential back-off, in milliseconds.
const BASE_BACKOFF_MS = 500;
// HTTP status returned when an API is rate-limiting the caller.
const STATUS_RATE_LIMITED = 429;
/**
* Fetches a URL, retrying on rate-limit (429) and server (5xx) errors.
* Permanent client errors (other 4xx) throw immediately — retrying them
* would only waste attempts.
*
* @param {string} url The URL to fetch.
* @param {Object} [options] UrlFetchApp options (method, headers, payload).
* @param {number} [maxAttempts=5] How many times to try before giving up.
* @returns {HTTPResponse} The successful response.
*/
function fetchWithRetry(url, options = {}, maxAttempts = 5) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
// muteHttpExceptions lets us inspect the status instead of throwing.
const res = UrlFetchApp.fetch(url, { ...options, muteHttpExceptions: true });
const code = res.getResponseCode();
// 1. Success — anything below 400 is good enough to return.
if (code < 400) return res;
// 2. Permanent failure — a 4xx that is not a 429 will not improve.
if (code !== STATUS_RATE_LIMITED && code < 500) {
throw new Error('Permanent: ' + code + ' ' + res.getContentText());
}
// 3. Retryable failure (429 or 5xx). Prefer the server's own
// Retry-After hint; fall back to capped exponential back-off.
const retryAfter = parseInt(res.getHeaders()['Retry-After'] || '0', 10);
const wait = retryAfter * 1000
|| Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * Math.pow(2, attempt));
Logger.log(`Attempt ${attempt} got ${code}; waiting ${wait}ms before retry.`);
Utilities.sleep(wait);
}
// 4. Out of attempts.
throw new Error('Exhausted retries after ' + maxAttempts + ' attempts');
}
How it works
fetchWithRetryloops up tomaxAttemptstimes. Each pass makes one real request withmuteHttpExceptions: true, so a bad status returns a response to inspect instead of throwing.- Any status below
400is treated as success and returned straight away. - A
4xxthat is not429is a permanent error — a404, a malformed request. Retrying it would only burn attempts, so the script throws immediately with the status and body. - A
429or any5xxis worth retrying. The script first looks for aRetry-Afterheader, which many APIs send to say exactly how long to wait. - If there is no
Retry-After, it falls back to exponential back-off —BASE_BACKOFF_MS * 2^attempt— capped atMAX_BACKOFF_MSso a long run of failures never sleeps for more than a minute. - It sleeps with
Utilities.sleep, logs the attempt, and loops. - If every attempt fails, it throws once retries are exhausted, so the caller still finds out something went wrong.
Example run
Calling an API that is briefly rate-limiting:
const res = fetchWithRetry('https://api.example.com/v1/orders', {
method: 'get',
headers: { Authorization: 'Bearer ...' },
});
Logger.log(res.getContentText());
A run where the API rate-limits twice, then recovers, logs:
Attempt 1 got 429; waiting 2000ms before retry.
Attempt 2 got 429; waiting 4000ms before retry.
The third attempt returns 200 and fetchWithRetry hands back the response. A
permanent failure looks different — fetchWithRetry to a missing endpoint
throws straight away:
Error: Permanent: 404 {"error":"not found"}
Run it
This is a utility, not a standalone job — you call it from your own code in
place of UrlFetchApp.fetch:
- Paste
fetchWithRetryand its config constants into the project that makes API calls. - Replace each
UrlFetchApp.fetch(url, options)withfetchWithRetry(url, options). - Keep your own
try/catcharound the call to handle the thrown errors — permanent failures and exhausted retries both surface as exceptions.
Watch out for
Utilities.sleepcounts against the runtime limit. Apps Script caps a single execution at about six minutes. A long chain of back-offs can eat that budget — keepmaxAttemptsandMAX_BACKOFF_MSmodest.UrlFetchApphas its own daily quota. Consumer accounts get roughly 20,000 calls a day; retries count toward it. A script that retries hard can burn through the quota faster than you expect.- Not every 5xx is transient. A persistent
500from a broken endpoint will retry the fullmaxAttemptsand then throw. The retries cost time and quota for no gain — watch the logs for repeated server errors. Retry-Aftercan be a date, not seconds. The HTTP spec allows an HTTP-date value.parseIntwill read that as0and fall through to exponential back-off. That is safe, but parse the date form if an API uses it.- This retries idempotent calls safely; writes are riskier. Retrying a
POSTthat already succeeded server-side but failed to respond can create a duplicate. For non-idempotent requests, send an idempotency key if the API supports one.
Related
Handle streaming responses from an LLM API
Manage long Northwind AI outputs reliably — note: Apps Script UrlFetch is synchronous.
Updated Jan 3, 2026
Cache API responses to cut quota usage
Store and reuse Northwind API responses intelligently — sub-second hits, fewer bills.
Updated Dec 26, 2025
Build an API-key vault and rotation system
Manage Northwind credentials securely at scale — centralised storage, scheduled rotation.
Updated Dec 22, 2025
Build a generic paginated-API fetcher
Handle cursors and pages for any large dataset — Northwind's standard pull pattern.
Updated Dec 6, 2025
Add carrier rate and shipping cost lookups
Quote Northwind shipping inline from a carrier API — DHL or UPS rates per order.
Updated Dec 2, 2025