fix: back off after Reddit access denials

This commit is contained in:
jjbrunton committed 2026-08-08 22:37:19 +01:00
1 parent e8b730b592
commit 53e9a44a5c
5 files changed
+81 -14

No files matched your search

+25 -1
View File
@@ -17,6 +17,13 @@ export class RedditRateLimitError extends Error {
} }
} }
export class RedditAccessDeniedError extends Error {
constructor(public readonly subreddit: string) {
super(`Reddit denied listing access for r/${subreddit} (HTTP 403)`);
this.name = 'RedditAccessDeniedError';
}
}
function getRateLimitDelayMs(headers: unknown): number { function getRateLimitDelayMs(headers: unknown): number {
if (!headers || typeof headers !== 'object') return 60_000; if (!headers || typeof headers !== 'object') return 60_000;
@@ -108,6 +115,7 @@ export function findSellerDomain(text: string, domains: string[]): string | null
export class RedditApiClient { export class RedditApiClient {
private client: AxiosInstance; private client: AxiosInstance;
private browserContext: BrowserContext | null = null; private browserContext: BrowserContext | null = null;
private consecutiveListing403s = 0;
constructor( constructor(
config: Config, config: Config,
@@ -185,6 +193,7 @@ export class RedditApiClient {
.filter((post: RedditPost) => this.isValidPost(post, type)); .filter((post: RedditPost) => this.isValidPost(post, type));
this.logger.debug(`Fetched ${posts.length} valid ${type} posts from r/${subreddit}`); this.logger.debug(`Fetched ${posts.length} valid ${type} posts from r/${subreddit}`);
this.consecutiveListing403s = 0;
return posts; return posts;
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@@ -193,7 +202,22 @@ export class RedditApiClient {
return []; return [];
} }
if (error.response?.status === 403) { if (error.response?.status === 403) {
this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`); this.consecutiveListing403s += 1;
const denied = new RedditAccessDeniedError(subreddit);
// One 403 can genuinely be a private or quarantined subreddit. A
// second consecutive denial from unrelated listings is a strong
// signal that Reddit is temporarily rejecting this session/IP.
if (this.consecutiveListing403s >= 2) {
const cooldownMs = 120_000;
this.requestScheduler.extendCooldown(cooldownMs);
this.logger.warn(
`Reddit is temporarily denying listing requests (consecutive HTTP 403s); pausing for ${cooldownMs / 1000}s`,
);
throw new RedditRateLimitError(cooldownMs);
}
this.logger.warn(`${denied.message}; it may be private, quarantined, or a temporary Reddit block`);
return []; return [];
} }
const rateLimitError = this.toRateLimitError(error); const rateLimitError = this.toRateLimitError(error);
+6 -3
View File
@@ -39,10 +39,13 @@ export class RedditRequestCoordinator implements RedditRequestScheduler {
try { try {
const waitMs = this.nextRequestAt - this.now(); const waitMs = this.nextRequestAt - this.now();
if (waitMs > 0) await this.sleep(waitMs); if (waitMs > 0) await this.sleep(waitMs);
const result = await request(); return await request();
this.nextRequestAt = Math.max(this.nextRequestAt, this.now() + this.minRequestIntervalMs);
return result;
} finally { } finally {
// A rejected request still reached Reddit. In particular, 403 responses
// are often a temporary anti-abuse block, so immediately trying the next
// subreddit turns one denial into a burst. Keep the same spacing after
// every attempted request; an explicit 429 cooldown remains authoritative.
this.nextRequestAt = Math.max(this.nextRequestAt, this.now() + this.minRequestIntervalMs);
release(); release();
} }
} }
+18 -9
View File
@@ -1,5 +1,6 @@
import winston from 'winston'; import winston from 'winston';
import Transport from 'winston-transport'; import Transport from 'winston-transport';
import { toJsonSafe } from './json-safe.js';
export type Logger = winston.Logger; export type Logger = winston.Logger;
@@ -37,27 +38,35 @@ export function attachLogSink(logger: Logger, sink: LogSink): void {
} }
function safeStringify(value: unknown): string { function safeStringify(value: unknown): string {
const seen = new WeakSet<object>();
try { try {
return JSON.stringify(value, (_key, val) => { return JSON.stringify(toJsonSafe(value));
if (typeof val === 'object' && val !== null) {
if (seen.has(val)) return '[Circular]';
seen.add(val);
}
if (typeof val === 'bigint') return val.toString();
return val;
});
} catch (err) { } catch (err) {
return `[Unserializable: ${(err as Error).message}]`; return `[Unserializable: ${(err as Error).message}]`;
} }
} }
/** Strip credentials and network internals before either console or file output. */
const redactLogMetadata = winston.format((info) => {
const record = info as Record<string, unknown>;
const { level, message, timestamp, ...metadata } = record;
const safeMetadata = toJsonSafe(metadata);
for (const key of Object.keys(metadata)) delete record[key];
if (safeMetadata && !Array.isArray(safeMetadata) && typeof safeMetadata === 'object') {
Object.assign(record, safeMetadata);
}
record.level = level;
record.message = message;
if (timestamp !== undefined) record.timestamp = timestamp;
return info;
});
export function createLogger(): Logger { export function createLogger(): Logger {
return winston.createLogger({ return winston.createLogger({
level: process.env.LOG_LEVEL || 'info', level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine( format: winston.format.combine(
winston.format.timestamp(), winston.format.timestamp(),
winston.format.errors({ stack: true }), winston.format.errors({ stack: true }),
redactLogMetadata(),
winston.format.json() winston.format.json()
), ),
transports: [ transports: [
+18 -1
View File
@@ -490,7 +490,7 @@ describe('RedditApiClient', () => {
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('returns empty array for 403 private subreddit', async () => { it('returns empty array and explains an initial 403 access denial', async () => {
const error = { isAxiosError: true, response: { status: 403 } }; const error = { isAxiosError: true, response: { status: 403 } };
const mockAxios = { get: vi.fn().mockRejectedValue(error) }; const mockAxios = { get: vi.fn().mockRejectedValue(error) };
vi.mocked(axios.create).mockReturnValue(mockAxios as any); vi.mocked(axios.create).mockReturnValue(mockAxios as any);
@@ -498,6 +498,23 @@ describe('RedditApiClient', () => {
const client2 = new RedditApiClient(getTestConfig(), logger); const client2 = new RedditApiClient(getTestConfig(), logger);
const result = await client2.getNewPosts('private'); const result = await client2.getNewPosts('private');
expect(result).toEqual([]); expect(result).toEqual([]);
expect(logger.logs).toContainEqual(expect.objectContaining({
level: 'warn', message: expect.stringContaining('HTTP 403'),
}));
});
it('treats consecutive 403 listing responses as a temporary Reddit block', async () => {
const error = { isAxiosError: true, response: { status: 403 } };
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
const scheduler: RedditRequestScheduler = { schedule: async (request) => await request(), extendCooldown: vi.fn() };
const client2 = new RedditApiClient(getTestConfig(), logger, scheduler);
await expect(client2.getNewPosts('first')).resolves.toEqual([]);
await expect(client2.getNewPosts('second')).rejects.toMatchObject({
name: 'RedditRateLimitError', retryAfterMs: 120_000,
});
expect(scheduler.extendCooldown).toHaveBeenCalledWith(120_000);
}); });
it('throws a compact error with the Reddit rate-limit reset delay on 429', async () => { it('throws a compact error with the Reddit rate-limit reset delay on 429', async () => {
+14
View File
@@ -55,4 +55,18 @@ describe('RedditRequestCoordinator', () => {
await second; await second;
expect(sleeps).toEqual([16_000]); expect(sleeps).toEqual([16_000]);
}); });
it('paces a queued request after a failed attempt without an explicit cooldown', async () => {
let now = 0;
const sleeps: number[] = [];
const coordinator = new RedditRequestCoordinator(6_500, {
now: () => now,
sleep: async (ms) => { sleeps.push(ms); now += ms; },
});
await expect(coordinator.schedule(async () => { throw new Error('403'); })).rejects.toThrow('403');
await coordinator.schedule(async () => undefined);
expect(sleeps).toEqual([6_500]);
});
}); });