From dad1e2962efc71546d27e91d7c38df3ab35a19af Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 18 Aug 2026 19:35:59 +0000 Subject: [PATCH] Stop retrying a connected account that has failed 2953 times MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sp_account.consecutive_failures was incremented on every failure and reset on every success, and then never read by anything. So an account whose posting had stopped working entirely was retried on every cadence tick, indefinitely. One Reddit account is at 2,953 consecutive failures with zero successes. Over the last seven days it was attempted 383 times and posted 0 times, each attempt launching a real browser. Nothing surfaced this: the campaign looked healthy, the account still read `active`, and the only trace was a `last_error` on rows nobody had reason to open. The browser path does disable an account, but only when it recognises a login wall. That is the failure you get when cookies die. It is not the failure you get when a site redesigns: then the selector simply stops matching and every attempt fails with a plain timeout, which no rule looked at. Reddit moved its composer to shreddit web components on 2026-08-14 and the account has failed on `shreddit-composer[name="body"]` 292 times since. So both post paths now stop at a ceiling of 10 consecutive failures and set status token_expired — the status the UI already renders as "reconnect", and one that account selection skips, so the retries stop and the user finds out. The rule lives in its own dependency-free module because the Next server and the Playwright worker both need it, and the server must not pull the browser stack into its bundle to read one number. This does not fix the selectors. The accounts that work are the OAuth ones; every failing account is cookie-auth, and reconnecting those over OAuth — which this codebase already implements for reddit, x, linkedin and facebook — skips the browser entirely. Co-Authored-By: Claude Opus 5 (1M context) --- lib/sp/accountHealth.ts | 35 ++++++++++++++++++++++++++++++ lib/sp/browserPost.ts | 9 ++++++-- lib/sp/post.ts | 10 ++++++++- tests/sp/account-health.test.ts | 38 +++++++++++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 lib/sp/accountHealth.ts create mode 100644 tests/sp/account-health.test.ts 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); + }); +});