Third-party APIs rarely care that your nightly trigger "usually" fits. When five client files share one key — or one file fans out 200 GETs — you get 429 and a half-written sheet. Own the throttle before UrlFetchApp.fetch, then treat 429 as a signal, not a surprise.
TL;DR
- Count recent calls in
CacheService(sliding window) or spend tokens from a tiny bucket. - Call
fetchonly when the guard says go; otherwise wait or skip. - On 429, honor
Retry-Afterwhen present; otherwise exponential backoff. - Don’t rehash Apps Script’s own quotas — this is about their limit.
- Log skips so "why didn’t row 40 sync?" has an answer.
Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.
Why throttle on your side?
Their limit is usually "N requests per minute per key." Apps Script won’t pace that for you. Without a guard:
- Overlapping triggers double your QPS
- A retry loop turns one 429 into a stampede
- You look like an abusive client even when each script is "reasonable"
A shared CacheService key (script cache for one project, or user cache if you must isolate) is enough for bootstrappers.
Sliding window with CacheService
/**
* Returns true if this call is allowed under maxCalls per windowMs.
* Uses CacheService script cache as a coarse shared counter.
*/
function claimRateSlot_(cacheKey, maxCalls, windowMs) {
var cache = CacheService.getScriptCache();
var now = Date.now();
var raw = cache.get(cacheKey);
var stamps = [];
if (raw) {
try { stamps = JSON.parse(raw) || []; } catch (e) { stamps = []; }
}
var cutoff = now - windowMs;
stamps = stamps.filter(function (t) { return t > cutoff; });
if (stamps.length >= maxCalls) {
cache.put(cacheKey, JSON.stringify(stamps), Math.ceil(windowMs / 1000) + 5);
return false;
}
stamps.push(now);
cache.put(cacheKey, JSON.stringify(stamps), Math.ceil(windowMs / 1000) + 5);
return true;
}
function fetchUnderLimit(url, options, rate) {
rate = rate || {};
var key = rate.cacheKey || ('rate:' + (rate.bucket || 'default'));
var maxCalls = rate.maxCalls != null ? rate.maxCalls : 30;
var windowMs = rate.windowMs != null ? rate.windowMs : 60000;
if (!claimRateSlot_(key, maxCalls, windowMs)) {
throw new Error('rate-limit: local guard blocked fetch (' + key + ')');
}
options = options || {};
options.muteHttpExceptions = true;
return UrlFetchApp.fetch(url, options);
}
Tune maxCalls / windowMs to under their published limit — leave headroom for retries and humans poking the menu.
Simple token bucket (burst-friendly)
function takeToken_(bucketKey, capacity, refillPerSec) {
var cache = CacheService.getScriptCache();
var now = Date.now();
var state = { tokens: capacity, updated: now };
var raw = cache.get(bucketKey);
if (raw) {
try { state = JSON.parse(raw); } catch (e) {}
}
var elapsed = Math.max(0, (now - state.updated) / 1000);
state.tokens = Math.min(capacity, state.tokens + elapsed * refillPerSec);
state.updated = now;
if (state.tokens < 1) {
cache.put(bucketKey, JSON.stringify(state), 300);
return false;
}
state.tokens -= 1;
cache.put(bucketKey, JSON.stringify(state), 300);
return true;
}
Use a bucket when short bursts are OK but sustained rate isn’t.
Handling 429 without making it worse
function fetchWith429Backoff(url, options, rate) {
var attempt = 0;
var maxAttempts = 4;
while (attempt < maxAttempts) {
var resp = fetchUnderLimit(url, options, rate);
var code = resp.getResponseCode();
if (code !== 429) return resp;
var waitMs = 1000 * Math.pow(2, attempt);
var retryAfter = resp.getHeaders()['Retry-After'] || resp.getHeaders()['retry-after'];
if (retryAfter) {
var asNum = Number(retryAfter);
waitMs = isNaN(asNum) ? waitMs : asNum * 1000;
}
Utilities.sleep(Math.min(waitMs, 30000));
attempt++;
}
throw new Error('rate-limit: still 429 after retries for ' + url);
}
Pair with your existing retry-backoff habits for 5xx / flaky networks — don’t collapse both into one infinite loop.
Practical wiring
function syncPage_(page) {
var resp = fetchWith429Backoff(
'https://api.example.com/v1/items?page=' + page,
{ headers: { Authorization: 'Bearer ' + getApiToken_() }, muteHttpExceptions: true },
{ bucket: 'example-api', maxCalls: 20, windowMs: 60000 }
);
var code = resp.getResponseCode();
if (code < 200 || code >= 300) {
throw new Error('API ' + code + ': ' + resp.getContentText().substring(0, 200));
}
return JSON.parse(resp.getContentText());
}
Soft Co-Pilot note
If you’re pasting this into a client workbook, NitroGAS keeps fetch + lock snippets handy — Co-Pilot can help wire a menu "Sync now (throttled)" without inventing a second rate counter. Free extension; Co-Pilot optional.
Closing checklist
- Local guard runs before UrlFetch
- Cache key is shared for all code paths that hit the same API key
- 429 path sleeps / respects Retry-After
- Failures log enough context to debug without refiring blindly
- Limits sit under the vendor’s published ceiling
Happy Coding!


