Respect Reddit rate limit delays

This commit is contained in:
jjbrunton committed 2026-08-08 18:34:37 +01:00
1 parent 7bcdbe236a
commit 50de1c180d
5 files changed
+77 -10

No files matched your search

+8 -1
View File
@@ -3,6 +3,7 @@ import { withRetry } from './utils/retry.js';
import { isSkip } from './services/processor.js'; import { isSkip } from './services/processor.js';
import { reportDeliveryResult, reportInvitationCooldown } from './web/event-reporting.js'; import { reportDeliveryResult, reportInvitationCooldown } from './web/event-reporting.js';
import { SessionBoundRuntime } from './services/session-bound-runtime.js'; import { SessionBoundRuntime } from './services/session-bound-runtime.js';
import { RedditRateLimitError } from './reddit/api.js';
export interface StoppableSchedule { export interface StoppableSchedule {
stop(): void; stop(): void;
@@ -395,7 +396,13 @@ export async function runMainLoop(app: AppContainer): Promise<void> {
} }
} catch (error) { } catch (error) {
if (isShuttingDown && error instanceof DOMException && error.name === 'AbortError') break; if (isShuttingDown && error instanceof DOMException && error.name === 'AbortError') break;
logger.error('Error in main loop:', error); if (error instanceof RedditRateLimitError) {
await sleep(Math.max(ERROR_BACKOFF_MS, error.retryAfterMs + 1_000), shutdownController.signal);
continue;
}
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Error in main loop: ${errorMessage}`);
await sleep(ERROR_BACKOFF_MS, shutdownController.signal); await sleep(ERROR_BACKOFF_MS, shutdownController.signal);
} }
} }
+24 -2
View File
@@ -3,6 +3,26 @@ import type { BrowserContext } from 'playwright';
import type { Config } from '../config/index.js'; import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
export class RedditRateLimitError extends Error {
constructor(public readonly retryAfterMs: number) {
const retryAfterSeconds = Math.ceil(retryAfterMs / 1000);
super(`Reddit API rate limit reached; retry after ${retryAfterSeconds}s`);
this.name = 'RedditRateLimitError';
}
}
function getRateLimitDelayMs(headers: unknown): number {
if (!headers || typeof headers !== 'object') return 60_000;
const values = headers as Record<string, unknown>;
const rawDelay = values['retry-after'] ?? values['x-ratelimit-reset'];
const seconds = typeof rawDelay === 'number' ? rawDelay : Number.parseFloat(String(rawDelay));
return Number.isFinite(seconds) && seconds >= 0
? Math.ceil(seconds * 1000)
: 60_000;
}
export interface RedditPost { export interface RedditPost {
id: string; id: string;
name: string; // fullname like "t3_xxxxx" name: string; // fullname like "t3_xxxxx"
@@ -134,8 +154,10 @@ export class RedditApiClient {
return []; return [];
} }
if (error.response?.status === 429) { if (error.response?.status === 429) {
this.logger.warn(`Rate limited by Reddit, waiting...`); const retryAfterMs = getRateLimitDelayMs(error.response.headers);
throw error; const rateLimitError = new RedditRateLimitError(retryAfterMs);
this.logger.warn(rateLimitError.message);
throw rateLimitError;
} }
} }
this.logger.error(`Error fetching posts from r/${subreddit}:`, error); this.logger.error(`Error fetching posts from r/${subreddit}:`, error);
+5 -2
View File
@@ -1,4 +1,4 @@
import type { RedditApiClient, RedditPost } from '../reddit/api.js'; import { RedditRateLimitError, type RedditApiClient, type RedditPost } from '../reddit/api.js';
import type { PostsRepository, MessagesRepository } from '../database/repositories.js'; import type { PostsRepository, MessagesRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js'; import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js'; import type { Logger } from '../utils/logger.js';
@@ -92,7 +92,10 @@ export class MonitorService {
} }
} }
} catch (error) { } catch (error) {
this.logger.error(`Failed to fetch posts from r/${subreddit.name}:`, error); if (error instanceof RedditRateLimitError) throw error;
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to fetch posts from r/${subreddit.name}: ${errorMessage}`);
} }
// Small delay between subreddit fetches to be nice to Reddit // Small delay between subreddit fetches to be nice to Reddit
+27 -1
View File
@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MonitorService } from '../src/services/monitor.js'; import { MonitorService } from '../src/services/monitor.js';
import type { RedditApiClient, RedditPost } from '../src/reddit/api.js'; import { RedditRateLimitError, type RedditApiClient, type RedditPost } from '../src/reddit/api.js';
import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js'; import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js';
import { createMockLogger } from '../tests/mockLogger.js'; import { createMockLogger } from '../tests/mockLogger.js';
import { getTestConfig } from '../tests/testUtils.js'; import { getTestConfig } from '../tests/testUtils.js';
@@ -246,6 +246,32 @@ describe('MonitorService', () => {
expect(logger.logs.some(l => l.level === 'error' && l.message.includes('Failed to fetch'))).toBe(true); expect(logger.logs.some(l => l.level === 'error' && l.message.includes('Failed to fetch'))).toBe(true);
}); });
it('stops scanning and propagates Reddit rate limits without logging the error object', async () => {
const redditApi = createMockRedditApi([]);
vi.mocked(redditApi.getNewPosts).mockRejectedValue(new RedditRateLimitError(57_000));
const config = getTestConfig({
reddit: {
subreddits: [
{ name: 'sub1', type: 'image' },
{ name: 'sub2', type: 'image' },
],
},
});
monitor = new MonitorService(
redditApi,
createMockPostsRepo(),
createMockMessagesRepo(),
config,
logger,
0,
);
await expect(monitor.getUnprocessedPosts()).rejects.toMatchObject({ retryAfterMs: 57_000 });
expect(redditApi.getNewPosts).toHaveBeenCalledTimes(1);
expect(logger.logs.some((entry) => entry.level === 'error')).toBe(false);
});
it('returns only one post per call', async () => { it('returns only one post per call', async () => {
const posts: RedditPost[] = Array.from({ length: 10 }, (_, i) => ({ const posts: RedditPost[] = Array.from({ length: 10 }, (_, i) => ({
id: `t3_${i}`, subreddit: 'sub1', author: `user${i}`, title: `Post ${i}`, id: `t3_${i}`, subreddit: 'sub1', author: `user${i}`, title: `Post ${i}`,
+13 -4
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import axios from 'axios'; import axios from 'axios';
import type { AxiosInstance } from 'axios'; import type { AxiosInstance } from 'axios';
import type { BrowserContext } from 'playwright'; import type { BrowserContext } from 'playwright';
import { RedditApiClient } from '../src/reddit/api.js'; import { RedditApiClient, RedditRateLimitError } from '../src/reddit/api.js';
import type { RedditPost } from '../src/reddit/api.js'; import type { RedditPost } from '../src/reddit/api.js';
import { createMockLogger } from '../tests/mockLogger.js'; import { createMockLogger } from '../tests/mockLogger.js';
import { getTestConfig } from '../tests/testUtils.js'; import { getTestConfig } from '../tests/testUtils.js';
@@ -455,13 +455,22 @@ describe('RedditApiClient', () => {
expect(result).toEqual([]); expect(result).toEqual([]);
}); });
it('throws on 429 rate limit', async () => { it('throws a compact error with the Reddit rate-limit reset delay on 429', async () => {
const error = { isAxiosError: true, response: { status: 429 } }; const error = {
isAxiosError: true,
response: { status: 429, headers: { 'x-ratelimit-reset': '57' } },
};
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);
const client2 = new RedditApiClient(getTestConfig(), logger); const client2 = new RedditApiClient(getTestConfig(), logger);
await expect(client2.getNewPosts('test')).rejects.toThrow(); const result = client2.getNewPosts('test');
await expect(result).rejects.toBeInstanceOf(RedditRateLimitError);
await expect(result).rejects.toMatchObject({ retryAfterMs: 57_000 });
expect(logger.logs.some((entry) =>
entry.level === 'warn' && entry.message.includes('retry after 57s'),
)).toBe(true);
}); });
it('parses valid image posts from response', async () => { it('parses valid image posts from response', async () => {