fix: stream HTTP 200 heartbeat before upload body to prevent proxy timeouts
Build and Deploy Verso / deploy (push) Has been cancelled

Uploads from slow connections consistently fail with 502 after ~60-120s
because an upstream proxy (Traefik or cloud load-balancer) has a
"first response byte" deadline that fires before the request body arrives.

Fix: add startStreamingResponse middleware (after auth, before multer)
that immediately writes HTTP 200 + Transfer-Encoding: chunked + '\n'.
With proxy_request_buffering off in Nginx, this reaches the proxy at T≈0,
so no timeout triggers. The upload body continues streaming; multer writes
to disk; the actual JSON result arrives as the final chunk. Periodic
heartbeat '\n' writes every 30s keep response-idle timeouts at bay too.

Client-side: override Uppy's getResponseData/validateStatus to trim
leading whitespace before JSON.parse so the extra '\n' bytes are ignored.
Server-side: sendUploadResponse() helper handles both streaming mode
(res.headersSent → res.end(json)) and normal mode (res.status(N).json()).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-14 08:59:02 +00:00
parent 8f372d13f8
commit 94d3764c05
4 changed files with 77 additions and 19 deletions
+7 -2
View File
@@ -10,16 +10,21 @@ server {
} }
# File upload endpoints: extended timeouts for large files on slow connections. # File upload endpoints: extended timeouts for large files on slow connections.
# client_body_timeout 15m is set globally in nginx.conf.template; repeated # proxy_request_buffering off: forward the request body to Node.js immediately
# here explicitly and client_max_body_size raised to 550m for this path. # rather than buffering first, so Node.js can send a keepalive response byte
# before the full body arrives (preventing upstream proxy "first-byte" timeouts).
# proxy_buffering off: forward that keepalive byte to Traefik/LB without delay.
location ~ ^/project/[^/]+/upload$ { location ~ ^/project/[^/]+/upload$ {
proxy_pass http://127.0.0.1:4000; proxy_pass http://127.0.0.1:4000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host; proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_request_buffering off;
proxy_buffering off;
proxy_read_timeout 15m; proxy_read_timeout 15m;
proxy_send_timeout 15m; proxy_send_timeout 15m;
send_timeout 15m;
client_body_timeout 15m; client_body_timeout 15m;
client_max_body_size 550m; client_max_body_size 550m;
} }
@@ -24,6 +24,15 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
const defaultsDeep = lodash.defaultsDeep const defaultsDeep = lodash.defaultsDeep
// Send a JSON response compatible with both normal mode and streaming mode
// (where startStreamingResponse already sent HTTP 200 + chunked headers).
function sendUploadResponse(res, statusCode, body) {
if (res.headersSent) {
return res.end(JSON.stringify(body))
}
return res.status(statusCode).json(body)
}
const upload = multer( const upload = multer(
defaultsDeep( defaultsDeep(
{ {
@@ -92,7 +101,7 @@ async function uploadFile(req, res, next) {
await fsPromises.unlink(path).catch(unlinkErr => { await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file') logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
}) })
return res.status(422).json({ return sendUploadResponse(res, 422, {
success: false, success: false,
error: 'invalid_filename', error: 'invalid_filename',
}) })
@@ -119,7 +128,7 @@ async function uploadFile(req, res, next) {
await fsPromises.unlink(path).catch(unlinkErr => { await fsPromises.unlink(path).catch(unlinkErr => {
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file') logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
}) })
throw error return sendUploadResponse(res, 500, { success: false })
} }
return FileSystemImportManager.addEntity( return FileSystemImportManager.addEntity(
@@ -134,22 +143,22 @@ async function uploadFile(req, res, next) {
timer.done() timer.done()
if (error != null) { if (error != null) {
if (error.name === 'InvalidNameError') { if (error.name === 'InvalidNameError') {
return res.status(422).json({ return sendUploadResponse(res, 422, {
success: false, success: false,
error: 'invalid_filename', error: 'invalid_filename',
}) })
} else if (error instanceof DuplicateNameError) { } else if (error instanceof DuplicateNameError) {
return res.status(422).json({ return sendUploadResponse(res, 422, {
success: false, success: false,
error: 'duplicate_file_name', error: 'duplicate_file_name',
}) })
} else if (error.message === 'project_has_too_many_files') { } else if (error.message === 'project_has_too_many_files') {
return res.status(422).json({ return sendUploadResponse(res, 422, {
success: false, success: false,
error: 'project_has_too_many_files', error: 'project_has_too_many_files',
}) })
} else if (error.message === 'folder_not_found') { } else if (error.message === 'folder_not_found') {
return res.status(422).json({ return sendUploadResponse(res, 422, {
success: false, success: false,
error: 'folder_not_found', error: 'folder_not_found',
}) })
@@ -164,10 +173,10 @@ async function uploadFile(req, res, next) {
}, },
'error uploading file' 'error uploading file'
) )
return res.status(422).json({ success: false }) return sendUploadResponse(res, 422, { success: false })
} }
} else { } else {
return res.json({ return sendUploadResponse(res, 200, {
success: true, success: true,
entity_id: entity?._id, entity_id: entity?._id,
entity_type: entity?.type, entity_type: entity?.type,
@@ -273,25 +282,28 @@ async function importDocument(req, res, next) {
*/ */
function multerMiddleware(req, res, next) { function multerMiddleware(req, res, next) {
if (upload == null) { if (upload == null) {
return res return sendUploadResponse(res, 500, {
.status(500) success: false,
.json({ success: false, error: req.i18n.translate('upload_failed') }) error: req.i18n.translate('upload_failed'),
})
} }
return upload.single('qqfile')( return upload.single('qqfile')(
req, req,
res, res,
/** @param {any} err */ function (err) { /** @param {any} err */ function (err) {
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') { if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
return res return sendUploadResponse(res, 422, {
.status(422) success: false,
.json({ success: false, error: req.i18n.translate('file_too_large') }) error: req.i18n.translate('file_too_large'),
})
} }
if (err) return next(err) if (err) return next(err)
if (!req.file?.path) { if (!req.file?.path) {
logger.info({ req }, 'missing req.file.path on upload') logger.info({ req }, 'missing req.file.path on upload')
return res return sendUploadResponse(res, 400, {
.status(400) success: false,
.json({ success: false, error: 'invalid_upload_request' }) error: 'invalid_upload_request',
})
} }
next() next()
} }
@@ -6,6 +6,27 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
import Settings from '@overleaf/settings' import Settings from '@overleaf/settings'
import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs' import AsyncLocalStorage from '../../infrastructure/AsyncLocalStorage.mjs'
// Sends HTTP 200 + first chunk immediately so upstream proxies (Traefik, cloud
// LBs) don't timeout waiting for the first response byte during large uploads.
// Must come *after* auth/rate-limit middleware (those still return proper codes)
// but *before* multer so it fires while the request body is still streaming in.
// The upload handler ends the response with the actual JSON result as the final
// chunk; the client's getResponseData trims leading whitespace before parsing.
function startStreamingResponse(req, res, next) {
res.writeHead(200, {
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked',
'X-Accel-Buffering': 'no',
})
res.write('\n')
const heartbeat = setInterval(() => {
if (!res.writableEnded) res.write('\n')
}, 30000)
res.on('finish', () => clearInterval(heartbeat))
res.on('close', () => clearInterval(heartbeat))
next()
}
const rateLimiters = { const rateLimiters = {
projectUpload: new RateLimiter('project-upload', { projectUpload: new RateLimiter('project-upload', {
points: 20, points: 20,
@@ -62,6 +83,7 @@ export default {
fileUploadRateLimit, fileUploadRateLimit,
AsyncLocalStorage.middleware, AsyncLocalStorage.middleware,
AuthorizationMiddleware.ensureUserCanWriteProjectContent, AuthorizationMiddleware.ensureUserCanWriteProjectContent,
startStreamingResponse,
ProjectUploadController.multerMiddleware, ProjectUploadController.multerMiddleware,
ProjectUploadController.uploadFile ProjectUploadController.uploadFile
) )
@@ -72,6 +94,7 @@ export default {
AuthenticationController.requireLogin(), AuthenticationController.requireLogin(),
AsyncLocalStorage.middleware, AsyncLocalStorage.middleware,
AuthorizationMiddleware.ensureUserCanWriteProjectContent, AuthorizationMiddleware.ensureUserCanWriteProjectContent,
startStreamingResponse,
ProjectUploadController.multerMiddleware, ProjectUploadController.multerMiddleware,
ProjectUploadController.uploadFile ProjectUploadController.uploadFile
) )
@@ -173,6 +173,24 @@ export default function FileTreeUploadDoc() {
// limit: maxConnections || 1, // limit: maxConnections || 1,
limit: 1, limit: 1,
fieldName: 'qqfile', // "qqfile" field inherited from FineUploader fieldName: 'qqfile', // "qqfile" field inherited from FineUploader
// The server sends HTTP 200 + a keepalive '\n' byte immediately to
// prevent upstream proxy timeouts on slow connections, then streams
// the actual JSON result as the final chunk. Trim before parsing.
getResponseData: (responseText: string) => {
try {
return JSON.parse(responseText.trim())
} catch {
return {}
}
},
validateStatus: (statusCode: number, responseText: string) => {
if (statusCode < 200 || statusCode >= 300) return false
try {
return JSON.parse(responseText.trim()).success === true
} catch {
return false
}
},
}) })
// close the modal when all the uploads completed successfully // close the modal when all the uploads completed successfully
.on('complete', result => { .on('complete', result => {