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
35 changes: 35 additions & 0 deletions lib/sp/accountHealth.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
9 changes: 7 additions & 2 deletions lib/sp/browserPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>;
Expand Down Expand Up @@ -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);
}
Expand Down
10 changes: 9 additions & 1 deletion lib/sp/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down
38 changes: 38 additions & 0 deletions tests/sp/account-health.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading