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.
|
# 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
|
||||||
)
|
)
|
||||||
|
|||||||
+18
@@ -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 => {
|
||||||
|
|||||||
Reference in New Issue
Block a user