feat: 调整限流器和并发
torbox-admin / deploy (push) Successful in 1m24s

This commit is contained in:
2026-10-06 23:04:27 -07:00
parent bd1ac6bab8
commit c8d07e04bb
7 changed files with 208 additions and 69 deletions
@@ -2,6 +2,7 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { IMAGE_BROWSER_MAX_AGE_SEC } from "@/constants/image-cache"; import { IMAGE_BROWSER_MAX_AGE_SEC } from "@/constants/image-cache";
import { guard } from "@/server/auth"; import { guard } from "@/server/auth";
import { acquireCdnSlot, releaseWhenDone } from "@/server/cdn-limiter";
import { isMockMode } from "@/server/env"; import { isMockMode } from "@/server/env";
import { import {
cacheWhileStreaming, cacheWhileStreaming,
@@ -128,20 +129,24 @@ const proxy = async (
} }
} }
/** 占用的下载节点并发名额,响应体传完(或出错、被取消)后释放 */
let release = () => {};
try { try {
let upstream: Response; let upstream: Response;
/** 下载节点:实测同一节点并发超过 20 会 429,出错时记录便于核对 */ /** 下载节点:实测同一节点并发超过 20 会 429,本地按节点限制并发,出错时记录便于核对 */
let cdnHost = ""; let cdnHost = "";
/** 本次用的直链:来源与已用时长,出错时一并记录 */ /** 本次用的直链:来源与已用时长,出错时一并记录 */
let link: { attempt?: number; from?: DownloadUrlFrom; ageSec?: number } = let link: { attempt?: number; from?: DownloadUrlFrom; ageSec?: number } =
{}; {};
for (let attempt = 1; ; attempt += 1) { for (let attempt = 1; ; attempt += 1) {
// 被拒绝后重新签发的直链可能换了节点,先放掉上一次的名额
release();
// 首次用缓存的直链,被拒绝后重新签发 // 首次用缓存的直链,被拒绝后重新签发
const { url, signedAt, from } = await requestDownloadUrl( const { url, signedAt, from } = await requestDownloadUrl(
type, type,
id, id,
fileId, fileId,
{ fresh: attempt > 1 } { fresh: attempt > 1, context: media }
); );
cdnHost = new URL(url).host; cdnHost = new URL(url).host;
link = { link = {
@@ -154,6 +159,7 @@ const proxy = async (
{ ...media, method, cdnHost, ...link }, { ...media, method, cdnHost, ...link },
URL_FROM_LABEL[from] URL_FROM_LABEL[from]
); );
release = await acquireCdnSlot(cdnHost, req.signal);
upstream = await fetch(url, { upstream = await fetch(url, {
method, method,
headers: range ? { Range: range } : undefined, headers: range ? { Range: range } : undefined,
@@ -226,8 +232,12 @@ const proxy = async (
let body = method === "HEAD" ? null : upstream.body; let body = method === "HEAD" ? null : upstream.body;
if (cacheable && body) if (cacheable && body)
body = cacheWhileStreaming(cacheKey, upstream) ?? body; body = cacheWhileStreaming(cacheKey, upstream) ?? body;
return new Response(body, { status: upstream.status, headers }); return new Response(releaseWhenDone(body, release), {
status: upstream.status,
headers
});
} catch (error) { } catch (error) {
release();
// 播放器关闭或跳转时会主动断开,不算故障 // 播放器关闭或跳转时会主动断开,不算故障
if (req.signal.aborted) log.debug(media, "客户端中断请求"); if (req.signal.aborted) log.debug(media, "客户端中断请求");
else log.error({ ...media, method, err: error }, "播放失败"); else log.error({ ...media, method, err: error }, "播放失败");
+11 -5
View File
@@ -8,7 +8,7 @@ const MINUTE = 60 * 1000;
const HOUR = 60 * MINUTE; const HOUR = 60 * MINUTE;
export type TorboxLimitRule = { export type TorboxLimitRule = {
/** 提示语里的操作名,如「获取播放地址过于频繁,请 3 分钟后再试」;以中文结尾,后面直接接「过于频繁」 */ /** 提示语里的操作名,如「获取文件地址过于频繁,请 3 分钟后再试」;以中文结尾,后面直接接「过于频繁」 */
label: string; label: string;
/** 任意 windowMs 时长内最多 limit 次(滑动窗口) */ /** 任意 windowMs 时长内最多 limit 次(滑动窗口) */
limit: number; limit: number;
@@ -49,13 +49,13 @@ export const TORBOX_LIMITS = {
}, },
/** /**
* 文档未写。实测 1 秒 1 次、2 秒 1 次都在约第 100 次 429,Retry-After 300 秒, * 文档未写。实测 1 秒 1 次、2 秒 1 次都在约第 100 次 429,Retry-After 300 秒,
* 约 20 次/分钟才不容易触发,即 5 分钟约 100 次的额度。取 80,平均 16 次/分钟, * 约 20 次/分钟才不容易触发,即 5 分钟约 100 次的额度。实际用量里 5 分钟内发出过约 91 次
* 同时容许图片墙一次加载几十张 * 也没有 429,取 90,平均 18 次/分钟,同时容许图片墙一次加载几十张
*/ */
requestDownload: { requestDownload: {
label: "获取播放地址", label: "获取文件地址",
match: /^\/(torrents|usenet|webdl)\/requestdl$/, match: /^\/(torrents|usenet|webdl)\/requestdl$/,
limit: 80, limit: 90,
windowMs: 5 * MINUTE windowMs: 5 * MINUTE
} }
} satisfies Record<string, TorboxLimitRule>; } satisfies Record<string, TorboxLimitRule>;
@@ -76,6 +76,12 @@ export type TorboxLimitedResource = keyof typeof TORBOX_LIMITS_BY_RESOURCE;
/** 同时进行中的 API 请求上限:实测约 40 个并发会把全部接口一起限住 */ /** 同时进行中的 API 请求上限:实测约 40 个并发会把全部接口一起限住 */
export const TORBOX_MAX_CONCURRENT = 10; export const TORBOX_MAX_CONCURRENT = 10;
/**
* 同一下载节点同时进行中的请求上限:实测并发超过 20 会 429(图片墙一次并发 17 个以上时出现),
* 留一点余量取 16,超出的排队等待
*/
export const TORBOX_CDN_MAX_CONCURRENT = 16;
/** 本地额度用尽时最多排队等这么久,超过直接提示稍后再试,不让请求长时间挂起 */ /** 本地额度用尽时最多排队等这么久,超过直接提示稍后再试,不让请求长时间挂起 */
export const TORBOX_MAX_WAIT_MS = 10 * 1000; export const TORBOX_MAX_WAIT_MS = 10 * 1000;
+1 -1
View File
@@ -25,7 +25,7 @@ const fetchLimits = async () => {
}; };
export type LimitWait = { export type LimitWait = {
/** 受限的操作名,如「获取播放地址」 */ /** 受限的操作名,如「获取文件地址」 */
label: string; label: string;
/** 恢复时间,本机毫秒时间戳 */ /** 恢复时间,本机毫秒时间戳 */
until: number; until: number;
+99
View File
@@ -0,0 +1,99 @@
// 下载节点并发限制:图片墙一次会并发拉几十张图,同一节点并发过多会返回 429。
// 与 TorBox API 的额度无关(不扣额度、不记入 SQLite),只在本进程内按节点主机名限制同时进行的请求数
import { TORBOX_CDN_MAX_CONCURRENT } from "@/constants/torbox-limits";
type HostState = {
active: number;
/** 等待名额的请求,先到先得 */
queue: (() => void)[];
};
// 挂在 globalThis 上,避免各路由打包后各持一份
const HOSTS_KEY = Symbol.for("torbox-admin.cdn-hosts");
const globals = globalThis as { [HOSTS_KEY]?: Map<string, HostState> };
const hosts = (globals[HOSTS_KEY] ??= new Map<string, HostState>());
/**
* 占用该节点的一个并发名额,返回释放函数(可重复调用)。
* 排队期间客户端离开(signal 中断)则放弃排队并抛出中断原因;
* 占用期间 signal 中断也会自动释放,调用方只需保证正常结束或出错时调用
*/
export const acquireCdnSlot = async (host: string, signal?: AbortSignal) => {
signal?.throwIfAborted();
const state = hosts.get(host) ?? { active: 0, queue: [] };
hosts.set(host, state);
/** 名额直接交给下一个等待者,active 不变 */
const free = () => {
const next = state.queue.shift();
if (next) next();
else {
state.active -= 1;
if (state.active === 0) hosts.delete(host);
}
};
if (state.active < TORBOX_CDN_MAX_CONCURRENT) {
state.active += 1;
} else {
await new Promise<void>((resolve, reject) => {
const entry = () => {
signal?.removeEventListener("abort", onAbort);
resolve();
};
const onAbort = () => {
const index = state.queue.indexOf(entry);
if (index >= 0) state.queue.splice(index, 1);
reject(signal!.reason);
};
signal?.addEventListener("abort", onAbort, { once: true });
state.queue.push(entry);
});
}
// 刚交到名额时客户端离开:监听不到已发生的中断,这里直接放弃
if (signal?.aborted) {
free();
throw signal.reason;
}
let released = false;
const release = () => {
if (released) return;
released = true;
signal?.removeEventListener("abort", release);
free();
};
signal?.addEventListener("abort", release, { once: true });
return release;
};
/** 响应体读完、出错或被取消后释放名额;没有响应体时立即释放 */
export const releaseWhenDone = (
body: ReadableStream | null,
release: () => void
) => {
if (!body) {
release();
return null;
}
const reader = body.getReader();
return new ReadableStream({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
release();
controller.close();
} else controller.enqueue(value);
} catch (error) {
release();
controller.error(error);
}
},
cancel(reason) {
release();
return reader.cancel(reason);
}
});
};
+10 -4
View File
@@ -53,6 +53,9 @@ const describeRule = (id: TorboxLimitId) => ({
ruleName: ruleName(id) ruleName: ruleName(id)
}); });
/** 请求的来源信息(如资源类型、资源 id),仅写入拒绝时的日志和事件,用来看是谁在耗额度 */
export type TorboxRequestContext = Record<string, unknown>;
/** 额度暂时用尽:路由据此返回 429 和 Retry-After */ /** 额度暂时用尽:路由据此返回 429 和 Retry-After */
export class RateLimitError extends Error { export class RateLimitError extends Error {
constructor( constructor(
@@ -149,7 +152,7 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
* 在全部额度里各记一次;等待超过 TORBOX_MAX_WAIT_MS 的直接拒绝,不占额度。 * 在全部额度里各记一次;等待超过 TORBOX_MAX_WAIT_MS 的直接拒绝,不占额度。
* 驱动是同步的:检查与记录之间不会穿插其他请求 * 驱动是同步的:检查与记录之间不会穿插其他请求
*/ */
const reserve = async (endpoint: string) => { const reserve = async (endpoint: string, context?: TorboxRequestContext) => {
const ids = getLimitIds(endpoint); const ids = getLimitIds(endpoint);
const startedAt = Date.now(); const startedAt = Date.now();
const deadline = startedAt + TORBOX_MAX_WAIT_MS; const deadline = startedAt + TORBOX_MAX_WAIT_MS;
@@ -188,6 +191,7 @@ const reserve = async (endpoint: string) => {
const fields = { const fields = {
endpoint, endpoint,
...context,
limitedBy, limitedBy,
...describeRule(blocking), ...describeRule(blocking),
usage: describeUsage(ids, now) usage: describeUsage(ids, now)
@@ -205,7 +209,8 @@ const reserve = async (endpoint: string) => {
limitedBy, limitedBy,
rule: blocking, rule: blocking,
retryAfterMs: waitMs, retryAfterMs: waitMs,
usage: fields.usage usage: fields.usage,
...(context ? { detail: context } : {})
}); });
throw new RateLimitError(blocking, waitMs, limitedBy); throw new RateLimitError(blocking, waitMs, limitedBy);
} }
@@ -246,10 +251,11 @@ const releaseSlot = () => {
*/ */
export const limitTorbox = async <T>( export const limitTorbox = async <T>(
endpoint: string, endpoint: string,
task: (queuedMs: number) => Promise<T> task: (queuedMs: number) => Promise<T>,
context?: TorboxRequestContext
) => { ) => {
const startedAt = Date.now(); const startedAt = Date.now();
await reserve(endpoint); await reserve(endpoint, context);
await acquireSlot(); await acquireSlot();
try { try {
return await task(Date.now() - startedAt); return await task(Date.now() - startedAt);
+27 -9
View File
@@ -10,7 +10,8 @@ import {
getRateLimitHeaders, getRateLimitHeaders,
limitTorbox, limitTorbox,
pauseTorbox, pauseTorbox,
RateLimitError RateLimitError,
type TorboxRequestContext
} from "@/server/torbox-limiter"; } from "@/server/torbox-limiter";
import { SOURCES, TORBOX_API } from "@/constants/torbox"; import { SOURCES, TORBOX_API } from "@/constants/torbox";
import { TORBOX_DOWNLOAD_URL_TTL_MS } from "@/constants/torbox-limits"; import { TORBOX_DOWNLOAD_URL_TTL_MS } from "@/constants/torbox-limits";
@@ -66,10 +67,16 @@ const apiKey = getTorboxApiKey;
const log = createLogger("torbox"); const log = createLogger("torbox");
/** 所有 TorBox 接口请求的唯一出口,经过统一限流 */ /** 所有 TorBox 接口请求的唯一出口,经过统一限流 */
const torboxFetch = (path: string, init: RequestInit = {}) => { const torboxFetch = (
path: string,
init: RequestInit = {},
context?: TorboxRequestContext
) => {
// 只记录接口路径:查询串里有资源 id,requestdl 的查询串还带着 API Key // 只记录接口路径:查询串里有资源 id,requestdl 的查询串还带着 API Key
const endpoint = path.split("?")[0]; const endpoint = path.split("?")[0];
return limitTorbox(endpoint, async (queuedMs) => { return limitTorbox(
endpoint,
async (queuedMs) => {
const startedAt = Date.now(); const startedAt = Date.now();
let res: Response; let res: Response;
try { try {
@@ -100,7 +107,13 @@ const torboxFetch = (path: string, init: RequestInit = {}) => {
// 实际限制尚不明确:响应带了限流相关的头就记为 info,便于对照额度 // 实际限制尚不明确:响应带了限流相关的头就记为 info,便于对照额度
const rateLimitHeaders = getRateLimitHeaders(res.headers); const rateLimitHeaders = getRateLimitHeaders(res.headers);
log[rateLimitHeaders ? "info" : "debug"]( log[rateLimitHeaders ? "info" : "debug"](
{ endpoint, status: res.status, durationMs, queuedMs, rateLimitHeaders }, {
endpoint,
status: res.status,
durationMs,
queuedMs,
rateLimitHeaders
},
"TorBox 请求" "TorBox 请求"
); );
const body = await res.json().catch(() => ({})); const body = await res.json().catch(() => ({}));
@@ -123,7 +136,9 @@ const torboxFetch = (path: string, init: RequestInit = {}) => {
}); });
if (failed) throw new Error(body.detail ?? res.statusText); if (failed) throw new Error(body.detail ?? res.statusText);
return body.data; return body.data;
}); },
context
);
}; };
export const isSourceType = (value: string): value is SourceType => export const isSourceType = (value: string): value is SourceType =>
@@ -305,7 +320,8 @@ const MOCK_PLAY_ERROR =
const signDownloadUrl = async ( const signDownloadUrl = async (
type: SourceType, type: SourceType,
id: string, id: string,
fileId: string fileId: string,
context?: TorboxRequestContext
) => { ) => {
const source = SOURCES.find((s) => s.type === type)!; const source = SOURCES.find((s) => s.type === type)!;
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -314,7 +330,7 @@ const signDownloadUrl = async (
file_id: fileId, file_id: fileId,
redirect: "false" redirect: "false"
}); });
const url = await torboxFetch(`/${type}/requestdl?${params}`); const url = await torboxFetch(`/${type}/requestdl?${params}`, {}, context);
if (typeof url !== "string" || !url) throw new Error("TorBox 未返回下载地址"); if (typeof url !== "string" || !url) throw new Error("TorBox 未返回下载地址");
return url; return url;
}; };
@@ -373,6 +389,8 @@ const signing = (signingGlobals[SIGNING_KEY] ??= new Map());
type RequestDownloadUrlOptions = { type RequestDownloadUrlOptions = {
/** 不用缓存、重新签发:缓存的直链被下载节点拒绝时使用 */ /** 不用缓存、重新签发:缓存的直链被下载节点拒绝时使用 */
fresh?: boolean; fresh?: boolean;
/** 本次请求的来源(如图片还是视频),额度用尽被拒绝时记入日志 */
context?: TorboxRequestContext;
}; };
/** /**
@@ -391,7 +409,7 @@ export const requestDownloadUrl = async (
type: SourceType, type: SourceType,
id: string, id: string,
fileId: string, fileId: string,
{ fresh = false }: RequestDownloadUrlOptions = {} { fresh = false, context }: RequestDownloadUrlOptions = {}
): Promise<SignedUrl & { from: DownloadUrlFrom }> => { ): Promise<SignedUrl & { from: DownloadUrlFrom }> => {
if (isMockMode()) throw new Error(MOCK_PLAY_ERROR); if (isMockMode()) throw new Error(MOCK_PLAY_ERROR);
@@ -405,7 +423,7 @@ export const requestDownloadUrl = async (
const pending = signing.get(key); const pending = signing.get(key);
if (pending) return { ...(await pending), from: "shared" }; if (pending) return { ...(await pending), from: "shared" };
const signed = signDownloadUrl(type, id, fileId) const signed = signDownloadUrl(type, id, fileId, context)
.then((url) => saveUrl(key, url, Date.now())) .then((url) => saveUrl(key, url, Date.now()))
.finally(() => signing.delete(key)); .finally(() => signing.delete(key));
signing.set(key, signed); signing.set(key, signed);
+1 -1
View File
@@ -58,7 +58,7 @@ export type LibraryResponse = {
/** 当前受限的 TorBox 额度 */ /** 当前受限的 TorBox 额度 */
export type TorboxLimitState = { export type TorboxLimitState = {
id: TorboxLimitId; id: TorboxLimitId;
/** 操作名,如「获取播放地址」 */ /** 操作名,如「获取文件地址」 */
label: string; label: string;
/** 多久(毫秒)后恢复 */ /** 多久(毫秒)后恢复 */
retryAfterMs: number; retryAfterMs: number;