From b52d6b1a6fa296c7ad40636c93efd88517e05c84 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Thu, 19 Feb 2026 13:55:38 -0700 Subject: [PATCH] Queue concurrent uploads instead of rejecting with 503 Server: replace hard 503 rejection with a bounded queue (50 slots, 2min timeout) so requests wait for a processing slot instead of failing. Client: upload files sequentially instead of via Promise.all, and retry with exponential backoff on 503/504 responses. --- hooks/useGame.ts | 53 +++++++++++++++++++++++-------------- server/src/routes/upload.ts | 34 +++++++++++++++++++++++- 2 files changed, 66 insertions(+), 21 deletions(-) diff --git a/hooks/useGame.ts b/hooks/useGame.ts index 7a48996..5159ed3 100644 --- a/hooks/useGame.ts +++ b/hooks/useGame.ts @@ -730,29 +730,41 @@ export const useGame = (defaultGameConfig?: GameConfig) => { }; }, []); - const uploadDocument = async (file: File, useOcr: boolean = false): Promise => { + const uploadDocument = async (file: File, useOcr: boolean = false, maxRetries = 5): Promise => { if (!auth.user?.access_token) { throw new Error('Authentication required to upload documents'); } - const formData = new FormData(); - formData.append('document', file); - formData.append('useOcr', String(useOcr)); - - const response = await fetch(`${BACKEND_URL}/api/upload`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${auth.user.access_token}`, - }, - body: formData - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to upload document'); + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const formData = new FormData(); + formData.append('document', file); + formData.append('useOcr', String(useOcr)); + + const response = await fetch(`${BACKEND_URL}/api/upload`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${auth.user.access_token}`, + }, + body: formData + }); + + if (response.status === 503 || response.status === 504) { + if (attempt < maxRetries) { + const delay = Math.min(1000 * 2 ** attempt, 15000); + await new Promise(r => setTimeout(r, delay)); + continue; + } + } + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to upload document'); + } + + return response.json(); } - return response.json(); + throw new Error('Failed to upload document after multiple retries'); }; const startQuizGen = async (options: { @@ -773,9 +785,10 @@ export const useGame = (defaultGameConfig?: GameConfig) => { let documents: ProcessedDocument[] | undefined; if (options.files && options.files.length > 0) { - documents = await Promise.all( - options.files.map(file => uploadDocument(file, options.useOcr)) - ); + documents = []; + for (const file of options.files) { + documents.push(await uploadDocument(file, options.useOcr)); + } } const generateOptions: GenerateQuizOptions = { diff --git a/server/src/routes/upload.ts b/server/src/routes/upload.ts index 8db13b2..07c3a4a 100644 --- a/server/src/routes/upload.ts +++ b/server/src/routes/upload.ts @@ -44,6 +44,31 @@ function tieredUploadLimiter(req: AuthenticatedRequest, res: Response, next: Nex let activeUploads = 0; const MAX_CONCURRENT_UPLOADS = 5; +const MAX_QUEUE_SIZE = 50; +const QUEUE_TIMEOUT_MS = 120_000; // 2 minutes + +const waitForSlot = (): Promise => { + if (activeUploads < MAX_CONCURRENT_UPLOADS) return Promise.resolve(); + return new Promise((resolve, reject) => { + let wrappedResolve: () => void; + const timeout = setTimeout(() => { + const idx = uploadQueue.indexOf(wrappedResolve); + if (idx !== -1) uploadQueue.splice(idx, 1); + reject(new Error('Upload queue timeout')); + }, QUEUE_TIMEOUT_MS); + wrappedResolve = () => { clearTimeout(timeout); resolve(); }; + uploadQueue.push(wrappedResolve); + }); +}; + +const uploadQueue: Array<() => void> = []; + +const drainQueue = () => { + while (uploadQueue.length > 0 && activeUploads < MAX_CONCURRENT_UPLOADS) { + const next = uploadQueue.shift(); + if (next) next(); + } +}; router.use(requireAuth); router.use(tieredUploadLimiter); @@ -66,10 +91,16 @@ const upload = multer({ }); router.post('/', upload.single('document'), async (req: AuthenticatedRequest, res) => { - if (activeUploads >= MAX_CONCURRENT_UPLOADS) { + if (activeUploads >= MAX_CONCURRENT_UPLOADS && uploadQueue.length >= MAX_QUEUE_SIZE) { return res.status(503).json({ error: 'Server busy processing uploads. Please try again shortly.' }); } + try { + await waitForSlot(); + } catch { + return res.status(504).json({ error: 'Upload timed out waiting in queue. Please try again.' }); + } + activeUploads++; try { @@ -114,6 +145,7 @@ router.post('/', upload.single('document'), async (req: AuthenticatedRequest, re }); } finally { activeUploads--; + drainQueue(); } });