Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions lib/alerts/valueserp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion lib/outreach/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -447,7 +448,15 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise<TickR
// about whether the queries still work, and letting it reset the streak
// would put a tapped-out campaign straight back to a search every fifteen
// minutes the moment its pipeline drained.
if (discoveryRan) {
// A pass that came back empty because the plan is out of credits says
// nothing about the queries — they never ran. Counting it would rest a
// perfectly good campaign for up to a day, and since the plan is a monthly
// bucket every campaign would be resting by the time it refilled, delaying
// recovery well past the reset. Leave the streak where it is and let the
// first pass with credits behind it be the one that votes.
if (discoveryRan && serpCreditsExhausted()) {
result.skipped.push("discovery: no credits, not counting this pass against the queries");
} else if (discoveryRan) {
// People and intent signals count. A run against a people-directory names
// humans without adding a prospect, and a query surfacing fresh buying
// intent is working even when it yields no new domain — backing either
Expand Down
31 changes: 31 additions & 0 deletions tests/discovery-backoff.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,37 @@ describe("ValueSERP out-of-credit cooldown", () => {
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");
Expand Down
Loading