From 3d0b5addb1e68e9bd8041dacd57cc244e57e9df7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 13 Aug 2026 04:21:59 +0000 Subject: [PATCH] fix(outreach): do not rest a campaign for a quota failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The back-off asks whether a discovery pass produced anything, but an empty ValueSERP plan answers HTTP 402 without running the queries at all — which looks identical to a query list with nothing left to give. Left as it was, the month-end 402 that prompted this work would have rested every campaign for up to a day, and since the plan is one shared monthly bucket they would all have been resting by the time it refilled, pushing recovery well past the reset it was supposed to ride out. serpCreditsExhausted() exposes the cooldown the previous 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 is the one that decides whether the queries still work. Co-Authored-By: Claude Opus 5 (1M context) --- lib/alerts/valueserp.ts | 12 ++++++++++++ lib/outreach/runner.ts | 11 ++++++++++- tests/discovery-backoff.test.ts | 31 +++++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/alerts/valueserp.ts b/lib/alerts/valueserp.ts index efa7778..510948a 100644 --- a/lib/alerts/valueserp.ts +++ b/lib/alerts/valueserp.ts @@ -72,6 +72,18 @@ export function resetSerpCreditCooldown(): void { outOfCreditsUntil = 0; } +/** + * Is the account known to be out of credits right now? + * + * Callers that judge their own searches by what came back need this to tell + * "these queries are tapped out" from "we could not run them at all". Without + * it, a month-end 402 looks identical to an exhausted query list, and the + * back-off punishes queries that were never given a chance. + */ +export function serpCreditsExhausted(): boolean { + return Date.now() < outOfCreditsUntil; +} + /** * Run one ValueSERP search. Returns billable `calls` even on an empty result * set so the caller can debit the budget accurately. Retries once on a diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts index 1e34c61..f8637b9 100644 --- a/lib/outreach/runner.ts +++ b/lib/outreach/runner.ts @@ -28,6 +28,7 @@ import { leadRunBilling, outOfCreditsNote } from "./billing"; import { recordDiscoveredPeople } from "./contacts"; import { sweepIntent } from "./intentSources"; import { describeIntent } from "./intent"; +import { serpCreditsExhausted } from "@/lib/alerts/valueserp"; import { LEAD_RUN_CREDITS } from "@/lib/credits"; import { sendIntentAlertEmail } from "@/lib/email"; import { siteBase } from "./pipeline"; @@ -447,7 +448,15 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise { expect(fetchMock).toHaveBeenCalledTimes(1); }); + // The back-off asks "did these queries produce anything?". An empty plan + // means they never ran, so it must be able to tell the two apart — otherwise + // a month-end 402 rests every campaign for a day and recovery lands well + // after the credits do. + it("reports the exhausted state so the back-off can discount the pass", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 402 })) as unknown as typeof fetch; + const { searchSerp, serpCreditsExhausted, resetSerpCreditCooldown } = await import( + "@/lib/alerts/valueserp" + ); + resetSerpCreditCooldown(); + + expect(serpCreditsExhausted()).toBe(false); + await searchSerp({ query: "web development agencies", recency: "any" }); + expect(serpCreditsExhausted()).toBe(true); + + resetSerpCreditCooldown(); + expect(serpCreditsExhausted()).toBe(false); + }); + + it("does not report exhaustion for an ordinary failure", async () => { + globalThis.fetch = vi.fn(async () => new Response("", { status: 400 })) as unknown as typeof fetch; + const { searchSerp, serpCreditsExhausted, resetSerpCreditCooldown } = await import( + "@/lib/alerts/valueserp" + ); + resetSerpCreditCooldown(); + + const res = await searchSerp({ query: "bad query", recency: "any" }); + expect(res.ok).toBe(false); + expect(serpCreditsExhausted()).toBe(false); + }); + it("never bills a call it did not make", async () => { globalThis.fetch = vi.fn(async () => new Response("", { status: 402 })) as unknown as typeof fetch; const { searchSerp, resetSerpCreditCooldown } = await import("@/lib/alerts/valueserp");