fix(outreach): stop campaigns burning the ValueSERP plan on dead queries - #194
Conversation
Three active campaigns emptied the 25,000-call monthly ValueSERP plan in six days, after which every search returned HTTP 402 — including the ones a human typed into the lead finder, which surfaced as "No free engine returned results" once the free fallbacks were tried and also failed. The runner was not misbehaving; it was doing what it was told. The funnel gate counts only prospects still in flight, so a campaign that has contacted everyone it found reads as empty forever, re-running its identical five-query list every fifteen minutes and paying full price for hosts it already had. Three campaigns worked out to ~43k searches a month against a 25k plan, at a measured 0-7 new prospects a day. Counting contacted prospects toward the target would have stopped it, but target_pipeline means "leads to keep in the funnel" and defaults to 25 — so that would cap a campaign at 25 leads for its entire life and turn an autopilot into a one-shot. Instead the gate stays as it is and an unproductive pass waits longer before trying again: 30 minutes doubling to a 24h ceiling, reset by a single new prospect, person or intent signal. Nothing is capped, and a dry campaign drops from 96 discovery passes a day to one. Also: - ValueSERP now parks on HTTP 402 for ten minutes rather than letting all ~21 searches in a tick each pay a round-trip to learn the plan is empty. No credit is billed either way; this buys latency and legible errors. - SERP_CALLS_PER_MONTH authorised a single pro account 200k calls out of a shared 25k bucket, so the cost backstop could not actually stop anything. Brought under the vendor plan, which is now named alongside it. The free engines cannot cover for any of this from a server: DuckDuckGo answers its anti-bot challenge (HTTP 202) from every datacentre IP tried, and Mojeek 403s from Railway while serving the same query from a residential host. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThreatCrush Security Scan35 finding(s) HIGH/CRITICAL: 5 | MEDIUM: 27 | LOW: 3
Snippets are redacted; ThreatCrush never prints matched credential material. |
Follow-up commit: don't rest a campaign for a quota failureFound while working out the safe re-enable ordering, so pushing it before this merges. The back-off asks "did this pass produce anything?" — but an empty plan answers HTTP 402 without running the queries at all, which is indistinguishable from a query list with nothing left to give. Left as it was, the very 402 that prompted this PR would have rested every campaign for up to 24h. And because the plan is one shared monthly bucket, they'd all have been resting by the time it refilled — pushing recovery well past the 16:40 UTC reset it was meant to ride out.
Two tests added: the exhausted state is reported after a 402 and cleared on reset, and an ordinary 400 does not report exhaustion (so a genuinely bad query still counts against the back-off, which is the point of it).
|
What broke
Searching
web development agenciesin the lead finder returned:The ValueSERP plan was at 0 of 25,000 monthly credits. 402 is its out-of-credits code. The cycle resets on the 13th, so it recovers on its own — then drains again within a week. This PR fixes the drain.
Why it drained
runEmailCampaignTickgates discovery onliveCount < target_pipeline, andliveCountcounts onlynew|researched|drafted—contactedis excluded. The three active campaigns had emailed nearly everything they found:So the gate never closed. Every 15-minute tick re-ran the same 5 queries per campaign and found nothing new, because
knownfilters out hosts already on the board. Measured: ~290 runs/day discovering 0–7 prospects/day while burning 3–4k credits/day.3 campaigns × 5 queries × 96 ticks/day = 1,440/day = 43,200/monthagainst a 25,000 plan — 1.7× over before a single contact lookup. Withmin_intentset, each tick also spends up to 6 intent searches and 10 contact lookups, which matches the observed 3–4k/day.The fix
Counting
contactedtoward the target would stop it, buttarget_pipelinemeans "leads to keep in the funnel" and defaults to 25 — that would cap a campaign at 25 leads for its entire life and turn the autopilot into a one-shot. I did not do that.Instead the gate is unchanged and an unproductive pass simply waits longer before retrying: 30 minutes doubling to a 24h ceiling, reset to full speed by a single new prospect, person, or intent signal. Nothing is capped — a tapped-out query set is still retried daily. A dry campaign drops from 96 discovery passes a day to 1.
Two smaller items:
searchSerpnow parks for 10 minutes on 402 instead of letting all ~21 searches in a tick each pay a round-trip to learn the plan is empty. No credit is billed either way; this buys latency and legible errors (one failure reported, not twenty).SERP_CALLS_PER_MONTHauthorised one pro account 200k calls out of a shared 25k bucket, so the cost backstop could not stop anything. Brought under the vendor plan, which is now named asVALUESERP_MONTHLY_PLANbeside it.On the free fallback
The error message is accurate, not a red herring — I tested both engines directly. DuckDuckGo answers its anti-bot challenge (202) from every datacentre IP tried, including the dev box. Mojeek returns 200 from the dev box but 403 from Railway. In prod, ValueSERP is effectively the only discovery path, so
freeSearch.tsshould be treated as best-effort only.Already applied outside this PR
active=false) so the quota arriving at 16:40 UTC isn't drained before this ships. Re-enable after deploy.Verification
npx tsc --noEmit— cleannpx vitest run— 1434 passed, 7 skipped, 0 failedtests/discovery-backoff.test.tscovers the back-off curve, the 96→1 reduction, the budget-under-plan invariant, and the 402 cooldown (21 searches → 1 fetch)tests/lead-billing.test.tshad a fixed 400-char source assertion that my added comment pushed past. The gate still callscanSpend(); I re-anchored the assertion to the gate's own condition, which is stricter than the byte window it replaces.🤖 Generated with Claude Code