loading...

07/09/2026

How I fixed false alerts and ETag caching for my AI website change detection

How I fixed false alerts and ETag caching for my AI website change detection

I run Rivale.io, a competitive-intelligence tool for WordPress plugin founders and developers. Part of what it does is watch competitors’ marketing pages and tell you when something moves: a pricing change, a new add-on, a repositioned homepage.

One morning I asked what should have been an accounting question. My daily ops email showed the crawler’s credit burn jumping around day to day, 2 one day and 6 the next, with nothing new added and the same short list of monitored pages.

I wanted to know why I was paying for a crawl of pages that hadn’t changed.

The answer turned out to be two separate bugs sitting on top of each other, and the expensive one was the less serious one. Underneath the cost problem was a correctness problem. My system had sent users 46 “competitor changed” alerts for changes that never happened, including the same fake discovery announced ten separate times since June. Nothing errored and no test failed, because the fake alerts were structurally identical to real ones.

This guide covers the phantom events first, then the redirect that was disabling free HTTP caching.

Architecture at a glance

Flowchart of the crawl pipeline: the prior-state lookup feeds both the cache pre-check and the AI classifier, and both the free-skip and failure paths write rows with no content hash.
The prior-state lookup feeds two consumers that need different answers. Both the free-skip path and a failed crawl write a row carrying no content hash.

The component that caused the damage is the first box, the “what did we see last time?” lookup. It fed both the cache pre-check and the change classifier from a single query, and those two consumers need different answers.

Part 1: The classifier that described instead of comparing

The symptom

The events feed carried repeated announcements of the same non-event. One competitor’s “Add-ons marketplace introduced” fired on 13 June, 16 June, 30 June, 15 July, 18 July, 26 July, 3 August, 6 August, 12 August, and 15 August.

That company did not launch an add-ons marketplace ten times.

In the 30 days before the fix, the events table held 29 events built from only 6 distinct titles. And 46 change events immediately followed a row whose crawl status was failed or skipped_head.

The root cause

The worker read its previous state with a single query for the newest crawl row, regardless of that row’s status:

// The read that caused it: newest row of ANY status
const { data: lastResult } = await db
  .from('<crawl_results>')
  .select('content_hash, etag, last_modified, diff_excerpt, content_markdown')
  .eq('url', url)
  .order('created_at', { ascending: false })
  .limit(1)
  .maybeSingle();

Two kinds of row carry no content_hash: a failed crawl, and a skipped_head row (the cheap “nothing changed” path). Both are legitimate rows. Both are frequently the newest.

So after any failure, lastHash came back null. That made the hash gate impossible to satisfy, which forced a scrape and then an AI call. And the AI was handed prevContent = null:

const prevContent = lastResult?.content_markdown ?? lastResult?.diff_excerpt ?? null;

The classifier’s prompt has a legitimate branch for “no previous version”. It describes the page instead of comparing two versions. That description comes back as a label, changeDetected flips to true, and an event fires at the user.

Warning: This is not a model failure. The prompt behaved exactly as designed. The defect is upstream, in a data path that hands the classifier a null prior state after a failure, rather than only on a genuine first sighting. A guard did exist for first sightings, but it tested a first-sighting flag, which post-failure runs do not set.

One failed fetch therefore manufactures a fake change on the next successful run. At discovery, over a third of the monitored URLs had a hash-less newest row, including every one of the flaky ones.

The fix

Split one query into two, because they answer different questions. The shipped code documents the reasoning inline:

// ─── STEP 3: Fetch last crawl state — TWO reads, two different questions ──
// These must not be one query. "What are the freshest cache validators?" and
// "what was the last content we actually saw?" have different correct answers:
//
//   • etag/last_modified — want the NEWEST row of ANY status. Failure rows do
//     carry validators, and they are fresher. Feeding a stale ETag to
//     headCheckUrl makes it report changed:true and forces a full paid scrape.
//   • content_hash/markdown — want the newest row that actually HAS a hash.

Validators want the freshest row whatever its status. Content wants the freshest row that actually holds content. Reading both from one query means one of them is always wrong.

Verification

After deploying, all three URLs that had been returning NULL recovered a real hash, while the one site with cache headers kept its fresh ETag from the newest row. Both halves of the split behaved as designed against live data.

Part 2: The trailing slash that turned off free caching

The symptom

Credit burn on every run, against a fixed list of pages that mostly did not change. Over 30 days, 128 crawl runs produced only 28 AI calls, so the hash gate was doing its job at 78%. The money was going out earlier in the pipeline, because the free pre-check almost never fired.

What did not work

Two plausible theories were tested and discarded before the real one.

Theory 1: the sites are blocking us, so stop retrying. Disproven by reading 30 days of failures. There were zero 403s or blocks. Every failure was HTTP 408, HTTP 502, or an aborted signal, all timeout-class. The block-retry path never fired at all.

Theory 2: normalize the rotating asset tokens. Pages carried ?ver= query strings on assets that changed between fetches, defeating a naive content hash. Stripping them with a regex worked mechanically, and was abandoned anyway:

