●SPARK — Gemini Spark, Google's agentic assistant, arrives in the Gemini app for macOS and works on tasks on your behalf●BRIEF — Daily Brief, powered by Personal Intelligence, distills priorities from Gmail and Calendar with suggested next steps●MODEL — Gemini 3.5 Flash reaches GA, offering faster, lower-cost AI with a 1M-token context window●GROWTH — The Gemini app has passed 900 million monthly active users, up from 400 million a year earlier●APPS — Spark now connects to Google Tasks and Google Keep and stays up to date on topics in real time●ENTERPRISE — Gemini 3.5 Flash is generally available across the Global, US, and EU regions on Gemini Enterprise●SPARK — Gemini Spark, Google's agentic assistant, arrives in the Gemini app for macOS and works on tasks on your behalf●BRIEF — Daily Brief, powered by Personal Intelligence, distills priorities from Gmail and Calendar with suggested next steps●MODEL — Gemini 3.5 Flash reaches GA, offering faster, lower-cost AI with a 1M-token context window●GROWTH — The Gemini app has passed 900 million monthly active users, up from 400 million a year earlier●APPS — Spark now connects to Google Tasks and Google Keep and stays up to date on topics in real time●ENTERPRISE — Gemini 3.5 Flash is generally available across the Global, US, and EU regions on Gemini Enterprise
Your Apps Script Gemini Automation Fails Every Month-End — Budgeting Against the UrlFetch Daily Quota
Apps Script automations that call Gemini stall on the UrlFetch daily call quota — a separate ceiling from the 6-minute limit and trigger counts. Here is a daily budget governor with backlog carry-over that keeps the job running on busy days, with working code and a verified simulation.
Every month-end, the same automation quietly died.
As an indie developer I run a handful of apps on my own, and a few of them lean on Apps Script to call Gemini — drafting replies to user reviews, tagging newly added wallpapers. On ordinary days everything flows without a hitch. But on a few days each month, an unfamiliar error showed up in the execution log, always the day after reviews had surged.
My first guess was rate limiting (429). But a 429 should be absorbed by exponential backoff. Following the log, I found the failure was not on Gemini's side at all — it was on Apps Script's. Service invoked too many times for one day: urlfetch. I had hit the UrlFetch daily call quota.
This article focuses on that third ceiling: the daily call quota of UrlFetchApp, which is neither the 6-minute runtime limit nor the trigger-count limit. It stays invisible while volume is steady and only shows its teeth on busy days. I will lay out a design that absorbs it quietly — a daily budget governor plus backlog carry-over — with working code and a verified simulation.
The third ceiling is "how many times can you call UrlFetch in a day"
As an Apps Script Gemini automation grows, it hits separate limits in sequence. Runtime and triggers are the well-known ones; the UrlFetch daily quota tends to hide behind them.
Limit you hit
What is capped
consumer (gmail.com)
Google Workspace
Execution time
Longest single execution
6 min / execution
30 min / execution
Total trigger runtime
Trigger runtime per day
90 min / day
6 hr / day
UrlFetch daily calls
UrlFetch calls per day
20,000 / day
100,000 / day
The runtime wall can be crossed by splitting work and continuing in the next trigger. The trigger-count wall can be crossed by consolidating into a single dispatcher. But the UrlFetch daily quota is different in kind: splitting or consolidating does not change the total number of calls you can issue in one day. If your design calls Gemini once per item, the number of items you process that day maps directly onto the quota.
Here is the naive version I wrote first. On each trigger, pick up every unprocessed row and send it to Gemini — on ordinary days, that is enough.
// Before: process all pending each run (fails on busy days)function enrichPendingRows() { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('reviews'); const rows = sheet.getDataRange().getValues(); for (let i = 1; i < rows.length; i++) { if (rows[i][STATUS_COL]) continue; // skip done rows const draft = callGemini(rows[i][TEXT_COL]); // one UrlFetch per item sheet.getRange(i + 1, STATUS_COL + 1).setValue('done'); sheet.getRange(i + 1, DRAFT_COL + 1).setValue(draft); }}function callGemini(text) { const res = UrlFetchApp.fetch(GEMINI_ENDPOINT, { method: 'post', contentType: 'application/json', headers: { 'x-goog-api-key': 'YOUR_API_KEY' }, payload: JSON.stringify({ contents: [{ parts: [{ text: text }] }] }), muteHttpExceptions: true, }); return JSON.parse(res.getContentText()).candidates[0].content.parts[0].text;}
This code has a weakness that always surfaces on a busy day: there is no cap on the number of pending items. At a usual 3,000 items/day it takes just over 3,000 UrlFetch calls. But the day after a feature launch adds 40,000 reviews, it tries to process 43,000 at once and issues over 46,000 UrlFetch calls. Well past the 20,000 wall, it hits the urlfetch daily limit and throws mid-run.
Worse, the failure point moves around. Which item it dies on depends on how many UrlFetch calls other scripts made that day. Whatever stopped short carries over to the next day — but the naive version has no notion of "how far did I get" in budget terms, so the next day it fires all pending items at once again and hits the same wall. That was the loop I ran, a few days every month-end.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Understand, from reproducible code, how the UrlFetch daily call quota (20,000 calls/day on consumer accounts) is a third ceiling — distinct from the 6-minute runtime and trigger-count limits — that only bites on high-volume days
✦Copy a complete Apps Script token-bucket governor that keeps a daily budget ledger in Script Properties and resets it on the timezone boundary, not UTC
✦Verify with a deterministic simulation (backlog clears three days after a spike) that carrying work over to the next day converges instead of snowballing
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The budget governor: decide how many calls you may spend per day, up front
The idea is simple. Decide up front how many UrlFetch calls you may issue in a day; when you are about to exceed it, stop for today and push the rest to tomorrow. It is close in spirit to a two-stage dollar cap, but what you count here is call count, not money. For the dollar-based design, see pairing Project Spend Caps with an app-side soft limit; if you also want to lock down cost, that is the counterpart.
Make the budget the effective value — the ceiling minus a safety margin and a reservation — not the ceiling itself.
Item
Value
Meaning
Daily quota (consumer)
20,000
The ceiling Apps Script guarantees
Safety margin
15%
Headroom for other scripts and bursts
Reservation
500
Held for heartbeat and logging
Effective budget
16,500 / day
What this automation may spend
Keep the ledger in Script Properties. The key point is to reset the budget on the timezone boundary, not UTC. Zero out usage the moment the date rolls over in the script's timezone (Asia/Tokyo for me). Leave it on UTC and yesterday's remainder revives at 9 a.m. local time, drifting away from what the daily quota actually measures.
Read-modify-write on Script Properties loses updates when several triggers run at once, which is why LockService wraps it here. For the Properties contention and size limits themselves, see folding Properties contention into a durable store; if the ledger grows, moving it into that storage layer is the safe path.
A dispatcher that carries the backlog to the next day
Once you can count the budget, change the worker to "advance only within the remaining budget and leave the rest." If you already track pending work with a status column, the carry-over happens for free — rows you did not process today simply stay unprocessed.
// After: process only within remaining budget, defer the rest to tomorrowfunction enrichWithinBudget() { const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('reviews'); const rows = sheet.getDataRange().getValues(); let remaining = remainingBudget(); if (remaining <= 0) { console.log('Daily UrlFetch budget spent. Carrying over to tomorrow.'); return; } // convert remaining budget into an item count (fold in retry amplification) let itemsAllowed = Math.floor(remaining / CALLS_PER_ITEM); for (let i = 1; i < rows.length && itemsAllowed > 0; i++) { if (rows[i][STATUS_COL]) continue; const { draft, calls } = callGeminiWithRetry(rows[i][TEXT_COL]); sheet.getRange(i + 1, STATUS_COL + 1).setValue('done'); sheet.getRange(i + 1, DRAFT_COL + 1).setValue(draft); chargeBudget(calls); // record calls actually spent itemsAllowed -= 1; }}// return the retry count too, so the ledger tracks realityfunction callGeminiWithRetry(text) { let calls = 0; for (let attempt = 0; attempt < 3; attempt++) { calls += 1; const res = UrlFetchApp.fetch(GEMINI_ENDPOINT, { method: 'post', contentType: 'application/json', headers: { 'x-goog-api-key': 'YOUR_API_KEY' }, payload: JSON.stringify({ contents: [{ parts: [{ text: text }] }] }), muteHttpExceptions: true, }); if (res.getResponseCode() === 200) { const draft = JSON.parse(res.getContentText()).candidates[0].content.parts[0].text; return { draft, calls }; } Utilities.sleep(Math.pow(2, attempt) * 1000); // exponential backoff } return { draft: '', calls }; // leave failed rows empty for next time}
The point is that the calls actually added by retries are recorded in the ledger as calls. The estimate factor (CALLS_PER_ITEM) is only used to convert budget into an item count; the record of consumption is measured. That way, even on a day with more retries than expected, the budget draws down correctly and the job stops naturally before the ceiling.
How long does the backlog take to clear — check with a simulation
"Carry over to tomorrow" raises a fear that the backlog will snowball. So I checked whether the carry-over actually converges, with a deterministic simulation. The premise: 3,000 items/day at steady state, plus 40,000 added on day 5 from a post-launch review surge and backfill.
import mathDAILY_QUOTA, MARGIN, RESERVE, CALLS_PER_ITEM = 20000, 0.15, 500, 1.08UB = int(DAILY_QUOTA * (1 - MARGIN)) - RESERVE # 16500CAP = math.floor(UB / CALLS_PER_ITEM) # 15277 items/dayarrivals = [3000]*14arrivals[4] += 40000 # spike on day 5backlog = 0for d in range(14): pend = backlog + arrivals[d] proc = min(pend, CAP) # process only within budget backlog = pend - proc # defer the rest to tomorrow naive = math.ceil(arrivals[d] * CALLS_PER_ITEM) # naive call count status = "OK" if naive <= DAILY_QUOTA else "THROW" print(d+1, arrivals[d], proc, backlog, naive, status)
Running it, the effective budget is 16,500 calls/day and daily capacity is 15,277 items. Here are the results.
Day
Arrivals
Processed (governor)
Backlog end
Naive calls
Naive
4
3,000
3,000
0
3,240
OK
5
43,000
15,277
27,723
46,440
THROW
6
3,000
15,277
15,446
3,240
OK
7
3,000
15,277
3,169
3,240
OK
8
3,000
6,169
0
3,240
OK
Two things stand out. First, the naive version attempts 46,440 UrlFetch calls on the day-5 spike, crosses the 20,000 wall, and loses that day's work mid-run (THROW). Second, the governor processes exactly 15,277 items within the ceiling that same day, defers the remaining 27,723 to later days, and clears the backlog to zero just three days after the spike (day 8). As long as the arrival rate (3,000/day) stays below capacity (15,277/day), the carry-over always converges. It does not snowball — it just flattens the peak.
Operations: noticing that it stopped, and the numbers per environment
With the governor in place, the automation stops "dying quietly," but in exchange you get an intentional stop — "spent today's budget, that's it for now." To tell this apart from a failure, log or notify the end-of-day backlog and usage. If the backlog grows for several days in a row, that is a sign the arrival rate has outpaced capacity, and a cue to consider a Workspace account (100,000 calls/day) or batching several items into one call.
Environment
UrlFetch daily quota
Effective budget (−15%, −500)
Capacity (1.08/item)
consumer (gmail.com)
20,000
16,500
~15,277 items/day
Google Workspace
100,000
84,500
~78,240 items/day
The same script has a ceiling that differs fivefold depending on the account type it runs under. If a migration is on the horizon, keep DAILY_QUOTA as a constant you can switch per environment, and you will not scramble later. For the current values of each Apps Script limit, see the Apps Script quotas and limitations documentation; reconciling your own environment's numbers before you go live is time well spent.
Wrapping up
When an Apps Script Gemini automation "only fails on busy days," the cause is often neither Gemini nor rate limiting but the UrlFetch daily call quota. It is easy to miss precisely because you have already solved runtime and trigger counts — a third ceiling behind the first two.
The fix is not to fight the limit but to decide how many calls you may spend per day and defer the rest to tomorrow. Reset the Script Properties budget ledger on the timezone boundary and record consumption from measured calls including retries, and even a spike day flattens out and clears in a few days. To start, add just a remainingBudget() log line to the automation you already run, and measure how many UrlFetch calls a steady day actually uses. Where to draw the margin becomes clear from that measurement.
For me, the month-end error alerts stopped the moment I added this budget ledger. If you run unattended automations the same way, I hope it helps toward a quieter operation. Thank you for reading.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.