diff --git a/packages/cli-repl/test/e2e-direct.spec.ts b/packages/cli-repl/test/e2e-direct.spec.ts index d9dbaec5f8..bc0af4f6ae 100644 --- a/packages/cli-repl/test/e2e-direct.spec.ts +++ b/packages/cli-repl/test/e2e-direct.spec.ts @@ -94,7 +94,7 @@ describe('e2e direct connection', () => { await shell.waitForPrompt(); await shell.executeLine('use admin'); await shell.executeLine('db.runCommand({ listCollections: 1 })'); - shell.assertContainsError('MongoError: not master'); + shell.assertContainsError('MongoError: not primary'); }); it('lists collections when readPreference is in the connection string', async() => { diff --git a/packages/shell-api/src/decorators.ts b/packages/shell-api/src/decorators.ts index 9751de5c59..39ed6115c5 100644 --- a/packages/shell-api/src/decorators.ts +++ b/packages/shell-api/src/decorators.ts @@ -12,6 +12,7 @@ import { import Help from './help'; import { addHiddenDataProperty } from './helpers'; import { checkInterrupted } from './interruptor'; +import { rephraseMongoError } from './mongo-errors'; const addSourceToResultsSymbol = Symbol.for('@@mongosh.addSourceToResults'); const resultSource = Symbol.for('@@mongosh.resultSource'); @@ -161,17 +162,27 @@ function wrapWithApiChecks any>(fn: T, className: s const internalState = getShellInternalState(this); checkForDeprecation(internalState, className, fn); const interrupted = checkInterrupted(internalState); - const result = await Promise.race([ - interrupted ? interrupted.asPromise() : new Promise(() => {}), - fn.call(this, ...args) - ]); + let result: any; + try { + result = await Promise.race([ + interrupted ? interrupted.asPromise() : new Promise(() => {}), + fn.call(this, ...args) + ]); + } catch (e) { + throw rephraseMongoError(e); + } checkInterrupted(internalState); return result; }) : function(this: any, ...args: any[]): any { const internalState = getShellInternalState(this); checkForDeprecation(internalState, className, fn); checkInterrupted(internalState); - const result = fn.call(this, ...args); + let result: any; + try { + result = fn.call(this, ...args); + } catch (e) { + throw rephraseMongoError(e); + } checkInterrupted(internalState); return result; }; diff --git a/packages/shell-api/src/mongo-errors.spec.ts b/packages/shell-api/src/mongo-errors.spec.ts new file mode 100644 index 0000000000..3cb809dc01 --- /dev/null +++ b/packages/shell-api/src/mongo-errors.spec.ts @@ -0,0 +1,104 @@ +import { MongoError } from 'mongodb'; +import { expect } from 'chai'; +import { rephraseMongoError } from './mongo-errors'; +import Mongo from './mongo'; +import { StubbedInstance, stubInterface } from 'ts-sinon'; +import { bson, ServiceProvider } from '@mongosh/service-provider-core'; +import Database from './database'; +import { EventEmitter } from 'events'; +import ShellInternalState from './shell-internal-state'; +import Collection from './collection'; + +class MongoshInternalError extends Error { + constructor(message: string) { + super(message); + this.name = 'MongoshInternalError'; + } +} + +describe('mongo-errors', () => { + describe('rephraseMongoError', () => { + context('for primitive "errors"', () => { + [ + true, + 42, + 'a message', + { some: 'object' } + ].forEach(e => { + it(`skips ${JSON.stringify(e)}`, () => { + expect(rephraseMongoError(e)).to.equal(e); + }); + }); + }); + + context('for non-MongoError errors', () => { + [ + new Error('an error'), + Object.assign(new MongoshInternalError('Dummy error'), { code: 13435 }) + ].forEach(e => { + it(`ignores ${e.constructor.name} ${JSON.stringify(e)}`, () => { + const origMessage = e.message; + const r = rephraseMongoError(e); + expect(r).to.equal(r); + expect(r.message).to.equal(origMessage); + }); + }); + }); + + context('for MongoError errors', () => { + it('ignores an irrelevant error', () => { + const e = new MongoError('ignored'); + const r = rephraseMongoError(e); + expect(r).to.equal(e); + expect(r.message).to.equal('ignored'); + }); + + it('rephrases a NotPrimaryNoSecondaryOk error', () => { + const e = new MongoError('not master and slaveOk=false'); + e.code = 13435; + const r = rephraseMongoError(e); + expect(r).to.equal(e); + expect(r.code).to.equal(13435); + expect(r.message).to.contain('setReadPref'); + }); + }); + }); + + describe('intercepts shell API calls', () => { + let mongo: Mongo; + let serviceProvider: StubbedInstance; + let database: Database; + let bus: StubbedInstance; + let internalState: ShellInternalState; + let collection: Collection; + + beforeEach(() => { + bus = stubInterface(); + serviceProvider = stubInterface(); + serviceProvider.runCommand.resolves({ ok: 1 }); + serviceProvider.runCommandWithCheck.resolves({ ok: 1 }); + serviceProvider.initialDb = 'test'; + serviceProvider.bsonLibrary = bson; + internalState = new ShellInternalState(serviceProvider, bus); + mongo = new Mongo(internalState, undefined, undefined, undefined, serviceProvider); + database = new Database(mongo, 'db1'); + collection = new Collection(mongo, database, 'coll1'); + }); + + it('on collection.find error', async() => { + const error = new MongoError('not master and slaveOk=false'); + error.code = 13435; + serviceProvider.insertOne.rejects(error); + + try { + await collection.insertOne({ fails: true }); + expect.fail('expected error'); + } catch (e) { + expect(e).to.equal(error); + expect(e.message).to.contain('not primary and secondaryOk=false'); + expect(e.message).to.contain('db.getMongo().setReadPref()'); + expect(e.message).to.contain('readPref'); + } + }); + }); +}); diff --git a/packages/shell-api/src/mongo-errors.ts b/packages/shell-api/src/mongo-errors.ts new file mode 100644 index 0000000000..58a71f349e --- /dev/null +++ b/packages/shell-api/src/mongo-errors.ts @@ -0,0 +1,39 @@ + +interface MongoErrorRephrase { + matchMessage?: RegExp | string; + code?: number; + replacement: ((message: string) => string) | string; +} +const ERROR_REPHRASES: MongoErrorRephrase[] = [ + { + // NotPrimaryNoSecondaryOk (also used for old terminology) + code: 13435, + replacement: 'not primary and secondaryOk=false - consider using db.getMongo().setReadPref() or readPref in the connection string' + } +]; + +export function rephraseMongoError(error: any): any { + if (!isMongoError(error)) { + return error; + } + + const e = error as Error; + const message = e.message; + + const rephrase = ERROR_REPHRASES.find(m => { + if (m.matchMessage) { + return typeof m.matchMessage === 'string' ? message.includes(m.matchMessage) : m.matchMessage.test(message); + } + return m.code !== undefined && (e as any).code === m.code; + }); + + if (rephrase) { + e.message = typeof rephrase.replacement === 'function' ? rephrase.replacement(message) : rephrase.replacement; + } + + return e; +} + +function isMongoError(error: any): boolean { + return /^Mongo([A-Z].*)?Error$/.test(Object.getPrototypeOf(error)?.constructor?.name ?? ''); +}