Model: n/a | Cost: $0 | Date: 2026-10 | Status: fixed in the runner, not yet in the library
A 297-page site came back from a Search Console sync with updated: 0, failed: 0. The same site had a page with 822 impressions that week. The API wasn't empty. It was throttling me, and my own error handling reported it as "no data".
Setup
A sync endpoint pulls per-page performance from Google's Search Console API into a database, 50 pages per call, cursor stored server-side. A response looks like this:
{"success":true,"done":false,"processed":50,"updated":3,"noData":47,"failed":0} A page with no impressions in the window legitimately returns nothing, so noData is normally the biggest number in the response. That's what made the bug invisible.
The trap
The data-fetch function wraps the API call like this:
try {
const rows = await queryPagePerformance(url, range);
return rows.length ? summarize(rows) : null;
} catch (e) {
return null; // "page not in GSC" — also: 429, 403, quota, anything
} Empty result and thrown error both end as null. The caller counts null as noData. failed only increments on errors that escape the function, and nothing escapes.
Why a fleet run triggers it
The function tries up to 4 URL variants per page (with/without trailing slash, www, scheme) before giving up. A page with no data costs 4 queries. Pages are processed in sub-batches of 10 in parallel.
One back-to-back run across 4 sites, about 1,700 pages:
- Thousands of API queries in roughly 13 minutes
- 10 requests in flight at any moment
- Per-account quota exceeded somewhere in the middle of the run
Sites processed first were fine. Sites processed last got a wall of 429s, every one of them silently converted to noData.
Evidence
Symptoms from the single pass:
| Site | Pages | Updated (throttled pass) | Updated (paced re-run) |
| A | 297 | 0 | 89 |
| B | 313 | 23 | 44 |
Site A's first pass produced entire batches of noData: 50. I confirmed it wasn't real by querying the API directly with the site's own credentials: the data existed, including the 822-impression page.
The fix that worked was boring: re-run the affected sites alone, one call at a time, with a 15 s sleep between batch calls.
for _ in range(cap):
r = post(f"/sync/auto?limit=50") # curl subprocess, not urllib
totals = accumulate(totals, r) # per batch, not last batch only
if r["done"]:
break
time.sleep(15) # ≈ 240 queries/min, under quota Site A went 0 → 89. Site B went 23 → 44. Same code, same credentials, same data. Only the call rate changed.
A second false alarm
Two weeks later a run looked throttled again: every site ending on a high noData count. This time the cause was my script, which printed only the final batch's counters instead of summing across batches. The last batch of a cycle is always the leftover tail, mostly empty pages. One solo probe call showed updated: 7 flowing normally.
So there are two ways to get a convincing all-zero result: a swallowed 429, and a runner that only reads the last response. Probe one call alone before believing either.
Detection rules I use now
- Zero updates on a site that had updates in recent runs is a throttle suspect, not a result. Re-run it alone.
- Never stop at the first empty batch. Pages are walked in a fixed order and data isn't evenly spread; early batches can be empty while batches 3–7 hold real rows. Stop on
done: true. - Sum per-batch counters. The final response is not the cycle total.
- Sanity-check coverage: the sum of
processedover a cycle must equal the published page count in the database. A truncated loop (my iteration cap was 10, a site needed 13 batches) looks identical to a quiet site. - A background job has a hard time limit. A paced 4-site run needs about 13 minutes. A default 600 s cap killed one at 596 s, mid-site.
What I'd change
The runner workarounds are patches. The bug is the catch { return null }. Errors should be counted as failed (or throttled) and surface in the response, so "0 failed" means no failures instead of no failures that I could see. Until that lands, pacing the calls is the only defence, and every all-zero result gets checked against a direct API query before I record it.