Skip to content

Commit b66d1e4

Browse files
ralyodioclaude
andauthored
fix(outreach): do not rest a campaign for a quota failure (#195)
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) <noreply@anthropic.com>
1 parent ccdb4a1 commit b66d1e4

3 files changed

Lines changed: 53 additions & 1 deletion

File tree

‎lib/alerts/valueserp.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,18 @@ export function resetSerpCreditCooldown(): void {
7272
outOfCreditsUntil = 0;
7373
}
7474

75+
/**
76+
* Is the account known to be out of credits right now?
77+
*
78+
* Callers that judge their own searches by what came back need this to tell
79+
* "these queries are tapped out" from "we could not run them at all". Without
80+
* it, a month-end 402 looks identical to an exhausted query list, and the
81+
* back-off punishes queries that were never given a chance.
82+
*/
83+
export function serpCreditsExhausted(): boolean {
84+
return Date.now() < outOfCreditsUntil;
85+
}
86+
7587
/**
7688
* Run one ValueSERP search. Returns billable `calls` even on an empty result
7789
* set so the caller can debit the budget accurately. Retries once on a

‎lib/outreach/runner.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { leadRunBilling, outOfCreditsNote } from "./billing";
2828
import { recordDiscoveredPeople } from "./contacts";
2929
import { sweepIntent } from "./intentSources";
3030
import { describeIntent } from "./intent";
31+
import { serpCreditsExhausted } from "@/lib/alerts/valueserp";
3132
import { LEAD_RUN_CREDITS } from "@/lib/credits";
3233
import { sendIntentAlertEmail } from "@/lib/email";
3334
import { siteBase } from "./pipeline";
@@ -447,7 +448,15 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
447448
// about whether the queries still work, and letting it reset the streak
448449
// would put a tapped-out campaign straight back to a search every fifteen
449450
// minutes the moment its pipeline drained.
450-
if (discoveryRan) {
451+
// A pass that came back empty because the plan is out of credits says
452+
// nothing about the queries — they never ran. Counting it would rest a
453+
// perfectly good campaign for up to a day, and since the plan is a monthly
454+
// bucket every campaign would be resting by the time it refilled, delaying
455+
// recovery well past the reset. Leave the streak where it is and let the
456+
// first pass with credits behind it be the one that votes.
457+
if (discoveryRan && serpCreditsExhausted()) {
458+
result.skipped.push("discovery: no credits, not counting this pass against the queries");
459+
} else if (discoveryRan) {
451460
// People and intent signals count. A run against a people-directory names
452461
// humans without adding a prospect, and a query surfacing fresh buying
453462
// intent is working even when it yields no new domain — backing either

‎tests/discovery-backoff.test.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,37 @@ describe("ValueSERP out-of-credit cooldown", () => {
8484
expect(fetchMock).toHaveBeenCalledTimes(1);
8585
});
8686

87+
// The back-off asks "did these queries produce anything?". An empty plan
88+
// means they never ran, so it must be able to tell the two apart — otherwise
89+
// a month-end 402 rests every campaign for a day and recovery lands well
90+
// after the credits do.
91+
it("reports the exhausted state so the back-off can discount the pass", async () => {
92+
globalThis.fetch = vi.fn(async () => new Response("", { status: 402 })) as unknown as typeof fetch;
93+
const { searchSerp, serpCreditsExhausted, resetSerpCreditCooldown } = await import(
94+
"@/lib/alerts/valueserp"
95+
);
96+
resetSerpCreditCooldown();
97+
98+
expect(serpCreditsExhausted()).toBe(false);
99+
await searchSerp({ query: "web development agencies", recency: "any" });
100+
expect(serpCreditsExhausted()).toBe(true);
101+
102+
resetSerpCreditCooldown();
103+
expect(serpCreditsExhausted()).toBe(false);
104+
});
105+
106+
it("does not report exhaustion for an ordinary failure", async () => {
107+
globalThis.fetch = vi.fn(async () => new Response("", { status: 400 })) as unknown as typeof fetch;
108+
const { searchSerp, serpCreditsExhausted, resetSerpCreditCooldown } = await import(
109+
"@/lib/alerts/valueserp"
110+
);
111+
resetSerpCreditCooldown();
112+
113+
const res = await searchSerp({ query: "bad query", recency: "any" });
114+
expect(res.ok).toBe(false);
115+
expect(serpCreditsExhausted()).toBe(false);
116+
});
117+
87118
it("never bills a call it did not make", async () => {
88119
globalThis.fetch = vi.fn(async () => new Response("", { status: 402 })) as unknown as typeof fetch;
89120
const { searchSerp, resetSerpCreditCooldown } = await import("@/lib/alerts/valueserp");

0 commit comments

Comments
 (0)