From 50de1c180d2e5fae13bb87609fc21882ff3128a2 Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Sat, 8 Aug 2026 18:34:37 +0100 Subject: [PATCH] Respect Reddit rate limit delays --- src/main-loop.ts | 9 ++++++++- src/reddit/api.ts | 26 ++++++++++++++++++++++++-- src/services/monitor.ts | 7 +++++-- tests/monitor.test.ts | 28 +++++++++++++++++++++++++++- tests/reddit-api.test.ts | 17 +++++++++++++---- 5 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/main-loop.ts b/src/main-loop.ts index 793f4bc..907da7c 100644 --- a/src/main-loop.ts +++ b/src/main-loop.ts @@ -3,6 +3,7 @@ import { withRetry } from './utils/retry.js'; import { isSkip } from './services/processor.js'; import { reportDeliveryResult, reportInvitationCooldown } from './web/event-reporting.js'; import { SessionBoundRuntime } from './services/session-bound-runtime.js'; +import { RedditRateLimitError } from './reddit/api.js'; export interface StoppableSchedule { stop(): void; @@ -395,7 +396,13 @@ export async function runMainLoop(app: AppContainer): Promise { } } catch (error) { 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); } } diff --git a/src/reddit/api.ts b/src/reddit/api.ts index 2b2287a..5bbd80c 100644 --- a/src/reddit/api.ts +++ b/src/reddit/api.ts @@ -3,6 +3,26 @@ import type { BrowserContext } from 'playwright'; import type { Config } from '../config/index.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; + 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 { id: string; name: string; // fullname like "t3_xxxxx" @@ -134,8 +154,10 @@ export class RedditApiClient { return []; } if (error.response?.status === 429) { - this.logger.warn(`Rate limited by Reddit, waiting...`); - throw error; + const retryAfterMs = getRateLimitDelayMs(error.response.headers); + const rateLimitError = new RedditRateLimitError(retryAfterMs); + this.logger.warn(rateLimitError.message); + throw rateLimitError; } } this.logger.error(`Error fetching posts from r/${subreddit}:`, error); diff --git a/src/services/monitor.ts b/src/services/monitor.ts index 6da9f85..a034056 100644 --- a/src/services/monitor.ts +++ b/src/services/monitor.ts @@ -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 { Config } from '../config/index.js'; import type { Logger } from '../utils/logger.js'; @@ -92,7 +92,10 @@ export class MonitorService { } } } 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 diff --git a/tests/monitor.test.ts b/tests/monitor.test.ts index 46b0f17..6aefcf4 100644 --- a/tests/monitor.test.ts +++ b/tests/monitor.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; 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 { createMockLogger } from '../tests/mockLogger.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); }); + 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 () => { const posts: RedditPost[] = Array.from({ length: 10 }, (_, i) => ({ id: `t3_${i}`, subreddit: 'sub1', author: `user${i}`, title: `Post ${i}`, diff --git a/tests/reddit-api.test.ts b/tests/reddit-api.test.ts index ea700ea..911f2ca 100644 --- a/tests/reddit-api.test.ts +++ b/tests/reddit-api.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import axios from 'axios'; import type { AxiosInstance } from 'axios'; 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 { createMockLogger } from '../tests/mockLogger.js'; import { getTestConfig } from '../tests/testUtils.js'; @@ -455,13 +455,22 @@ describe('RedditApiClient', () => { expect(result).toEqual([]); }); - it('throws on 429 rate limit', async () => { - const error = { isAxiosError: true, response: { status: 429 } }; + it('throws a compact error with the Reddit rate-limit reset delay on 429', async () => { + const error = { + isAxiosError: true, + response: { status: 429, headers: { 'x-ratelimit-reset': '57' } }, + }; const mockAxios = { get: vi.fn().mockRejectedValue(error) }; vi.mocked(axios.create).mockReturnValue(mockAxios as any); 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 () => {