← Back to lab

My Sync Reported 0 Failed and 0 Updates for a Live Page. The Search API Was Throttling Me.

A catch block turned every Search Console 429 into null, and null meant 'no data'. A 297-page site synced 0 pages until I paced the calls: 0 became 89.

My Sync Reported 0 Failed and 0 Updates for a Live Page. The Search API Was Throttling Me.

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:

SitePagesUpdated (throttled pass)Updated (paced re-run)
A297089
B3132344

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 processed over 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.