diff --git a/lib/sp/accountHealth.ts b/lib/sp/accountHealth.ts new file mode 100644 index 00000000..6362f1a7 --- /dev/null +++ b/lib/sp/accountHealth.ts @@ -0,0 +1,35 @@ +// Shared health rules for connected social accounts. +// +// Deliberately dependency-free: both the Next server (lib/sp/post.ts) and the +// Playwright worker (lib/sp/browserPost.ts) import this, and the server must +// not pull the browser stack into its bundle to read one number. + +/** + * How many consecutive posting failures an account may rack up before we stop + * posting to it and ask the user to reconnect. + * + * The browser path recognises a dead session by its login wall, but that only + * catches a *recognised* failure. When a site redesigns and a selector stops + * matching, every attempt fails with a plain timeout instead, nothing flags the + * account, and the worker keeps driving a browser at it every cadence tick + * forever. One Reddit account reached 2,953 consecutive failures and 0 + * successes that way, because nothing ever read the counter it was + * incrementing. + */ +export const MAX_CONSECUTIVE_FAILURES = 10; + +/** + * Whether this failure should take the account out of rotation. + * + * `token_expired` is the status the UI already renders as "reconnect", and + * account selection only ever picks up `active` rows, so setting it both stops + * the retries and tells the user why. + */ +export function shouldDisableAccount(args: { + consecutiveFailures: number; + sessionExpired?: boolean; +}): boolean { + return ( + Boolean(args.sessionExpired) || args.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES + ); +} diff --git a/lib/sp/browserPost.ts b/lib/sp/browserPost.ts index e2e748cc..58c95ea2 100644 --- a/lib/sp/browserPost.ts +++ b/lib/sp/browserPost.ts @@ -36,6 +36,8 @@ import { reconcilePromo } from "@/lib/promote/reconcilePromo"; import { pickDefaultSubreddit } from "@/lib/sp/redditSubreddit"; import { makeCodeWaiter } from "@/lib/sp/verificationChallenge"; +import { shouldDisableAccount } from "@/lib/sp/accountHealth"; + export async function processBrowserPost(args: { postId: string; supabase: SupabaseClient; @@ -219,11 +221,14 @@ async function fail( // token_expired so the UI prompts a reconnect (re-export cookies) and stops // posting to it until then. const sessionExpired = message.startsWith(LOGIN_WALL_PREFIX); + const failures = ((acct?.consecutive_failures ?? 0) as number) + 1; await supabase .from("sp_account") .update({ - consecutive_failures: ((acct?.consecutive_failures ?? 0) as number) + 1, - ...(sessionExpired ? { status: "token_expired" } : {}), + consecutive_failures: failures, + ...(shouldDisableAccount({ consecutiveFailures: failures, sessionExpired }) + ? { status: "token_expired" } + : {}), }) .eq("id", accountId); } diff --git a/lib/sp/post.ts b/lib/sp/post.ts index 63defd47..38f21504 100644 --- a/lib/sp/post.ts +++ b/lib/sp/post.ts @@ -11,6 +11,7 @@ import { graphemeLength } from "./blueskyFacets"; import { encryptSecret, decryptSecret } from "@/lib/sp/vault"; import { enqueueBrowserPost } from "@/lib/lx/workerClient"; import { resolveSubreddit } from "@/lib/sp/redditSubreddit"; +import { shouldDisableAccount } from "@/lib/sp/accountHealth"; import { createBlueskyPost, createBlueskySession, @@ -424,10 +425,17 @@ export async function postViaAccount(args: { .from("sp_post") .update({ status: "failed", last_error: message }) .eq("id", row.id); + // Same ceiling the browser path applies: an account that has failed this + // many times in a row is not going to start working on the next tick, and + // retrying it forever just burns quota and hides the real problem. + const failures = (account.consecutive_failures ?? 0) + 1; await supabase .from("sp_account") .update({ - consecutive_failures: (account.consecutive_failures ?? 0) + 1, + consecutive_failures: failures, + ...(shouldDisableAccount({ consecutiveFailures: failures }) + ? { status: "token_expired" } + : {}), }) .eq("id", account.id); await supabase.from("sp_publish_attempt").insert({ diff --git a/tests/sp/account-health.test.ts b/tests/sp/account-health.test.ts new file mode 100644 index 00000000..03cc7e66 --- /dev/null +++ b/tests/sp/account-health.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { + MAX_CONSECUTIVE_FAILURES, + shouldDisableAccount, +} from "@/lib/sp/accountHealth"; + +describe("shouldDisableAccount", () => { + it("keeps an account in rotation through a run of transient failures", () => { + expect(shouldDisableAccount({ consecutiveFailures: 1 })).toBe(false); + expect( + shouldDisableAccount({ consecutiveFailures: MAX_CONSECUTIVE_FAILURES - 1 }), + ).toBe(false); + }); + + it("takes it out once the failures stop looking transient", () => { + expect( + shouldDisableAccount({ consecutiveFailures: MAX_CONSECUTIVE_FAILURES }), + ).toBe(true); + }); + + it("takes it out immediately on a recognised dead session", () => { + // A login wall is proof, not a guess — no need to burn ten attempts. + expect(shouldDisableAccount({ consecutiveFailures: 1, sessionExpired: true })).toBe( + true, + ); + }); + + it("would have caught the Reddit account that failed 2953 times", () => { + // The regression this exists for: selector timeouts are not login walls, so + // nothing flagged the account and the worker retried it for days. + expect(shouldDisableAccount({ consecutiveFailures: 2953 })).toBe(true); + }); + + it("resets are the caller's job — a success zeroes the counter", () => { + // Guards the contract the post paths rely on: 0 failures is always healthy. + expect(shouldDisableAccount({ consecutiveFailures: 0 })).toBe(false); + }); +});