Merge pull request #184 from overleaf/jpa-custom-errors

[misc] migrate to custom OErrors
This commit is contained in:
Jakob Ackermann 2020-08-25 11:42:19 +02:00 committed by GitHub
commit 735027a2b7
13 changed files with 190 additions and 100 deletions

View file

@ -1,6 +1,8 @@
/* eslint-disable /* eslint-disable
camelcase, camelcase,
*/ */
const { NotAuthorizedError } = require('./Errors')
let AuthorizationManager let AuthorizationManager
module.exports = AuthorizationManager = { module.exports = AuthorizationManager = {
assertClientCanViewProject(client, callback) { assertClientCanViewProject(client, callback) {
@ -23,7 +25,7 @@ module.exports = AuthorizationManager = {
if (allowedLevels.includes(client.ol_context.privilege_level)) { if (allowedLevels.includes(client.ol_context.privilege_level)) {
callback(null) callback(null)
} else { } else {
callback(new Error('not authorized')) callback(new NotAuthorizedError())
} }
}, },
@ -49,7 +51,7 @@ module.exports = AuthorizationManager = {
if (client.ol_context[`doc:${doc_id}`] === 'allowed') { if (client.ol_context[`doc:${doc_id}`] === 'allowed') {
callback(null) callback(null)
} else { } else {
callback(new Error('not authorized')) callback(new NotAuthorizedError())
} }
}, },

View file

@ -6,6 +6,12 @@ const _ = require('underscore')
const logger = require('logger-sharelatex') const logger = require('logger-sharelatex')
const settings = require('settings-sharelatex') const settings = require('settings-sharelatex')
const metrics = require('metrics-sharelatex') const metrics = require('metrics-sharelatex')
const {
ClientRequestedMissingOpsError,
DocumentUpdaterRequestFailedError,
NullBytesInOpError,
UpdateTooLargeError
} = require('./Errors')
const rclient = require('redis-sharelatex').createClient( const rclient = require('redis-sharelatex').createClient(
settings.redis.documentupdater settings.redis.documentupdater
@ -42,23 +48,11 @@ const DocumentUpdaterManager = {
body = body || {} body = body || {}
callback(null, body.lines, body.version, body.ranges, body.ops) callback(null, body.lines, body.version, body.ranges, body.ops)
} else if ([404, 422].includes(res.statusCode)) { } else if ([404, 422].includes(res.statusCode)) {
err = new Error('doc updater could not load requested ops') callback(new ClientRequestedMissingOpsError(res.statusCode))
err.statusCode = res.statusCode
logger.warn(
{ err, project_id, doc_id, url, fromVersion },
'doc updater could not load requested ops'
)
callback(err)
} else { } else {
err = new Error( callback(
`doc updater returned a non-success status code: ${res.statusCode}` new DocumentUpdaterRequestFailedError('getDocument', res.statusCode)
) )
err.statusCode = res.statusCode
logger.error(
{ err, project_id, doc_id, url },
`doc updater returned a non-success status code: ${res.statusCode}`
)
callback(err)
} }
}) })
}, },
@ -88,15 +82,12 @@ const DocumentUpdaterManager = {
logger.log({ project_id }, 'deleted project from document updater') logger.log({ project_id }, 'deleted project from document updater')
callback(null) callback(null)
} else { } else {
err = new Error( callback(
`document updater returned a failure status code: ${res.statusCode}` new DocumentUpdaterRequestFailedError(
'flushProjectToMongoAndDelete',
res.statusCode
)
) )
err.statusCode = res.statusCode
logger.error(
{ err, project_id },
`document updater returned failure status code: ${res.statusCode}`
)
callback(err)
} }
}) })
}, },
@ -115,19 +106,12 @@ const DocumentUpdaterManager = {
const jsonChange = JSON.stringify(change) const jsonChange = JSON.stringify(change)
if (jsonChange.indexOf('\u0000') !== -1) { if (jsonChange.indexOf('\u0000') !== -1) {
// memory corruption check // memory corruption check
const error = new Error('null bytes found in op') return callback(new NullBytesInOpError(jsonChange))
logger.error(
{ err: error, project_id, doc_id, jsonChange },
error.message
)
return callback(error)
} }
const updateSize = jsonChange.length const updateSize = jsonChange.length
if (updateSize > settings.maxUpdateSize) { if (updateSize > settings.maxUpdateSize) {
const error = new Error('update is too large') return callback(new UpdateTooLargeError(updateSize))
error.updateSize = updateSize
return callback(error)
} }
// record metric for each update added to queue // record metric for each update added to queue

View file

@ -1,9 +1,103 @@
class CodedError extends Error { const OError = require('@overleaf/o-error')
constructor(message, code) {
super(message) class ClientRequestedMissingOpsError extends OError {
this.name = this.constructor.name constructor(statusCode) {
this.code = code super('doc updater could not load requested ops', {
statusCode
})
} }
} }
module.exports = { CodedError } class CodedError extends OError {
constructor(message, code) {
super(message, { code })
}
}
class CorruptedJoinProjectResponseError extends OError {
constructor() {
super('no data returned from joinProject request')
}
}
class DataTooLargeToParseError extends OError {
constructor(data) {
super('data too large to parse', {
head: data.slice(0, 1024),
length: data.length
})
}
}
class DocumentUpdaterRequestFailedError extends OError {
constructor(action, statusCode) {
super('doc updater returned a non-success status code', {
action,
statusCode
})
}
}
class JoinLeaveEpochMismatchError extends OError {
constructor() {
super('joinLeaveEpoch mismatch')
}
}
class MissingSessionError extends OError {
constructor() {
super('could not look up session by key')
}
}
class NotAuthorizedError extends OError {
constructor() {
super('not authorized')
}
}
class NotJoinedError extends OError {
constructor() {
super('no project_id found on client')
}
}
class NullBytesInOpError extends OError {
constructor(jsonChange) {
super('null bytes found in op', { jsonChange })
}
}
class UnexpectedArgumentsError extends OError {
constructor() {
super('unexpected arguments')
}
}
class UpdateTooLargeError extends OError {
constructor(updateSize) {
super('update is too large', { updateSize })
}
}
class WebApiRequestFailedError extends OError {
constructor(statusCode) {
super('non-success status code from web', { statusCode })
}
}
module.exports = {
CodedError,
CorruptedJoinProjectResponseError,
ClientRequestedMissingOpsError,
DataTooLargeToParseError,
DocumentUpdaterRequestFailedError,
JoinLeaveEpochMismatchError,
MissingSessionError,
NotAuthorizedError,
NotJoinedError,
NullBytesInOpError,
UnexpectedArgumentsError,
UpdateTooLargeError,
WebApiRequestFailedError
}

View file

@ -9,6 +9,7 @@ const HttpController = require('./HttpController')
const HttpApiController = require('./HttpApiController') const HttpApiController = require('./HttpApiController')
const bodyParser = require('body-parser') const bodyParser = require('body-parser')
const base64id = require('base64id') const base64id = require('base64id')
const { UnexpectedArgumentsError } = require('./Errors')
const basicAuth = require('basic-auth-connect') const basicAuth = require('basic-auth-connect')
const httpAuth = basicAuth(function (user, pass) { const httpAuth = basicAuth(function (user, pass) {
@ -33,8 +34,8 @@ module.exports = Router = {
attrs.client_id = client.id attrs.client_id = client.id
attrs.err = error attrs.err = error
if (error.name === 'CodedError') { if (error.name === 'CodedError') {
logger.warn(attrs, error.message, { code: error.code }) logger.warn(attrs, error.message)
const serializedError = { message: error.message, code: error.code } const serializedError = { message: error.message, code: error.info.code }
callback(serializedError) callback(serializedError)
} else if (error.message === 'unexpected arguments') { } else if (error.message === 'unexpected arguments') {
// the payload might be very large, put it on level info // the payload might be very large, put it on level info
@ -64,7 +65,7 @@ module.exports = Router = {
}, },
_handleInvalidArguments(client, method, args) { _handleInvalidArguments(client, method, args) {
const error = new Error('unexpected arguments') const error = new UnexpectedArgumentsError()
let callback = args[args.length - 1] let callback = args[args.length - 1]
if (typeof callback !== 'function') { if (typeof callback !== 'function') {
callback = function () {} callback = function () {}

View file

@ -1,14 +1,10 @@
const Settings = require('settings-sharelatex') const Settings = require('settings-sharelatex')
const logger = require('logger-sharelatex') const { DataTooLargeToParseError } = require('./Errors')
module.exports = { module.exports = {
parse(data, callback) { parse(data, callback) {
if (data.length > Settings.maxUpdateSize) { if (data.length > Settings.maxUpdateSize) {
logger.error( return callback(new DataTooLargeToParseError(data))
{ head: data.slice(0, 1024), length: data.length },
'data too large to parse'
)
return callback(new Error('data too large to parse'))
} }
let parsed let parsed
try { try {

View file

@ -1,7 +1,8 @@
const { EventEmitter } = require('events') const { EventEmitter } = require('events')
const { MissingSessionError } = require('./Errors')
module.exports = function (io, sessionStore, cookieParser, cookieName) { module.exports = function (io, sessionStore, cookieParser, cookieName) {
const missingSessionError = new Error('could not look up session by key') const missingSessionError = new MissingSessionError()
const sessionSockets = new EventEmitter() const sessionSockets = new EventEmitter()
function next(error, socket, session) { function next(error, socket, session) {

View file

@ -4,7 +4,11 @@
const request = require('request') const request = require('request')
const settings = require('settings-sharelatex') const settings = require('settings-sharelatex')
const logger = require('logger-sharelatex') const logger = require('logger-sharelatex')
const { CodedError } = require('./Errors') const {
CodedError,
CorruptedJoinProjectResponseError,
WebApiRequestFailedError
} = require('./Errors')
module.exports = { module.exports = {
joinProject(project_id, user, callback) { joinProject(project_id, user, callback) {
@ -32,15 +36,9 @@ module.exports = {
if (error) { if (error) {
return callback(error) return callback(error)
} }
let err
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
if (!(data && data.project)) { if (!(data && data.project)) {
err = new Error('no data returned from joinProject request') return callback(new CorruptedJoinProjectResponseError())
logger.error(
{ err, project_id, user_id },
'error accessing web api'
)
return callback(err)
} }
callback( callback(
null, null,
@ -57,11 +55,7 @@ module.exports = {
) )
) )
} else { } else {
err = new Error( callback(new WebApiRequestFailedError(response.statusCode))
`non-success status code from web: ${response.statusCode}`
)
logger.error({ err, project_id, user_id }, 'error accessing web api')
callback(err)
} }
} }
) )

View file

@ -9,6 +9,11 @@ const DocumentUpdaterManager = require('./DocumentUpdaterManager')
const ConnectedUsersManager = require('./ConnectedUsersManager') const ConnectedUsersManager = require('./ConnectedUsersManager')
const WebsocketLoadBalancer = require('./WebsocketLoadBalancer') const WebsocketLoadBalancer = require('./WebsocketLoadBalancer')
const RoomManager = require('./RoomManager') const RoomManager = require('./RoomManager')
const {
JoinLeaveEpochMismatchError,
NotAuthorizedError,
NotJoinedError
} = require('./Errors')
let WebsocketController let WebsocketController
module.exports = WebsocketController = { module.exports = WebsocketController = {
@ -48,12 +53,7 @@ module.exports = WebsocketController = {
} }
if (!privilegeLevel) { if (!privilegeLevel) {
const err = new Error('not authorized') return callback(new NotAuthorizedError())
logger.warn(
{ err, project_id, user_id, client_id: client.id },
'user is not authorized to join project'
)
return callback(err)
} }
client.ol_context = {} client.ol_context = {}
@ -162,7 +162,7 @@ module.exports = WebsocketController = {
metrics.inc('editor.join-doc') metrics.inc('editor.join-doc')
const { project_id, user_id, is_restricted_user } = client.ol_context const { project_id, user_id, is_restricted_user } = client.ol_context
if (!project_id) { if (!project_id) {
return callback(new Error('no project_id found on client')) return callback(new NotJoinedError())
} }
logger.log( logger.log(
{ user_id, project_id, doc_id, fromVersion, client_id: client.id }, { user_id, project_id, doc_id, fromVersion, client_id: client.id },
@ -184,7 +184,7 @@ module.exports = WebsocketController = {
} }
if (joinLeaveEpoch !== client.joinLeaveEpoch) { if (joinLeaveEpoch !== client.joinLeaveEpoch) {
// another joinDoc or leaveDoc rpc overtook us // another joinDoc or leaveDoc rpc overtook us
return callback(new Error('joinLeaveEpoch mismatch')) return callback(new JoinLeaveEpochMismatchError())
} }
// ensure the per-doc applied-ops channel is subscribed before sending the // ensure the per-doc applied-ops channel is subscribed before sending the
// doc to the client, so that no events are missed. // doc to the client, so that no events are missed.
@ -428,7 +428,7 @@ module.exports = WebsocketController = {
return callback(null, []) return callback(null, [])
} }
if (!project_id) { if (!project_id) {
return callback(new Error('no project_id found on client')) return callback(new NotJoinedError())
} }
logger.log( logger.log(
{ user_id, project_id, client_id: client.id }, { user_id, project_id, client_id: client.id },
@ -463,7 +463,7 @@ module.exports = WebsocketController = {
// client may have disconnected, but we can submit their update to doc-updater anyways. // client may have disconnected, but we can submit their update to doc-updater anyways.
const { user_id, project_id } = client.ol_context const { user_id, project_id } = client.ol_context
if (!project_id) { if (!project_id) {
return callback(new Error('no project_id found on client')) return callback(new NotJoinedError())
} }
WebsocketController._assertClientCanApplyUpdate( WebsocketController._assertClientCanApplyUpdate(
@ -509,7 +509,7 @@ module.exports = WebsocketController = {
function (error) { function (error) {
if ((error && error.message) === 'update is too large') { if ((error && error.message) === 'update is too large') {
metrics.inc('update_too_large') metrics.inc('update_too_large')
const { updateSize } = error const { updateSize } = error.info
logger.warn( logger.warn(
{ user_id, project_id, doc_id, updateSize }, { user_id, project_id, doc_id, updateSize },
'update is too large' 'update is too large'

View file

@ -19,6 +19,7 @@
"format:fix": "node_modules/.bin/prettier-eslint $PWD'/**/*.js' --write" "format:fix": "node_modules/.bin/prettier-eslint $PWD'/**/*.js' --write"
}, },
"dependencies": { "dependencies": {
"@overleaf/o-error": "^3.0.0",
"async": "^0.9.0", "async": "^0.9.0",
"base64id": "0.1.0", "base64id": "0.1.0",
"basic-auth-connect": "^1.0.0", "basic-auth-connect": "^1.0.0",

View file

@ -136,14 +136,16 @@ describe('DocumentUpdaterManager', function () {
return it('should return the callback with an error', function () { return it('should return the callback with an error', function () {
this.callback.called.should.equal(true) this.callback.called.should.equal(true)
const err = this.callback.getCall(0).args[0] this.callback
err.should.have.property('statusCode', statusCode) .calledWith(
err.should.have.property( sinon.match({
'message', message: 'doc updater could not load requested ops',
'doc updater could not load requested ops' info: { statusCode }
) })
)
.should.equal(true)
this.logger.error.called.should.equal(false) this.logger.error.called.should.equal(false)
return this.logger.warn.called.should.equal(true) this.logger.warn.called.should.equal(false)
}) })
}) })
) )
@ -163,13 +165,18 @@ describe('DocumentUpdaterManager', function () {
return it('should return the callback with an error', function () { return it('should return the callback with an error', function () {
this.callback.called.should.equal(true) this.callback.called.should.equal(true)
const err = this.callback.getCall(0).args[0] this.callback
err.should.have.property('statusCode', 500) .calledWith(
err.should.have.property( sinon.match({
'message', message: 'doc updater returned a non-success status code',
'doc updater returned a non-success status code: 500' info: {
) action: 'getDocument',
return this.logger.error.called.should.equal(true) statusCode: 500
}
})
)
.should.equal(true)
this.logger.error.called.should.equal(false)
}) })
}) })
}) })
@ -234,12 +241,17 @@ describe('DocumentUpdaterManager', function () {
return it('should return the callback with an error', function () { return it('should return the callback with an error', function () {
this.callback.called.should.equal(true) this.callback.called.should.equal(true)
const err = this.callback.getCall(0).args[0] this.callback
err.should.have.property('statusCode', 500) .calledWith(
return err.should.have.property( sinon.match({
'message', message: 'doc updater returned a non-success status code',
'document updater returned a failure status code: 500' info: {
) action: 'flushProjectToMongoAndDelete',
statusCode: 500
}
})
)
.should.equal(true)
}) })
}) })
}) })
@ -346,7 +358,7 @@ describe('DocumentUpdaterManager', function () {
}) })
it('should add the size to the error', function () { it('should add the size to the error', function () {
return this.callback.args[0][0].updateSize.should.equal(7782422) return this.callback.args[0][0].info.updateSize.should.equal(7782422)
}) })
return it('should not push the change onto the pending-updates-list queue', function () { return it('should not push the change onto the pending-updates-list queue', function () {

View file

@ -50,7 +50,7 @@ describe('SafeJsonParse', function () {
const data = `{\"foo\": \"${big_blob}\"}` const data = `{\"foo\": \"${big_blob}\"}`
this.Settings.maxUpdateSize = 2 * 1024 this.Settings.maxUpdateSize = 2 * 1024
return this.SafeJsonParse.parse(data, (error, parsed) => { return this.SafeJsonParse.parse(data, (error, parsed) => {
this.logger.error.called.should.equal(true) this.logger.error.called.should.equal(false)
expect(error).to.exist expect(error).to.exist
return done() return done()
}) })

View file

@ -106,7 +106,10 @@ describe('WebApiManager', function () {
return it('should call the callback with an error', function () { return it('should call the callback with an error', function () {
return this.callback return this.callback
.calledWith( .calledWith(
sinon.match({ message: 'non-success status code from web: 500' }) sinon.match({
message: 'non-success status code from web',
info: { statusCode: 500 }
})
) )
.should.equal(true) .should.equal(true)
}) })
@ -152,7 +155,9 @@ describe('WebApiManager', function () {
.calledWith( .calledWith(
sinon.match({ sinon.match({
message: 'rate-limit hit when joining project', message: 'rate-limit hit when joining project',
code: 'TooManyRequests' info: {
code: 'TooManyRequests'
}
}) })
) )
.should.equal(true) .should.equal(true)

View file

@ -19,6 +19,7 @@ const { expect } = chai
const modulePath = '../../../app/js/WebsocketController.js' const modulePath = '../../../app/js/WebsocketController.js'
const SandboxedModule = require('sandboxed-module') const SandboxedModule = require('sandboxed-module')
const tk = require('timekeeper') const tk = require('timekeeper')
const { UpdateTooLargeError } = require('../../../app/js/Errors')
describe('WebsocketController', function () { describe('WebsocketController', function () {
beforeEach(function () { beforeEach(function () {
@ -1507,8 +1508,7 @@ describe('WebsocketController', function () {
this.client.emit = sinon.stub() this.client.emit = sinon.stub()
this.client.ol_context.user_id = this.user_id this.client.ol_context.user_id = this.user_id
this.client.ol_context.project_id = this.project_id this.client.ol_context.project_id = this.project_id
const error = new Error('update is too large') const error = new UpdateTooLargeError(7372835)
error.updateSize = 7372835
this.DocumentUpdaterManager.queueChange = sinon this.DocumentUpdaterManager.queueChange = sinon
.stub() .stub()
.callsArgWith(3, error) .callsArgWith(3, error)