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

+48 -4
View File
@@ -195,20 +195,64 @@ describe('GrowthPage', () => {
it('surfaces the allowance, trust level and comment health', async () => {
renderGrowth();
expect(await screen.findByText('1 / 3 today')).toBeVisible();
expect(screen.getByText('1 of 4')).toBeVisible();
expect(await screen.findByText('1 / 3')).toBeVisible();
expect(screen.getByText('Used in last 24h')).toBeVisible();
expect(screen.getByText('1 of 4 → 3/day')).toBeVisible();
// 6 posted less 1 removed.
expect(screen.getByText('5')).toBeVisible();
expect(screen.getByText('4')).toBeVisible();
});
it('explains that the allowance is adaptive rather than fixed', async () => {
it('explains where the allowance comes from and that it is a rolling window', async () => {
renderGrowth();
expect(await screen.findByText(/allowance rises on its own/i)).toBeVisible();
// The number is derived from the trust level, not typed in by the operator,
// which is the thing most likely to confuse someone reading the page.
expect(await screen.findByText(/level 0 permits 2 comments a day/i)).toBeVisible();
expect(screen.getByText(/rolling 24-hour window rather than a midnight reset/i)).toBeVisible();
expect(screen.getByText(/Two removals in a row/i)).toBeVisible();
});
it('warns when a community cap exceeds what the account allowance permits', async () => {
detailPayload = detail({
settings: settings({ trustLevel: 0, dailyAllowance: 2 }),
subreddits: [{
subreddit: 'AskUK',
enabled: true,
weight: 1,
dailyCap: 10,
disabledReason: null,
cooldownUntil: null,
addedAt: 1,
postedLast24h: 1,
}],
});
renderGrowth();
// A cap of 10 with an allowance of 2 reads as "10 available" otherwise.
expect(await screen.findByText(/Only 2 of these can actually be used today/i)).toBeVisible();
});
it('does not warn when the cap sits within the allowance', async () => {
detailPayload = detail({
settings: settings({ dailyAllowance: 5 }),
subreddits: [{
subreddit: 'AskUK',
enabled: true,
weight: 1,
dailyCap: 2,
disabledReason: null,
cooldownUntil: null,
addedAt: 1,
postedLast24h: 0,
}],
});
renderGrowth();
expect(await screen.findByText('r/AskUK')).toBeVisible();
expect(screen.queryByText(/can actually be used today/i)).not.toBeInTheDocument();
});
it('toggles growth off through the API', async () => {
renderGrowth();
const user = userEvent.setup();