mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-05-23 17:19:37 +02:00
add missing handling of timeouts in PersistenceManager GitOrigin-RevId: 7fe74068f3ea647b27103393c3f0b243b4b25fe3
277 lines
7.1 KiB
JavaScript
277 lines
7.1 KiB
JavaScript
// @ts-check
|
|
const { setTimeout } = require('node:timers/promises')
|
|
const Settings = require('@overleaf/settings')
|
|
const Errors = require('./Errors')
|
|
const OError = require('@overleaf/o-error')
|
|
const Metrics = require('./Metrics')
|
|
const logger = require('@overleaf/logger')
|
|
const { fetchJson, RequestFailedError } = require('@overleaf/fetch-utils')
|
|
|
|
const MAX_ATTEMPTS = 2
|
|
const RETRY_DELAY_MS = 10
|
|
// We have to be quick with HTTP calls because we're holding a lock that
|
|
// expires after 30 seconds. We can't let any errors in the rest of the stack
|
|
// hold us up, and need to bail out quickly if there is a problem.
|
|
const MAX_HTTP_REQUEST_LENGTH = 5000 // 5 seconds
|
|
|
|
async function getDocOnce(projectId, docId, options = {}) {
|
|
const timer = new Metrics.Timer('persistenceManager.getDoc')
|
|
const info = { projectId, docId } // for errors
|
|
const url = new URL(
|
|
`/project/${projectId}/doc/${docId}`,
|
|
Settings.apis.web.url
|
|
)
|
|
if (options.peek) {
|
|
// used by resyncs
|
|
url.searchParams.set('peek', 'true')
|
|
}
|
|
const fetchParams = {
|
|
method: 'GET',
|
|
basicAuth: {
|
|
user: Settings.apis.web.user,
|
|
password: Settings.apis.web.pass,
|
|
},
|
|
signal: AbortSignal.timeout(MAX_HTTP_REQUEST_LENGTH),
|
|
}
|
|
try {
|
|
const body = await fetchJson(url, fetchParams)
|
|
|
|
if (body.lines == null) {
|
|
throw new Errors.DocumentValidationError(
|
|
'web API response had no doc lines',
|
|
info
|
|
)
|
|
}
|
|
if (body.version == null) {
|
|
throw new Errors.DocumentValidationError(
|
|
'web API response had no valid doc version',
|
|
info
|
|
)
|
|
}
|
|
if (body.pathname == null) {
|
|
throw new Errors.DocumentValidationError(
|
|
'web API response had no valid doc pathname',
|
|
info
|
|
)
|
|
}
|
|
if (!body.pathname) {
|
|
logger.warn(
|
|
{ projectId, docId },
|
|
'missing pathname in PersistenceManager getDoc'
|
|
)
|
|
Metrics.inc('pathname', 1, {
|
|
path: 'PersistenceManager.getDoc',
|
|
status: body.pathname === '' ? 'zero-length' : 'undefined',
|
|
})
|
|
}
|
|
|
|
if (body.otMigrationStage > 0) {
|
|
// Use history-ot
|
|
body.lines = { content: body.lines.join('\n') }
|
|
body.ranges = {}
|
|
}
|
|
|
|
if (!body.projectHistoryId) {
|
|
logger.warn(
|
|
{ projectId, docId },
|
|
'projectHistoryId not found for doc from web'
|
|
)
|
|
}
|
|
Metrics.inc('getDoc', 1, { status: '200' })
|
|
return {
|
|
lines: body.lines,
|
|
version: body.version,
|
|
ranges: body.ranges,
|
|
pathname: body.pathname,
|
|
projectHistoryId: body.projectHistoryId?.toString(),
|
|
historyRangesSupport: body.historyRangesSupport || false,
|
|
resolvedCommentIds: body.resolvedCommentIds || [],
|
|
}
|
|
} catch (err) {
|
|
let status
|
|
if (err instanceof RequestFailedError) {
|
|
status = err.response?.status
|
|
} else if (err instanceof Errors.DocumentValidationError) {
|
|
status = 'validation-error'
|
|
} else if (err instanceof Error && 'code' in err) {
|
|
status = err.code
|
|
} else {
|
|
status = 'unknown'
|
|
}
|
|
Metrics.inc('getDoc', 1, { status })
|
|
if (err instanceof RequestFailedError) {
|
|
if (status === 404) {
|
|
throw new Errors.NotFoundError('doc not found', info)
|
|
} else if (status === 413) {
|
|
throw new Errors.FileTooLargeError('doc exceeds maximum size', info)
|
|
} else {
|
|
throw new Errors.WebApiServerError('error accessing web API', {
|
|
...info,
|
|
status,
|
|
})
|
|
}
|
|
} else if (err instanceof Errors.DocumentValidationError) {
|
|
throw err
|
|
} else {
|
|
throw OError.tag(err, 'getDoc failed', info)
|
|
}
|
|
} finally {
|
|
timer.done()
|
|
}
|
|
}
|
|
|
|
async function setDocOnce(
|
|
projectId,
|
|
docId,
|
|
lines,
|
|
version,
|
|
ranges,
|
|
lastUpdatedAt,
|
|
lastUpdatedBy
|
|
) {
|
|
const timer = new Metrics.Timer('persistenceManager.setDoc')
|
|
|
|
const info = { projectId, docId } // for errors
|
|
const url = new URL(
|
|
`/project/${projectId}/doc/${docId}`,
|
|
Settings.apis.web.url
|
|
)
|
|
const fetchParams = {
|
|
method: 'POST',
|
|
json: {
|
|
lines,
|
|
ranges,
|
|
version,
|
|
lastUpdatedBy,
|
|
lastUpdatedAt,
|
|
},
|
|
basicAuth: {
|
|
user: Settings.apis.web.user,
|
|
password: Settings.apis.web.pass,
|
|
},
|
|
signal: AbortSignal.timeout(MAX_HTTP_REQUEST_LENGTH),
|
|
}
|
|
try {
|
|
const result = await fetchJson(url, fetchParams)
|
|
Metrics.inc('setDoc', 1, { status: '200' })
|
|
return result
|
|
} catch (err) {
|
|
let status
|
|
if (err instanceof RequestFailedError) {
|
|
status = err.response?.status
|
|
} else if (err instanceof Error && 'code' in err) {
|
|
status = err.code
|
|
} else {
|
|
status = 'unknown'
|
|
}
|
|
Metrics.inc('setDoc', 1, { status })
|
|
if (err instanceof RequestFailedError) {
|
|
if (status === 404) {
|
|
throw new Errors.NotFoundError('doc not found', info)
|
|
} else if (status === 413) {
|
|
throw new Errors.FileTooLargeError('doc exceeds maximum size', info)
|
|
} else {
|
|
throw new Errors.WebApiServerError('error accessing web API', {
|
|
...info,
|
|
status,
|
|
})
|
|
}
|
|
} else {
|
|
throw OError.tag(err, 'setDoc failed', info)
|
|
}
|
|
} finally {
|
|
timer.done()
|
|
}
|
|
}
|
|
|
|
// Original set of retryable errors from requestretry
|
|
const RETRYABLE_ERRORS = new Set([
|
|
'ECONNRESET',
|
|
'ENOTFOUND',
|
|
'ESOCKETTIMEDOUT',
|
|
'ETIMEDOUT',
|
|
'ECONNREFUSED',
|
|
'EHOSTUNREACH',
|
|
'EPIPE',
|
|
'EAI_AGAIN',
|
|
'EBUSY',
|
|
])
|
|
|
|
function isRetryable(error) {
|
|
// use the same retryable errors as requestretry
|
|
// node-fetch uses AbortError:
|
|
// https://github.com/node-fetch/node-fetch/blob/main/docs/ERROR-HANDLING.md
|
|
if (error.name === 'AbortError') {
|
|
return true
|
|
} else if (error instanceof Errors.WebApiServerError) {
|
|
const status = error.info?.status
|
|
return (
|
|
typeof status === 'number' &&
|
|
(status === 429 || (status >= 500 && status < 600))
|
|
)
|
|
} else if (typeof error?.code === 'string') {
|
|
return Boolean(RETRYABLE_ERRORS.has(error.code))
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function callWithRetries(name, fn) {
|
|
let remainingAttempts = MAX_ATTEMPTS
|
|
while (true) {
|
|
try {
|
|
const result = await fn()
|
|
if (remainingAttempts < MAX_ATTEMPTS) {
|
|
Metrics.inc(`${name}-retries`, 1, { status: 'success' })
|
|
}
|
|
return result
|
|
} catch (err) {
|
|
remainingAttempts--
|
|
if (remainingAttempts > 0 && isRetryable(err)) {
|
|
await setTimeout(RETRY_DELAY_MS)
|
|
continue
|
|
} else {
|
|
if (remainingAttempts < MAX_ATTEMPTS - 1) {
|
|
Metrics.inc(`${name}-retries`, 1, { status: 'error' })
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function getDocWithRetries(projectId, docId, options = {}) {
|
|
return await callWithRetries('getDoc', async () => {
|
|
return await getDocOnce(projectId, docId, options)
|
|
})
|
|
}
|
|
|
|
async function setDocWithRetries(
|
|
projectId,
|
|
docId,
|
|
lines,
|
|
version,
|
|
ranges,
|
|
lastUpdatedAt,
|
|
lastUpdatedBy
|
|
) {
|
|
return await callWithRetries('setDoc', async () => {
|
|
return await setDocOnce(
|
|
projectId,
|
|
docId,
|
|
lines,
|
|
version,
|
|
ranges,
|
|
lastUpdatedAt,
|
|
lastUpdatedBy
|
|
)
|
|
})
|
|
}
|
|
|
|
module.exports = {
|
|
promises: {
|
|
getDoc: getDocWithRetries,
|
|
setDoc: setDocWithRetries,
|
|
},
|
|
}
|