fix: back off after Reddit access denials
This commit is contained in:
1 parent
e8b730b592
commit
53e9a44a5c
5 files changed
+81
-14
No files matched your search
+25
-1
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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: [
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user