Note: Every new site adds another pattern to strip, forever, and over-stripping silently hides a real change. A missed skip costs one credit, an over-strip hides a change you needed to know about, and that asymmetry settles the question. Let the origin server answer it instead.

The root cause

The orchestrator stripped the trailing slash from each stored URL to build a dedup key, then crawled that stripped URL. On WordPress-style sites, the slashless form does not serve the page. It answers 301.

The HEAD pre-check pins redirect: 'manual' as an SSRF guard, and treats any 3xx as inconclusive. Inconclusive means “fall through to a full paid scrape.” So the free path was unreachable for every URL that redirected.

Four decisions, each correct on its own, chained into a bug:

Flowchart showing four correct decisions chaining into a bug: stripping the trailing slash for a dedup key leads to a 301, which the SSRF guard treats as inconclusive, forcing a paid scrape instead of a free conditional GET.
Four decisions, each correct alone, chain into a bug. The dotted path is the free conditional GET the servers were offering the whole time.

The dotted path is the one the servers were offering the whole time.

The probe that settled it, run against the exact URLs in production:

for u in "https://example-a.com/" "https://example-a.com/add-ons/" "https://example-a.com/add-ons/premium/"; do
  hdr=$(curl -sIL --max-time 20 "$u")
  etag=$(printf '%s' "$hdr" | grep -i '^etag:' | tail -1 | sed 's/^[Ee][Tt][Aa][Gg]: *//' | tr -d '\r')
  echo "$u  ETag='$etag'"
  if [ -n "$etag" ]; then
    code=$(curl -s -o /dev/null -w '%{http_code}' -L --max-time 20 -H "If-None-Match: $etag" "$u")
    echo "    conditional GET -> HTTP $code"
  fi
done

Every one of those URLs returned a free 304 on the trailing-slash form. The same pages, slash stripped, returned 301 and were treated as unknowable.

The measurement recorded in the shipped code comment is that the single URL that reached its ETag got 60% free skips, and the three that redirected got 0%.

Important: This is not limited to the two competitors above. Every /pricing page tested on the other monitored sites answered 301 without its trailing slash. If you normalize URLs anywhere upstream of an HTTP cache check, you have probably disabled that check.

The fix

Dedup on a slash-insensitive key, but crawl the URL exactly as the user stored it. The key and the fetched URL are different things and were being conflated.

Adding a slash everywhere was rejected as a guess that breaks query-string and file-extension URLs. The worker follows the redirect once instead, resolving canonically where SSRF validation already lives.

A second layer shipped alongside it, because several of the monitored sites serve no cache headers at all: a raw-hash pre-check that does a free GET over our own egress, hashes the body, and skips the paid scrape on a match. The redirect fix helps cooperative servers, the raw hash covers the rest.

A dead sentinel

A failure-sentinel constant was read in the hash comparison but never written anywhere. Zero rows carried it. The ordering hazard is that repairing the read path is exactly what would have made that dead branch live. Dead code is not automatically safe to fix.

Troubleshooting reference

SymptomRoot causeFix
Same “change” announced repeatedly, weeks apartPrior-state lookup returns a failure row with no content hash, so the classifier gets null and describes instead of comparingSplit the lookup: newest-any-status for validators, newest-with-content for the hash
Change events cluster right after failed crawlsA failure row is the newest row, poisoning the next run’s gateFilter the content read on a non-null hash
Cache pre-check never returns 304 despite server sending ETagsURL normalization produces a redirect, and the guard treats 3xx as inconclusiveDedup on a key, fetch the URL as stored
A guard branch never firesThe sentinel it tests is read but never writtenConfirm with a count before trusting the branch, and expect fixing the read to make it reachable

Verification checklist

ComponentTest actionExpected result
Prior-state splitRun the worker on a URL whose newest row is a failureA real content hash is recovered, not null
Cache validatorsSame run, on a site that serves ETagsFresh ETag still read from the newest row of any status
Conditional GETcurl -H "If-None-Match: <etag>" on the stored URL formHTTP 304
URL canonicalizationcurl -sI the stripped form and the stored formStripped form 301s, stored form 200s

What two weeks of live data said

The phantom events stopped. In the 30 days before the fix, 24 of 81 successful crawls produced a change event. In the two weeks after, 1 of 16 did, and that one was a real hero-section rewrite with a distinct content hash.

The cost prediction did not land the way I expected. Free skips ran at 4 of 31 runs after the fix versus 18 of 125 before, which is no measurable improvement yet. The redirect fix and the raw-hash layer are both in place and both fire in the logs, but the sample is small and a third of the runs since still ended in a timeout before any header came back. The 60% versus 0% measurement in the code comment is the evidence that the fix is right. I will report the burn line once there is enough post-fix data to measure it.

What to take from this

  • A classifier handed no prior state will describe rather than compare, and a description reads as a change. The guard belongs on the data path, because the prompt behaved as designed.
  • URL normalization upstream of an HTTP cache check can silently disable it, and the only symptom is a bill that nothing errors on.
  • Both bugs produced correct-looking output, and they surfaced only because I checked the burn number against the crawl table.

Raw draft by Claude from session transcripts. Edited, fact-checked, and finalized by Matteo Duò.

Share this post on LinkedIn

Posted in Building in public, How To