节流私有素材换签请求

限制大量不同素材同时换签时的首批派发和后续间隔

补充 read-url 换签爆发导致发布入口 429 的排障记录
This commit is contained in:
2026-06-23 23:11:37 +08:00
parent 562366820d
commit f1b4e70bda
4 changed files with 129 additions and 1 deletions
+56
View File
@@ -362,6 +362,62 @@ describe('assetReadUrlService', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
});
test('getSignedAssetReadUrl throttles distinct signed url bursts', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2099-01-01T00:00:00Z'));
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input) => {
const url = new URL(
String(input),
globalThis.location?.origin ?? 'https://www.genarrative.world',
);
const objectKey = url.searchParams.get('objectKey') ?? 'missing.png';
return new Response(
JSON.stringify({
ok: true,
data: {
read: {
objectKey,
signedUrl: `https://signed.example.com/${encodeURIComponent(objectKey)}`,
expiresAt: '2099-01-01T00:10:00Z',
},
},
error: null,
meta: {
apiVersion: '2026-06-16',
routeVersion: '2026-06-16',
latencyMs: 1,
timestamp: '2099-01-01T00:00:00Z',
},
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
);
},
);
const requests = Array.from({ length: 28 }, (_, index) =>
getSignedAssetReadUrl({
objectKey: `generated-character-drafts/editor/ui-design-assets/result/asset-${String(index + 1).padStart(3, '0')}.png`,
}),
);
await vi.advanceTimersByTimeAsync(0);
expect(fetchSpy).toHaveBeenCalledTimes(24);
await vi.advanceTimersByTimeAsync(16);
expect(fetchSpy).toHaveBeenCalledTimes(25);
await vi.advanceTimersByTimeAsync(48);
expect(fetchSpy).toHaveBeenCalledTimes(28);
await expect(Promise.all(requests)).resolves.toHaveLength(28);
});
test('getSignedAssetReadUrl caches not-found failures for the same legacy path', async () => {
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
new Response(
+64
View File
@@ -61,9 +61,13 @@ const ASSET_READ_URL_BACKGROUND_OPTIONS =
BACKGROUND_AUTH_REQUEST_OPTIONS satisfies ApiRequestOptions;
const SIGNED_READ_URL_SESSION_CACHE_PREFIX =
'genarrative.assetReadUrlCache.v1:';
const SIGNED_READ_URL_INITIAL_DISPATCH_BURST = 24;
const SIGNED_READ_URL_DISPATCH_SPACING_MS = 16;
const signedReadUrlCache = new Map<string, CachedReadUrlEntry>();
const signedReadUrlFailureCache = new Map<string, CachedReadUrlFailureEntry>();
const pendingSignedReadUrlRequests = new Map<string, Promise<string>>();
let signedReadUrlDispatchBurstUsed = 0;
let signedReadUrlNextDispatchAtMs = 0;
export function isGeneratedLegacyPath(value: string) {
return /^\/?generated-[^/?#]+\/.+/u.test(value.trim());
@@ -261,6 +265,64 @@ function writeSignedUrlSessionCache(
}
}
function resetSignedReadUrlDispatchLimiter() {
signedReadUrlDispatchBurstUsed = 0;
signedReadUrlNextDispatchAtMs = 0;
}
function reserveSignedReadUrlDispatchDelayMs(nowMs = Date.now()) {
if (signedReadUrlNextDispatchAtMs < nowMs) {
signedReadUrlDispatchBurstUsed = 0;
signedReadUrlNextDispatchAtMs = nowMs;
}
if (
signedReadUrlDispatchBurstUsed < SIGNED_READ_URL_INITIAL_DISPATCH_BURST
) {
signedReadUrlDispatchBurstUsed += 1;
return 0;
}
const dispatchAtMs = Math.max(
signedReadUrlNextDispatchAtMs + SIGNED_READ_URL_DISPATCH_SPACING_MS,
nowMs + SIGNED_READ_URL_DISPATCH_SPACING_MS,
);
signedReadUrlNextDispatchAtMs = dispatchAtMs;
return Math.max(0, dispatchAtMs - nowMs);
}
function createSignedReadUrlAbortError() {
if (typeof DOMException === 'function') {
return new DOMException('The operation was aborted.', 'AbortError');
}
return new Error('The operation was aborted.');
}
async function waitForSignedReadUrlDispatch(signal?: AbortSignal) {
if (signal?.aborted) {
throw createSignedReadUrlAbortError();
}
const delayMs = reserveSignedReadUrlDispatchDelayMs();
if (delayMs <= 0) {
return;
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', handleAbort);
resolve();
}, delayMs);
function handleAbort() {
clearTimeout(timer);
reject(createSignedReadUrlAbortError());
}
signal?.addEventListener('abort', handleAbort, { once: true });
});
}
function clearSignedUrlSessionCache() {
const storage = getSignedReadUrlSessionStorage();
if (!storage) {
@@ -323,6 +385,7 @@ export async function getSignedAssetReadUrl(
const searchParams = buildAssetReadSearchParams(request);
try {
await waitForSignedReadUrlDispatch(signal);
const response = await requestJson<AssetReadUrlResponse>(
`${ASSET_READ_URL_API_PATH}?${searchParams.toString()}`,
{
@@ -518,5 +581,6 @@ export function clearSignedAssetReadUrlCache() {
signedReadUrlCache.clear();
signedReadUrlFailureCache.clear();
pendingSignedReadUrlRequests.clear();
resetSignedReadUrlDispatchLimiter();
clearSignedUrlSessionCache();
}