节流私有素材换签请求
限制大量不同素材同时换签时的首批派发和后续间隔 补充 read-url 换签爆发导致发布入口 429 的排障记录
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user