Stay under someone else’s API rate limit

TL;DR — Keep UrlFetchApp under someone else’s API rate limit with a CacheService sliding window (or simple token bucket), handle HTTP 429, and pair with retry-backoff without burning your own quotas.

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 fetch only when the guard says go; otherwise wait or skip.
  • On 429, honor Retry-After when 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!