fix: stop Reddit's block page reading as a signed-out session

Reddit answers a throttled client with a large HTML 403 interstitial. To
the comment client that was indistinguishable from a logged-out session:
the body would not parse, so no modhash was found, so it reported
"Reddit did not return a modhash for this session" and returned
session-invalid — which calls auth.invalidate() and so could disrupt
outreach and chat for an account whose session was perfectly healthy.

That matches what prod is doing. u/Saira_Makimad posted successfully 25
seconds after boot and every later attempt failed with that message,
which is the shape of Reddit starting to throttle rather than a session
going bad.

A response is now only treated as a verdict about the session when it is
actually JSON. Non-JSON, at any status, is Reddit refusing to serve us
and stays retryable. A genuine JSON 403, or JSON with no modhash, still
invalidates as before.

When Reddit does report a signed-out session, the log and the recorded
reason now name the cookies visible to the write origin — names and
domains only, never values. Cookies scoped to www.reddit.com rather than
.reddit.com would produce exactly this symptom, and this makes that
answerable from the logs instead of by guesswork.

Also clarify the allowance in the dashboard. It is derived from the trust
level rather than typed in, and it is a rolling 24-hour window, neither
of which the page said. A per-community cap above the account allowance
now says how much of it is actually reachable, because "1 of 10 today"
next to an account limit of 2 reads as far more headroom than exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 20:59:25 +01:00
1 parent f36e93d5be
commit f88db1ae7d
5 files changed
+240 -18

No files matched your search

+49 -2
View File
@@ -71,6 +71,12 @@ export interface RedditCommentClientDependencies {
scheduler?: RedditRequestScheduler;
anonymousFetch?: AnonymousFetcher;
now?: () => number;
/**
* Names and domains of the cookies visible to Reddit, never their values.
* Included in the log when Reddit reports a signed-out session, because the
* usual cause is cookies scoped to a host that this origin cannot see.
*/
describeCookies?: () => Promise<string>;
}
const passthroughScheduler: RedditRequestScheduler = {
@@ -107,6 +113,7 @@ export class RedditCommentClient {
private readonly scheduler: RedditRequestScheduler;
private readonly anonymousFetch: AnonymousFetcher | null;
private readonly now: () => number;
private readonly describeCookies: (() => Promise<string>) | null;
constructor(
private readonly transport: RedditWriteTransport,
@@ -116,6 +123,7 @@ export class RedditCommentClient {
this.scheduler = dependencies.scheduler ?? passthroughScheduler;
this.anonymousFetch = dependencies.anonymousFetch ?? null;
this.now = dependencies.now ?? Date.now;
this.describeCookies = dependencies.describeCookies ?? null;
}
/**
@@ -157,7 +165,12 @@ export class RedditCommentClient {
}
if (response.status === 403) {
// A 403 here is ambiguous between a dead session and a community ban, so
// Reddit's anti-abuse interstitial is an HTML 403. It says nothing about
// whether we are signed in, so it must not invalidate the account.
if (!looksLikeJson(response.body)) {
return { status: 'failed', reason: blockPageReason(response) };
}
// A JSON 403 is ambiguous between a dead session and a community ban, so
// drop the cached modhash and let the next attempt re-derive identity.
this.modhash = null;
return { status: 'session-invalid', reason: 'Reddit rejected the comment with HTTP 403' };
@@ -271,6 +284,13 @@ export class RedditCommentClient {
};
}
// A non-JSON body is Reddit refusing to serve us at all — an anti-abuse
// interstitial or error page. Reading that as "signed out" would invalidate
// a perfectly good session and disrupt outreach, so it stays retryable.
if (!looksLikeJson(response.body)) {
this.logger.warn(`Growth: ${blockPageReason(response)}`);
return { ok: false, kind: 'unreachable', reason: blockPageReason(response) };
}
if (response.status === 401 || response.status === 403) {
return {
ok: false,
@@ -293,16 +313,32 @@ export class RedditCommentClient {
: undefined;
const modhash = typeof data?.modhash === 'string' ? data.modhash : '';
if (!modhash) {
// Reddit answered with real JSON and no modhash, so it genuinely does not
// recognise this session. The cookie summary is the fastest way to see
// why — most often they are scoped to a host this origin cannot read.
const cookies = await this.describeSessionCookies();
this.logger.warn(
`Growth: Reddit does not recognise this session on ${REDDIT_WRITE_ORIGIN}. Cookies visible: ${cookies}`,
);
return {
ok: false,
kind: 'signed-out',
reason: 'Reddit did not return a modhash for this session',
reason: `Reddit did not return a modhash for this session (cookies visible: ${cookies})`,
};
}
this.modhash = modhash;
return { ok: true, modhash };
}
private async describeSessionCookies(): Promise<string> {
if (!this.describeCookies) return 'not inspected';
try {
return await this.describeCookies();
} catch (error) {
return `could not be read (${errorMessage(error)})`;
}
}
private classifyError(
error: { code: string; message: string },
subreddit: string,
@@ -490,6 +526,17 @@ function readListing(payload: unknown): Map<string, { score: number | null; remo
return found;
}
/** Reddit serves JSON for real answers and HTML when it is blocking or erroring. */
function looksLikeJson(body: string): boolean {
const start = body.trimStart();
return start.startsWith('{') || start.startsWith('[');
}
function blockPageReason(response: HttpTextResponse): string {
return `Reddit served a non-JSON HTTP ${response.status} response `
+ `(${response.body.length} bytes), which is an anti-abuse block page rather than a session verdict`;
}
function parseJson(body: string): unknown {
try {
return JSON.parse(body) as unknown;
@@ -21,6 +21,7 @@ import {
} from '../database/account-repositories.js';
import { RedditApiClient, RedditRateLimitError, type RedditPost } from '../reddit/api.js';
import {
REDDIT_WRITE_ORIGIN,
RedditCommentClient,
createAnonymousFetcher,
createBrowserWriteTransport,
@@ -258,6 +259,13 @@ async function buildRuntime(
{
scheduler: redditRequestCoordinator,
anonymousFetch: createAnonymousFetcher(accountConfig.reddit.userAgent),
// Names and domains only — never cookie values. This is the fastest way
// to see why Reddit stopped recognising a session on the write origin.
describeCookies: async () => {
const cookies = await browserClient.getContext()?.cookies(REDDIT_WRITE_ORIGIN) ?? [];
if (cookies.length === 0) return `none visible to ${REDDIT_WRITE_ORIGIN}`;
return cookies.map((cookie) => `${cookie.name}@${cookie.domain}`).join(', ');
},
},
);
const growthService = new AccountGrowthService({