PersistenceManager.js 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. // @ts-check
  2. const { setTimeout } = require('node:timers/promises')
  3. const Settings = require('@overleaf/settings')
  4. const Errors = require('./Errors')
  5. const OError = require('@overleaf/o-error')
  6. const Metrics = require('./Metrics')
  7. const logger = require('@overleaf/logger')
  8. const { fetchJson, RequestFailedError } = require('@overleaf/fetch-utils')
  9. const MAX_ATTEMPTS = 2
  10. const RETRY_DELAY_MS = 10
  11. // We have to be quick with HTTP calls because we're holding a lock that
  12. // expires after 30 seconds. We can't let any errors in the rest of the stack
  13. // hold us up, and need to bail out quickly if there is a problem.
  14. const MAX_HTTP_REQUEST_LENGTH = 5000 // 5 seconds
  15. async function getDocOnce(projectId, docId, options = {}) {
  16. const timer = new Metrics.Timer('persistenceManager.getDoc')
  17. const info = { projectId, docId } // for errors
  18. const url = new URL(
  19. `/project/${projectId}/doc/${docId}`,
  20. Settings.apis.web.url
  21. )
  22. if (options.peek) {
  23. // used by resyncs
  24. url.searchParams.set('peek', 'true')
  25. }
  26. const fetchParams = {
  27. method: 'GET',
  28. basicAuth: {
  29. user: Settings.apis.web.user,
  30. password: Settings.apis.web.pass,
  31. },
  32. signal: AbortSignal.timeout(MAX_HTTP_REQUEST_LENGTH),
  33. }
  34. try {
  35. const body = await fetchJson(url, fetchParams)
  36. if (body.lines == null) {
  37. throw new Errors.DocumentValidationError(
  38. 'web API response had no doc lines',
  39. info
  40. )
  41. }
  42. if (body.version == null) {
  43. throw new Errors.DocumentValidationError(
  44. 'web API response had no valid doc version',
  45. info
  46. )
  47. }
  48. if (body.pathname == null) {
  49. throw new Errors.DocumentValidationError(
  50. 'web API response had no valid doc pathname',
  51. info
  52. )
  53. }
  54. if (!body.pathname) {
  55. logger.warn(
  56. { projectId, docId },
  57. 'missing pathname in PersistenceManager getDoc'
  58. )
  59. Metrics.inc('pathname', 1, {
  60. path: 'PersistenceManager.getDoc',
  61. status: body.pathname === '' ? 'zero-length' : 'undefined',
  62. })
  63. }
  64. if (body.otMigrationStage > 0) {
  65. // Use history-ot
  66. body.lines = { content: body.lines.join('\n') }
  67. body.ranges = {}
  68. }
  69. if (!body.projectHistoryId) {
  70. logger.warn(
  71. { projectId, docId },
  72. 'projectHistoryId not found for doc from web'
  73. )
  74. }
  75. Metrics.inc('getDoc', 1, { status: '200' })
  76. return {
  77. lines: body.lines,
  78. version: body.version,
  79. ranges: body.ranges,
  80. pathname: body.pathname,
  81. projectHistoryId: body.projectHistoryId?.toString(),
  82. historyRangesSupport: body.historyRangesSupport || false,
  83. resolvedCommentIds: body.resolvedCommentIds || [],
  84. }
  85. } catch (err) {
  86. let status
  87. if (err instanceof RequestFailedError) {
  88. status = err.response?.status
  89. } else if (err instanceof Errors.DocumentValidationError) {
  90. status = 'validation-error'
  91. } else if (err instanceof Error && 'code' in err) {
  92. status = err.code
  93. } else {
  94. status = 'unknown'
  95. }
  96. Metrics.inc('getDoc', 1, { status })
  97. if (err instanceof RequestFailedError) {
  98. if (status === 404) {
  99. throw new Errors.NotFoundError('doc not found', info)
  100. } else if (status === 413) {
  101. throw new Errors.FileTooLargeError('doc exceeds maximum size', info)
  102. } else {
  103. throw new Errors.WebApiServerError('error accessing web API', {
  104. ...info,
  105. status,
  106. })
  107. }
  108. } else if (err instanceof Errors.DocumentValidationError) {
  109. throw err
  110. } else {
  111. throw OError.tag(err, 'getDoc failed', info)
  112. }
  113. } finally {
  114. timer.done()
  115. }
  116. }
  117. async function setDocOnce(
  118. projectId,
  119. docId,
  120. lines,
  121. version,
  122. ranges,
  123. lastUpdatedAt,
  124. lastUpdatedBy
  125. ) {
  126. const timer = new Metrics.Timer('persistenceManager.setDoc')
  127. const info = { projectId, docId } // for errors
  128. const url = new URL(
  129. `/project/${projectId}/doc/${docId}`,
  130. Settings.apis.web.url
  131. )
  132. const fetchParams = {
  133. method: 'POST',
  134. json: {
  135. lines,
  136. ranges,
  137. version,
  138. lastUpdatedBy,
  139. lastUpdatedAt,
  140. },
  141. basicAuth: {
  142. user: Settings.apis.web.user,
  143. password: Settings.apis.web.pass,
  144. },
  145. signal: AbortSignal.timeout(MAX_HTTP_REQUEST_LENGTH),
  146. }
  147. try {
  148. const result = await fetchJson(url, fetchParams)
  149. Metrics.inc('setDoc', 1, { status: '200' })
  150. return result
  151. } catch (err) {
  152. let status
  153. if (err instanceof RequestFailedError) {
  154. status = err.response?.status
  155. } else if (err instanceof Error && 'code' in err) {
  156. status = err.code
  157. } else {
  158. status = 'unknown'
  159. }
  160. Metrics.inc('setDoc', 1, { status })
  161. if (err instanceof RequestFailedError) {
  162. if (status === 404) {
  163. throw new Errors.NotFoundError('doc not found', info)
  164. } else if (status === 413) {
  165. throw new Errors.FileTooLargeError('doc exceeds maximum size', info)
  166. } else {
  167. throw new Errors.WebApiServerError('error accessing web API', {
  168. ...info,
  169. status,
  170. })
  171. }
  172. } else {
  173. throw OError.tag(err, 'setDoc failed', info)
  174. }
  175. } finally {
  176. timer.done()
  177. }
  178. }
  179. // Original set of retryable errors from requestretry
  180. const RETRYABLE_ERRORS = new Set([
  181. 'ECONNRESET',
  182. 'ENOTFOUND',
  183. 'ESOCKETTIMEDOUT',
  184. 'ETIMEDOUT',
  185. 'ECONNREFUSED',
  186. 'EHOSTUNREACH',
  187. 'EPIPE',
  188. 'EAI_AGAIN',
  189. 'EBUSY',
  190. ])
  191. function isRetryable(error) {
  192. // use the same retryable errors as requestretry
  193. // node-fetch uses AbortError:
  194. // https://github.com/node-fetch/node-fetch/blob/main/docs/ERROR-HANDLING.md
  195. if (error.name === 'AbortError') {
  196. return true
  197. } else if (error instanceof Errors.WebApiServerError) {
  198. const status = error.info?.status
  199. return (
  200. typeof status === 'number' &&
  201. (status === 429 || (status >= 500 && status < 600))
  202. )
  203. } else if (typeof error?.code === 'string') {
  204. return Boolean(RETRYABLE_ERRORS.has(error.code))
  205. } else {
  206. return false
  207. }
  208. }
  209. async function callWithRetries(name, fn) {
  210. let remainingAttempts = MAX_ATTEMPTS
  211. while (true) {
  212. try {
  213. const result = await fn()
  214. if (remainingAttempts < MAX_ATTEMPTS) {
  215. Metrics.inc(`${name}-retries`, 1, { status: 'success' })
  216. }
  217. return result
  218. } catch (err) {
  219. remainingAttempts--
  220. if (remainingAttempts > 0 && isRetryable(err)) {
  221. await setTimeout(RETRY_DELAY_MS)
  222. continue
  223. } else {
  224. if (remainingAttempts < MAX_ATTEMPTS - 1) {
  225. Metrics.inc(`${name}-retries`, 1, { status: 'error' })
  226. }
  227. throw err
  228. }
  229. }
  230. }
  231. }
  232. async function getDocWithRetries(projectId, docId, options = {}) {
  233. return await callWithRetries('getDoc', async () => {
  234. return await getDocOnce(projectId, docId, options)
  235. })
  236. }
  237. async function setDocWithRetries(
  238. projectId,
  239. docId,
  240. lines,
  241. version,
  242. ranges,
  243. lastUpdatedAt,
  244. lastUpdatedBy
  245. ) {
  246. return await callWithRetries('setDoc', async () => {
  247. return await setDocOnce(
  248. projectId,
  249. docId,
  250. lines,
  251. version,
  252. ranges,
  253. lastUpdatedAt,
  254. lastUpdatedBy
  255. )
  256. })
  257. }
  258. module.exports = {
  259. promises: {
  260. getDoc: getDocWithRetries,
  261. setDoc: setDocWithRetries,
  262. },
  263. }