Skip to content

fix(outreach): stop campaigns burning the ValueSERP plan on dead queries - #194

Merged
ralyodio merged 1 commit into
masterfrom
worktree-valueserp-quota-burn
Aug 13, 2026
Merged

ralyodio merged 1 commit into
masterfrom
worktree-valueserp-quota-burn

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

What broke

Searching web development agencies in the lead finder returned:

ValueSERP HTTP 402; No free engine returned results.

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

runEmailCampaignTick gates discovery on liveCount < target_pipeline, and liveCount counts only new|researched|drafted — contacted is excluded. The three active campaigns had emailed nearly everything they found:

campaign contacted skipped live
CTOs 57 6 0
game designers 53 19 3
instagram designers 52 2 0

So the gate never closed. Every 15-minute tick re-ran the same 5 queries per campaign and found nothing new, because known filters 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/month against a 25,000 plan — 1.7× over before a single contact lookup. With min_intent set, each tick also spends up to 6 intent searches and 10 contact lookups, which matches the observed 3–4k/day.

The fix

Counting contacted toward the target would stop it, but target_pipeline means "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:

  • 402 backstop — searchSerp now 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_MONTH authorised 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 as VALUESERP_MONTHLY_PLAN beside 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.ts should be treated as best-effort only.

Already applied outside this PR

  • The three campaigns are paused (active=false) so the quota arriving at 16:40 UTC isn't drained before this ships. Re-enable after deploy.
  • The migration is already applied to prod via MCP (no CI applies migrations here); the file is committed for the record.

Verification

  • npx tsc --noEmit — clean
  • npx vitest run — 1434 passed, 7 skipped, 0 failed
  • New tests/discovery-backoff.test.ts covers 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.ts had a fixed 400-char source assertion that my added comment pushed past. The gate still calls canSpend(); I re-anchored the assertion to the gate's own condition, which is stricter than the byte window it replaces.

🤖 Generated with Claude Code

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>
@github-actions

Copy link
Copy Markdown

ThreatCrush Security Scan

35 finding(s)

HIGH/CRITICAL: 5 | MEDIUM: 27 | LOW: 3

Severity Rule Location
HIGH secret-generic-credential app/(marketing)/docs/autoblog-webhook/page.tsx:145
HIGH tls-verification-disabled lib/onion.ts:47
HIGH secret-generic-credential lib/sp/platforms/facebook.ts:32
HIGH secret-generic-credential lib/sp/platforms/linkedin.ts:25
HIGH sh-remote-script-execution prober/deploy/provision.sh:30
MEDIUM js-unescaped-html-sink app/(app)/admin/email-broadcast/EmailBroadcastForm.tsx:125
MEDIUM js-unescaped-html-sink app/(app)/projects/[id]/autoblog/articles/[articleId]/page.tsx:214
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:67
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:97
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:104
MEDIUM js-unescaped-html-sink app/(marketing)/blog/[slug]/page.tsx:110
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:186
MEDIUM js-unescaped-html-sink app/(marketing)/recent/page.tsx:190
MEDIUM js-unescaped-html-sink app/c/[project]/[slug]/page.tsx:77
MEDIUM js-unescaped-html-sink app/c/[project]/page.tsx:57
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:228
MEDIUM js-unescaped-html-sink app/careers.js/route.ts:285
MEDIUM js-unescaped-html-sink app/layout.tsx:129
MEDIUM js-open-redirect app/login/form.tsx:39
MEDIUM js-unescaped-html-sink app/r/[token]/page.tsx:176
MEDIUM js-open-redirect app/signup/form.tsx:43
MEDIUM js-open-redirect components/billing/buy-credits-modal.tsx:98
MEDIUM js-unescaped-html-sink components/json-ld.tsx:8
MEDIUM js-unescaped-html-sink components/report/markdown-view.tsx:15
MEDIUM redos-nested-quantifier lib/careers/jobs.ts:139
MEDIUM js-unescaped-html-sink lib/careers/page-templates.ts:198
MEDIUM redos-nested-quantifier lib/emailMarkdown.ts:130
MEDIUM redos-nested-quantifier lib/lx/articleGen.ts:93
MEDIUM js-dynamic-code-execution tests/careers-page-templates.test.ts:21
MEDIUM js-dynamic-code-execution tests/careers-widget-script.test.ts:69
MEDIUM js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:51
MEDIUM js-dynamic-code-execution tests/contract/ad-visitor-id.test.ts:52
LOW secret-generic-credential tests/contract/coinpay.test.ts:4
LOW secret-generic-credential tests/contract/posthog-integration.test.ts:13
LOW secret-generic-credential tests/lead-campaign.test.ts:16

Snippets are redacted; ThreatCrush never prints matched credential material.

@ralyodio
ralyodio merged commit ccdb4a1 into master Aug 13, 2026
8 checks passed
@ralyodio

Copy link
Copy Markdown
Contributor Author

Follow-up commit: don't rest a campaign for a quota failure

Found 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.

serpCreditsExhausted() exposes the cooldown the first commit already tracked, so a pass with no credits behind it declines to vote and leaves the streak alone. The first pass that actually reaches Google decides whether the queries still work.

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).

npx tsc --noEmit clean, npx vitest run 1436 passed / 0 failed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant