feat: add agent API and operational analytics
This commit is contained in:
1 parent
720ea0652a
commit
e8b730b592
65 files changed
+2650
-1214
No files matched your search
+48
-25
@@ -2,6 +2,12 @@ import axios, { AxiosInstance } from 'axios';
|
||||
import type { BrowserContext } from 'playwright';
|
||||
import type { Config } from '../config/index.js';
|
||||
import type { Logger } from '../utils/logger.js';
|
||||
import type { RedditRequestScheduler } from './request-coordinator.js';
|
||||
|
||||
const uncoordinatedRequestScheduler: RedditRequestScheduler = {
|
||||
schedule: async <T>(request: () => Promise<T>) => await request(),
|
||||
extendCooldown: () => undefined,
|
||||
};
|
||||
|
||||
export class RedditRateLimitError extends Error {
|
||||
constructor(public readonly retryAfterMs: number) {
|
||||
@@ -105,7 +111,10 @@ export class RedditApiClient {
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
private logger: Logger
|
||||
private logger: Logger,
|
||||
// Production bootstraps supply one process-wide paced coordinator. Keep
|
||||
// standalone clients independent for embedders and focused unit tests.
|
||||
private readonly requestScheduler: RedditRequestScheduler = uncoordinatedRequestScheduler,
|
||||
) {
|
||||
this.client = axios.create({
|
||||
baseURL: 'https://www.reddit.com',
|
||||
@@ -143,15 +152,17 @@ export class RedditApiClient {
|
||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||
|
||||
try {
|
||||
const response = await this.client.get(
|
||||
const response = await this.scheduleRequest(() => this.client.get(
|
||||
`/user/${encodeURIComponent(username)}/about.json`,
|
||||
{ params: { raw_json: 1 }, headers },
|
||||
);
|
||||
));
|
||||
const id = response.data?.data?.id;
|
||||
if (typeof id !== 'string' || id.length === 0) return null;
|
||||
return id.startsWith('t2_') ? id : `t2_${id}`;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) return null;
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -164,10 +175,10 @@ export class RedditApiClient {
|
||||
headers['Cookie'] = cookieHeader;
|
||||
}
|
||||
|
||||
const response = await this.client.get(`/r/${subreddit}/new.json`, {
|
||||
const response = await this.scheduleRequest(() => this.client.get(`/r/${subreddit}/new.json`, {
|
||||
params: { limit, raw_json: 1 },
|
||||
headers,
|
||||
});
|
||||
}));
|
||||
|
||||
const posts: RedditPost[] = response.data.data.children
|
||||
.map((child: { data: RedditPost }) => child.data)
|
||||
@@ -185,12 +196,8 @@ export class RedditApiClient {
|
||||
this.logger.warn(`Subreddit r/${subreddit} is private or quarantined`);
|
||||
return [];
|
||||
}
|
||||
if (error.response?.status === 429) {
|
||||
const retryAfterMs = getRateLimitDelayMs(error.response.headers);
|
||||
const rateLimitError = new RedditRateLimitError(retryAfterMs);
|
||||
this.logger.warn(rateLimitError.message);
|
||||
throw rateLimitError;
|
||||
}
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
}
|
||||
this.logger.error(`Error fetching posts from r/${subreddit}:`, error);
|
||||
throw error;
|
||||
@@ -310,10 +317,10 @@ export class RedditApiClient {
|
||||
headers['Cookie'] = cookieHeader;
|
||||
}
|
||||
|
||||
const response = await this.client.get(`/user/${encodeURIComponent(username)}/about.json`, {
|
||||
const response = await this.scheduleRequest(() => this.client.get(`/user/${encodeURIComponent(username)}/about.json`, {
|
||||
params: { raw_json: 1 },
|
||||
headers,
|
||||
});
|
||||
}));
|
||||
|
||||
const userData = response.data?.data;
|
||||
if (userData) {
|
||||
@@ -331,11 +338,8 @@ export class RedditApiClient {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 429) {
|
||||
const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers));
|
||||
this.logger.warn(rateLimitError.message);
|
||||
throw rateLimitError;
|
||||
}
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
this.logger.warn(`User u/${username} profile not found`);
|
||||
return false;
|
||||
@@ -350,10 +354,10 @@ export class RedditApiClient {
|
||||
const cookieHeader = await this.getCookieHeader();
|
||||
const headers: Record<string, string> = {};
|
||||
if (cookieHeader) headers.Cookie = cookieHeader;
|
||||
const response = await this.client.get(`/user/${encodeURIComponent(username)}/overview.json`, {
|
||||
const response = await this.scheduleRequest(() => this.client.get(`/user/${encodeURIComponent(username)}/overview.json`, {
|
||||
params: { limit: 25, raw_json: 1 },
|
||||
headers,
|
||||
});
|
||||
}));
|
||||
const children: RedditListingChild[] = Array.isArray(response.data?.data?.children)
|
||||
? response.data.data.children
|
||||
: [];
|
||||
@@ -372,11 +376,8 @@ export class RedditApiClient {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error) && error.response?.status === 429) {
|
||||
const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers));
|
||||
this.logger.warn(rateLimitError.message);
|
||||
throw rateLimitError;
|
||||
}
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
this.logger.warn(`Failed to check recent activity for u/${username}:`, error);
|
||||
}
|
||||
|
||||
@@ -386,4 +387,26 @@ export class RedditApiClient {
|
||||
getPostUrl(post: RedditPost): string {
|
||||
return `https://www.reddit.com${post.permalink}`;
|
||||
}
|
||||
|
||||
private toRateLimitError(error: unknown): RedditRateLimitError | null {
|
||||
if (error instanceof RedditRateLimitError) return error;
|
||||
if (!axios.isAxiosError(error) || error.response?.status !== 429) return null;
|
||||
const rateLimitError = new RedditRateLimitError(getRateLimitDelayMs(error.response.headers));
|
||||
this.requestScheduler.extendCooldown(rateLimitError.retryAfterMs);
|
||||
this.logger.warn(rateLimitError.message);
|
||||
return rateLimitError;
|
||||
}
|
||||
|
||||
/** Record a 429 before releasing the shared request queue to its next waiter. */
|
||||
private async scheduleRequest<T>(request: () => Promise<T>): Promise<T> {
|
||||
return await this.requestScheduler.schedule(async () => {
|
||||
try {
|
||||
return await request();
|
||||
} catch (error) {
|
||||
const rateLimitError = this.toRateLimitError(error);
|
||||
if (rateLimitError) throw rateLimitError;
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user