Files
seerr/server/api/externalapi.ts
fallenbagel 84accb544a fix(proxy): proxy clients built before proxy init
Proxying for axios relied on the per-instance httpAgent/httpsAgent, which axios.create() snapshots
from axios.defaults at construction time. API clients constructed at import (such as
BaseScanner.tmdb, avalabilitySync.tmdb) are built before createCustomProxyAgent runs, so they
snapshotted no proxy agent and bypassed the proxy permanently, while clients created per-request
afterwards were proxied. This fix resolves the agent per request from a live module-level holder via
a stable interceptor that is registered on the default instance and every ExternalAPI client, so
construction order no longer matters. forceIpv4First has been moved to the same path logic as well.

fix #3193
2026-06-23 10:13:06 +08:00

159 lines
3.9 KiB
TypeScript

import { proxyRequestInterceptor } from '@server/utils/customProxyAgent';
import type { AxiosInstance, AxiosRequestConfig } from 'axios';
import axios from 'axios';
import rateLimit from 'axios-rate-limit';
import type NodeCache from 'node-cache';
// 5 minute default TTL (in seconds)
const DEFAULT_TTL = 300;
// 10 seconds default rolling buffer (in ms)
const DEFAULT_ROLLING_BUFFER = 10000;
export interface ExternalAPIOptions {
nodeCache?: NodeCache;
headers?: Record<string, unknown>;
timeout?: number;
rateLimit?: {
maxRPS: number;
maxRequests: number;
};
}
class ExternalAPI {
protected axios: AxiosInstance;
private baseUrl: string;
private cache?: NodeCache;
constructor(
baseUrl: string,
params: Record<string, unknown>,
options: ExternalAPIOptions = {}
) {
this.axios = axios.create({
baseURL: baseUrl,
params,
timeout: options.timeout,
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...options.headers,
},
});
this.axios.interceptors.request.use(proxyRequestInterceptor);
if (options.rateLimit) {
this.axios = rateLimit(this.axios, {
maxRequests: options.rateLimit.maxRequests,
maxRPS: options.rateLimit.maxRPS,
});
}
this.baseUrl = baseUrl;
this.cache = options.nodeCache;
}
protected async get<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...config?.params,
headers: config?.headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async post<T>(
endpoint: string,
data?: Record<string, unknown>,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
config: config?.params,
...(data ? { data } : {}),
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
return cachedItem;
}
const response = await this.axios.post<T>(endpoint, data, config);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected async getRolling<T>(
endpoint: string,
config?: AxiosRequestConfig,
ttl?: number
): Promise<T> {
const cacheKey = this.serializeCacheKey(endpoint, {
...config?.params,
headers: config?.headers,
});
const cachedItem = this.cache?.get<T>(cacheKey);
if (cachedItem) {
const keyTtl = this.cache?.getTtl(cacheKey) ?? 0;
// If the item has passed our rolling check, fetch again in background
if (
keyTtl - (ttl ?? DEFAULT_TTL) * 1000 <
Date.now() - DEFAULT_ROLLING_BUFFER
) {
this.axios.get<T>(endpoint, config).then((response) => {
this.cache?.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
});
}
return cachedItem;
}
const response = await this.axios.get<T>(endpoint, config);
if (this.cache && ttl !== 0) {
this.cache.set(cacheKey, response.data, ttl ?? DEFAULT_TTL);
}
return response.data;
}
protected removeCache(endpoint: string, options?: Record<string, unknown>) {
const cacheKey = this.serializeCacheKey(endpoint, {
...options,
});
this.cache?.del(cacheKey);
}
private serializeCacheKey(
endpoint: string,
options?: Record<string, unknown>
) {
if (!options) {
return `${this.baseUrl}${endpoint}`;
}
return `${this.baseUrl}${endpoint}${JSON.stringify(options)}`;
}
}
export default ExternalAPI;