fix: stream HTTP 200 heartbeat before upload body to prevent proxy timeouts
Build and Deploy Verso / deploy (push) Has been cancelled
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:
@@ -10,16 +10,21 @@ server {
|
||||
}
|
||||
|
||||
# File upload endpoints: extended timeouts for large files on slow connections.
|
||||
# client_body_timeout 15m is set globally in nginx.conf.template; repeated
|
||||
# here explicitly and client_max_body_size raised to 550m for this path.
|
||||
# proxy_request_buffering off: forward the request body to Node.js immediately
|
||||
# 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$ {
|
||||
proxy_pass http://127.0.0.1:4000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_request_buffering off;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 15m;
|
||||
proxy_send_timeout 15m;
|
||||
send_timeout 15m;
|
||||
client_body_timeout 15m;
|
||||
client_max_body_size 550m;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ import AnalyticsManager from '../Analytics/AnalyticsManager.mjs'
|
||||
|
||||
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(
|
||||
defaultsDeep(
|
||||
{
|
||||
@@ -92,7 +101,7 @@ async function uploadFile(req, res, next) {
|
||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||
})
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'invalid_filename',
|
||||
})
|
||||
@@ -119,7 +128,7 @@ async function uploadFile(req, res, next) {
|
||||
await fsPromises.unlink(path).catch(unlinkErr => {
|
||||
logger.warn({ err: unlinkErr, path }, 'error unlinking uploaded file')
|
||||
})
|
||||
throw error
|
||||
return sendUploadResponse(res, 500, { success: false })
|
||||
}
|
||||
|
||||
return FileSystemImportManager.addEntity(
|
||||
@@ -134,22 +143,22 @@ async function uploadFile(req, res, next) {
|
||||
timer.done()
|
||||
if (error != null) {
|
||||
if (error.name === 'InvalidNameError') {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'invalid_filename',
|
||||
})
|
||||
} else if (error instanceof DuplicateNameError) {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'duplicate_file_name',
|
||||
})
|
||||
} else if (error.message === 'project_has_too_many_files') {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'project_has_too_many_files',
|
||||
})
|
||||
} else if (error.message === 'folder_not_found') {
|
||||
return res.status(422).json({
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: 'folder_not_found',
|
||||
})
|
||||
@@ -164,10 +173,10 @@ async function uploadFile(req, res, next) {
|
||||
},
|
||||
'error uploading file'
|
||||
)
|
||||
return res.status(422).json({ success: false })
|
||||
return sendUploadResponse(res, 422, { success: false })
|
||||
}
|
||||
} else {
|
||||
return res.json({
|
||||
return sendUploadResponse(res, 200, {
|
||||
success: true,
|
||||
entity_id: entity?._id,
|
||||
entity_type: entity?.type,
|
||||
@@ -273,25 +282,28 @@ async function importDocument(req, res, next) {
|
||||
*/
|
||||
function multerMiddleware(req, res, next) {
|
||||
if (upload == null) {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ success: false, error: req.i18n.translate('upload_failed') })
|
||||
return sendUploadResponse(res, 500, {
|
||||
success: false,
|
||||
error: req.i18n.translate('upload_failed'),
|
||||
})
|
||||
}
|
||||
return upload.single('qqfile')(
|
||||
req,
|
||||
res,
|
||||
/** @param {any} err */ function (err) {
|
||||
if (err instanceof multer.MulterError && err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res
|
||||
.status(422)
|
||||
.json({ success: false, error: req.i18n.translate('file_too_large') })
|
||||
return sendUploadResponse(res, 422, {
|
||||
success: false,
|
||||
error: req.i18n.translate('file_too_large'),
|
||||
})
|
||||
}
|
||||
if (err) return next(err)
|
||||
if (!req.file?.path) {
|
||||
logger.info({ req }, 'missing req.file.path on upload')
|
||||
return res
|
||||
.status(400)
|
||||
.json({ success: false, error: 'invalid_upload_request' })
|
||||
return sendUploadResponse(res, 400, {
|
||||
success: false,
|
||||
error: 'invalid_upload_request',
|
||||
})
|
||||
}
|
||||
next()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,27 @@ import RateLimiterMiddleware from '../Security/RateLimiterMiddleware.mjs'
|
||||
import Settings from '@overleaf/settings'
|
||||
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 = {
|
||||
projectUpload: new RateLimiter('project-upload', {
|
||||
points: 20,
|
||||
@@ -62,6 +83,7 @@ export default {
|
||||
fileUploadRateLimit,
|
||||
AsyncLocalStorage.middleware,
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
startStreamingResponse,
|
||||
ProjectUploadController.multerMiddleware,
|
||||
ProjectUploadController.uploadFile
|
||||
)
|
||||
@@ -72,6 +94,7 @@ export default {
|
||||
AuthenticationController.requireLogin(),
|
||||
AsyncLocalStorage.middleware,
|
||||
AuthorizationMiddleware.ensureUserCanWriteProjectContent,
|
||||
startStreamingResponse,
|
||||
ProjectUploadController.multerMiddleware,
|
||||
ProjectUploadController.uploadFile
|
||||
)
|
||||
|
||||
+18
@@ -173,6 +173,24 @@ export default function FileTreeUploadDoc() {
|
||||
// limit: maxConnections || 1,
|
||||
limit: 1,
|
||||
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
|
||||
.on('complete', result => {
|
||||
|
||||
Reference in New Issue
Block a user