Disable unavailable subreddits after Reddit 404

This commit is contained in:
jjbrunton committed 2026-08-08 22:53:24 +01:00
1 parent 664aa5c098
commit dcf57273e2
13 files changed
+153 -12

No files matched your search

+5 -1
View File
@@ -49,6 +49,7 @@ import { RedditAccountRuntimeManager } from './services/reddit-account-runtime.j
import { createConcreteRedditAccountRuntimeFactory } from './services/reddit-account-runtime-builder.js';
import { importLegacyAccountContent } from './database/legacy-content-import.js';
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
export interface AppContainer {
config: Config;
@@ -168,7 +169,10 @@ export async function bootstrap(
// AI + services
const aiClient = new OpenRouterClient(config, logger);
const monitorService = new MonitorService(redditApi, postsRepo, recipientDeduplicator, config, logger);
const monitorService = new MonitorService(
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
);
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
const strategySelector = new StrategySelector(config, messagesRepo, logger);
processorService.setStrategySelector(strategySelector);
+1
View File
@@ -9,6 +9,7 @@ dotenv.config();
export const SubredditConfigSchema = z.object({
name: z.string(),
enabled: z.boolean().default(true),
type: z.enum(['image', 'chat']).default('image'),
weight: z.number().positive().optional(),
tone: z.enum(TONE_KEYS).default('default'),
+9 -2
View File
@@ -24,6 +24,14 @@ export class RedditAccessDeniedError extends Error {
}
}
/** Reddit confirms the community listing is gone and must no longer be polled. */
export class RedditSubredditUnavailableError extends Error {
constructor(public readonly subreddit: string) {
super(`Reddit reports that r/${subreddit} is unavailable (HTTP 404)`);
this.name = 'RedditSubredditUnavailableError';
}
}
function getRateLimitDelayMs(headers: unknown): number {
if (!headers || typeof headers !== 'object') return 60_000;
@@ -198,8 +206,7 @@ export class RedditApiClient {
} catch (error) {
if (axios.isAxiosError(error)) {
if (error.response?.status === 404) {
this.logger.warn(`Subreddit r/${subreddit} not found`);
return [];
throw new RedditSubredditUnavailableError(subreddit);
}
if (error.response?.status === 403) {
this.consecutiveListing403s += 1;
+25 -3
View File
@@ -1,4 +1,9 @@
import { RedditRateLimitError, type RedditApiClient, type RedditPost } from '../reddit/api.js';
import {
RedditRateLimitError,
RedditSubredditUnavailableError,
type RedditApiClient,
type RedditPost,
} from '../reddit/api.js';
import type { PostsRepository } from '../database/repositories.js';
import type { Config } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
@@ -17,6 +22,12 @@ interface ListingScreeningSummary {
deferredReason: 'selected-recent-candidate' | 'older-than-fallback' | null;
}
/** Coordinates permanent polling disablement across account runtimes. */
export interface SubredditPollingControl {
disableUnavailableSubreddit(subreddit: string): void;
isSubredditEnabled(subreddit: string): boolean;
}
export class MonitorService {
// Flips on each call so that when both pools have candidates the bot
// alternates between image and chat instead of always favouring whichever
@@ -30,6 +41,7 @@ export class MonitorService {
private config: Config,
private logger: Logger,
private delayBetweenFetchesMs: number = 1000,
private readonly pollingControl?: SubredditPollingControl,
) { }
async getUnprocessedPosts(): Promise<RedditPost[]> {
@@ -151,6 +163,11 @@ export class MonitorService {
}
} catch (error) {
if (error instanceof RedditRateLimitError) throw error;
if (error instanceof RedditSubredditUnavailableError) {
this.pollingControl?.disableUnavailableSubreddit(subreddit.name);
this.logger.warn(`Disabled r/${subreddit.name}: Reddit returned HTTP 404`);
continue;
}
const errorMessage = error instanceof Error ? error.message : String(error);
this.logger.error(`Failed to fetch posts from r/${subreddit.name}: ${errorMessage}`);
@@ -182,10 +199,10 @@ export class MonitorService {
*/
private buildCheckOrder(): Config['reddit']['subreddits'] {
const images = this.weightedShuffle(
this.config.reddit.subreddits.filter((s) => s.type === 'image'),
this.config.reddit.subreddits.filter((s) => s.type === 'image' && this.isSubredditEnabled(s.name)),
);
const chats = this.weightedShuffle(
this.config.reddit.subreddits.filter((s) => s.type === 'chat'),
this.config.reddit.subreddits.filter((s) => s.type === 'chat' && this.isSubredditEnabled(s.name)),
);
const [a, b] = this.chatGoesFirst ? [chats, images] : [images, chats];
@@ -200,6 +217,11 @@ export class MonitorService {
return out;
}
private isSubredditEnabled(subreddit: string): boolean {
const local = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase());
return local?.enabled !== false && (this.pollingControl?.isSubredditEnabled(subreddit) ?? true);
}
// Weighted random ordering (Efraimidis–Spirakis): key = random^(1/weight),
// sorted descending. Heavier-weighted subs are more likely to land earlier
// each cycle, while every sub still has a non-zero chance of any position.
@@ -39,6 +39,8 @@ import {
type VerifiedAccountSession,
} from './reddit-account-runtime.js';
import { RecipientDeduplicator } from './recipient-deduplicator.js';
import { ConfigStore } from '../config/persistence.js';
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
export interface ConcreteRuntimeFactoryOptions {
config: Config;
@@ -116,6 +118,7 @@ async function buildRuntime(
const aiClient = new OpenRouterClient(accountConfig, logger);
const monitorService = new MonitorService(
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
);
const processorService = new ProcessorService(redditApi, aiClient, accountConfig, logger);
processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
+25
View File
@@ -0,0 +1,25 @@
import type { Config } from '../config/index.js';
import type { ConfigPersistence } from '../config/index.js';
import type { Logger } from '../utils/logger.js';
import type { SubredditPollingControl } from './monitor.js';
/** Persists a terminal Reddit listing failure so every account stops polling it. */
export function createConfigBackedSubredditPollingControl(
config: Config,
configStore: ConfigPersistence,
logger: Logger,
): SubredditPollingControl {
return {
disableUnavailableSubreddit(subreddit: string): void {
const entry = config.reddit.subreddits.find((candidate) => candidate.name.toLowerCase() === subreddit.toLowerCase());
if (!entry || entry.enabled === false) return;
entry.enabled = false;
configStore.save(config);
logger.warn(`Persisted disabled status for unavailable subreddit r/${entry.name}`);
},
isSubredditEnabled(subreddit: string): boolean {
return config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())?.enabled !== false;
},
};
}