fix: park the growth page on Reddit's origin before fetching
Growth could never post. Its page is opened with newPage() and was never navigated, so it sat on about:blank, whose opaque origin cannot fetch Reddit at all — production logged "Failed to read the Reddit session modhash: TypeError: Failed to fetch" on every attempt. Confirmed against real network: the same fetch throws from about:blank and succeeds from https://old.reddit.com. The transport now parks the page on that origin before any request, and re-parks it if the page wanders elsewhere. Being same-origin is also what sends the session cookies, so the authenticated info.json lookup moved to the same host rather than www; the signed-out visibility probe stays on www because it runs from Node, where CORS does not apply. The unit tests could not catch this: they stub the transport, and the earlier browser check navigated the page by hand in the harness — the production path was the one place that never did. The page fake now starts on about:blank like the real thing and asserts the navigation. Also stop a transport failure from invalidating the account session. A modhash lookup that could not reach Reddit returned session-invalid, which calls auth.invalidate() and so would disrupt outreach and chat for that account over a network blip. Only Reddit actually reporting a signed-out session (HTTP 401/403, or a response with no modhash) does that now; anything else is retryable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1ac6ad22ad
commit
f36e93d5be
2 files changed
+248
-42
No files matched your search
+119
-42
@@ -1,7 +1,13 @@
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { RedditRequestScheduler } from './request-coordinator.js';
|
||||
|
||||
const OLD_REDDIT = 'https://old.reddit.com';
|
||||
/**
|
||||
* Every authenticated call is made against this one origin so the page issuing
|
||||
* it is same-origin: a cross-origin fetch would not send the session cookies,
|
||||
* and a page still on `about:blank` cannot reach Reddit at all.
|
||||
*/
|
||||
export const REDDIT_WRITE_ORIGIN = 'https://old.reddit.com';
|
||||
/** The public view, only ever fetched signed out from Node, so CORS never applies. */
|
||||
const WWW_REDDIT = 'https://www.reddit.com';
|
||||
|
||||
/**
|
||||
@@ -25,6 +31,11 @@ export type CommentPostResult =
|
||||
/** Anything else: transient, safe to retry later. */
|
||||
| { status: 'failed'; reason: string };
|
||||
|
||||
/** Why a modhash lookup failed, so the caller can pick a proportionate response. */
|
||||
type ModhashResult =
|
||||
| { ok: true; modhash: string }
|
||||
| { ok: false; kind: 'unreachable' | 'signed-out'; reason: string };
|
||||
|
||||
export interface RedditCommentState {
|
||||
fullname: string;
|
||||
score: number | null;
|
||||
@@ -126,16 +137,20 @@ export class RedditCommentClient {
|
||||
}
|
||||
|
||||
const modhash = await this.ensureModhash();
|
||||
if (!modhash) {
|
||||
return { status: 'session-invalid', reason: 'Reddit did not return a modhash for this session' };
|
||||
if (!modhash.ok) {
|
||||
// Only Reddit actually reporting a signed-out session may invalidate the
|
||||
// account's auth; anything else is transient and simply retried.
|
||||
return modhash.kind === 'signed-out'
|
||||
? { status: 'session-invalid', reason: modhash.reason }
|
||||
: { status: 'failed', reason: modhash.reason };
|
||||
}
|
||||
|
||||
let response: HttpTextResponse;
|
||||
try {
|
||||
response = await this.scheduler.schedule(() => this.transport.postForm(
|
||||
`${OLD_REDDIT}/api/comment`,
|
||||
{ thing_id: threadFullname, text, api_type: 'json', uh: modhash },
|
||||
{ 'X-Modhash': modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
`${REDDIT_WRITE_ORIGIN}/api/comment`,
|
||||
{ thing_id: threadFullname, text, api_type: 'json', uh: modhash.modhash },
|
||||
{ 'X-Modhash': modhash.modhash, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
));
|
||||
} catch (error) {
|
||||
return { status: 'failed', reason: errorMessage(error) };
|
||||
@@ -188,7 +203,7 @@ export class RedditCommentClient {
|
||||
if (ids.length === 0) return [];
|
||||
if (ids.length > 100) throw new Error('Reddit accepts at most 100 fullnames per info lookup');
|
||||
|
||||
const url = `${WWW_REDDIT}/api/info.json?id=${ids.join(',')}&raw_json=1`;
|
||||
const url = `${REDDIT_WRITE_ORIGIN}/api/info.json?id=${ids.join(',')}&raw_json=1`;
|
||||
const response = await this.scheduler.schedule(() => this.transport.get(url));
|
||||
if (response.status !== 200) {
|
||||
throw new Error(`Reddit returned HTTP ${response.status} for comment info`);
|
||||
@@ -232,28 +247,60 @@ export class RedditCommentClient {
|
||||
this.modhash = null;
|
||||
}
|
||||
|
||||
private async ensureModhash(): Promise<string | null> {
|
||||
if (this.modhash) return this.modhash;
|
||||
/**
|
||||
* Resolves this session's modhash, distinguishing "we could not reach Reddit"
|
||||
* from "Reddit says this session is not signed in".
|
||||
*
|
||||
* The distinction matters beyond growth: a `session-invalid` result
|
||||
* invalidates the account's shared auth context, which would disrupt outreach
|
||||
* and chat for that account. A network or browser failure must never do that.
|
||||
*/
|
||||
private async ensureModhash(): Promise<ModhashResult> {
|
||||
if (this.modhash) return { ok: true, modhash: this.modhash };
|
||||
let response: HttpTextResponse;
|
||||
try {
|
||||
const response = await this.scheduler.schedule(
|
||||
() => this.transport.get(`${OLD_REDDIT}/api/me.json`),
|
||||
response = await this.scheduler.schedule(
|
||||
() => this.transport.get(`${REDDIT_WRITE_ORIGIN}/api/me.json`),
|
||||
);
|
||||
if (response.status !== 200) {
|
||||
this.logger.warn(`Reddit returned HTTP ${response.status} for the session identity check`);
|
||||
return null;
|
||||
}
|
||||
const payload = parseJson(response.body);
|
||||
const data = payload && typeof payload === 'object'
|
||||
? (payload as { data?: { modhash?: unknown } }).data
|
||||
: undefined;
|
||||
const modhash = typeof data?.modhash === 'string' ? data.modhash : '';
|
||||
if (!modhash) return null;
|
||||
this.modhash = modhash;
|
||||
return modhash;
|
||||
} catch (error) {
|
||||
this.logger.warn('Failed to read the Reddit session modhash:', error);
|
||||
return null;
|
||||
this.logger.warn('Could not reach Reddit to read the session modhash:', error);
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'unreachable',
|
||||
reason: `Could not reach Reddit to read the session modhash: ${errorMessage(error)}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'signed-out',
|
||||
reason: `Reddit returned HTTP ${response.status} for the session identity check`,
|
||||
};
|
||||
}
|
||||
if (response.status !== 200) {
|
||||
this.logger.warn(`Reddit returned HTTP ${response.status} for the session identity check`);
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'unreachable',
|
||||
reason: `Reddit returned HTTP ${response.status} for the session identity check`,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = parseJson(response.body);
|
||||
const data = payload && typeof payload === 'object'
|
||||
? (payload as { data?: { modhash?: unknown } }).data
|
||||
: undefined;
|
||||
const modhash = typeof data?.modhash === 'string' ? data.modhash : '';
|
||||
if (!modhash) {
|
||||
return {
|
||||
ok: false,
|
||||
kind: 'signed-out',
|
||||
reason: 'Reddit did not return a modhash for this session',
|
||||
};
|
||||
}
|
||||
this.modhash = modhash;
|
||||
return { ok: true, modhash };
|
||||
}
|
||||
|
||||
private classifyError(
|
||||
@@ -304,34 +351,64 @@ export class RedditCommentClient {
|
||||
* the request carries that profile's cookies and looks like the signed-in user.
|
||||
*/
|
||||
export interface EvaluatingPage {
|
||||
url(): string;
|
||||
goto(url: string, options?: { waitUntil?: 'domcontentloaded'; timeout?: number }): Promise<unknown>;
|
||||
evaluate<Result, Argument>(
|
||||
fn: (argument: Argument) => Promise<Result>,
|
||||
argument: Argument,
|
||||
): Promise<Result>;
|
||||
}
|
||||
|
||||
export function isRedditWriteOrigin(value: string): boolean {
|
||||
try {
|
||||
return new URL(value).origin === REDDIT_WRITE_ORIGIN;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createBrowserWriteTransport(page: EvaluatingPage): RedditWriteTransport {
|
||||
/**
|
||||
* Parks the page on the Reddit origin before it fetches anything.
|
||||
*
|
||||
* A freshly opened page sits on `about:blank`, whose opaque origin cannot
|
||||
* reach Reddit at all — the fetch fails outright with "Failed to fetch". Any
|
||||
* other origin would make these calls cross-origin, so the session cookies
|
||||
* would be withheld even if CORS allowed the request. Being on the same
|
||||
* origin is what makes them authenticated.
|
||||
*/
|
||||
const ensureOrigin = async (): Promise<void> => {
|
||||
if (isRedditWriteOrigin(page.url())) return;
|
||||
await page.goto(REDDIT_WRITE_ORIGIN, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
};
|
||||
|
||||
return {
|
||||
// The page already carries the profile's cookies and user agent, so the
|
||||
// request needs no explicit identity of its own.
|
||||
get: (url) => page.evaluate(async ({ target }) => {
|
||||
const response = await fetch(target, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, { target: url }),
|
||||
get: async (url) => {
|
||||
await ensureOrigin();
|
||||
return page.evaluate(async ({ target }) => {
|
||||
const response = await fetch(target, {
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, { target: url });
|
||||
},
|
||||
|
||||
postForm: (url, form, headers) => page.evaluate(async ({ target, fields, extraHeaders }) => {
|
||||
const body = new URLSearchParams(fields).toString();
|
||||
const response = await fetch(target, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', ...extraHeaders },
|
||||
body,
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, { target: url, fields: form, extraHeaders: headers ?? {} }),
|
||||
postForm: async (url, form, headers) => {
|
||||
await ensureOrigin();
|
||||
return page.evaluate(async ({ target, fields, extraHeaders }) => {
|
||||
const body = new URLSearchParams(fields).toString();
|
||||
const response = await fetch(target, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json', ...extraHeaders },
|
||||
body,
|
||||
});
|
||||
return { status: response.status, body: await response.text() };
|
||||
}, { target: url, fields: form, extraHeaders: headers ?? {} });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user