Skip to content

Commit 0ede48a

Browse files
authored
chore(service-provider-server): fail fast for doomed connect MONGOSH-705 (#801)
This was brought up as a nice way of erroring early in the driver sync meeting today.
1 parent 9a10a62 commit 0ede48a

File tree

7 files changed

+171
-46
lines changed

7 files changed

+171
-46
lines changed

packages/cli-repl/test/e2e.spec.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,5 +880,23 @@ describe('e2e', function() {
880880
});
881881
});
882882
});
883+
884+
describe('fail-fast connections', () => {
885+
it('fails fast for ENOTFOUND errors', async() => {
886+
const shell = TestShell.start({ args: [
887+
'mongodb://' + 'verymuchnonexistentdomainname'.repeat(10) + '.mongodb.net/'
888+
] });
889+
const result = await shell.waitForPromptOrExit();
890+
expect(result).to.deep.equal({ state: 'exit', exitCode: 1 });
891+
});
892+
893+
it('fails fast for ECONNREFUSED errors', async() => {
894+
const shell = TestShell.start({ args: [
895+
'--port', '0'
896+
] });
897+
const result = await shell.waitForPromptOrExit();
898+
expect(result).to.deep.equal({ state: 'exit', exitCode: 1 });
899+
});
900+
});
883901
});
884902

packages/cli-repl/test/test-shell.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import path from 'path';
66
import stripAnsi from 'strip-ansi';
77
import { eventually } from './helpers';
88

9-
export type TestShellStartupResult = { state: 'prompt' | 'exit'; exitCode?: number | undefined };
9+
export type TestShellStartupResult = { state: 'prompt' } | { state: 'exit'; exitCode: number };
1010
type SignalType = ChildProcess extends { kill: (signal: infer T) => any } ? T : never;
1111

1212
// Assume that prompt strings are those that end in '> ' but do not contain
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { expect } from 'chai';
2+
import { isFastFailureConnectionError } from './index';
3+
4+
class MongoNetworkError extends Error {
5+
constructor(msg: string) {
6+
super(msg);
7+
this.name = this.constructor.name;
8+
}
9+
}
10+
class MongoError extends Error {
11+
constructor(msg: string) {
12+
super(msg);
13+
this.name = this.constructor.name;
14+
}
15+
}
16+
17+
describe('isFastFailureConnectionError', function() {
18+
it('returns true for ECONNREFUSED', function() {
19+
expect(isFastFailureConnectionError(new MongoNetworkError('ECONNREFUSED'))).to.equal(true);
20+
});
21+
22+
it('returns true for ENOTFOUND', function() {
23+
expect(isFastFailureConnectionError(new MongoNetworkError('ENOTFOUND'))).to.equal(true);
24+
});
25+
26+
it('returns true for ENETUNREACH', function() {
27+
expect(isFastFailureConnectionError(new MongoNetworkError('ENETUNREACH'))).to.equal(true);
28+
});
29+
30+
it('returns true when an API version is reuqired', function() {
31+
expect(isFastFailureConnectionError(new MongoError('The apiVersion parameter is required'))).to.equal(true);
32+
});
33+
34+
it('returns false for generic errors', function() {
35+
expect(isFastFailureConnectionError(new Error('could not connect'))).to.equal(false);
36+
});
37+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// It probably makes sense to put this into its own package/repository once
2+
// other tools start using it.
3+
4+
export function isFastFailureConnectionError(error: Error) {
5+
switch (error.name) {
6+
case 'MongoNetworkError':
7+
return /\b(ECONNREFUSED|ENOTFOUND|ENETUNREACH)\b/.test(error.message);
8+
case 'MongoError':
9+
return /The apiVersion parameter is required/.test(error.message);
10+
default:
11+
return false;
12+
}
13+
}

packages/service-provider-core/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import ShellAuthOptions from './shell-auth-options';
2626
import { ConnectionString } from './connection-string';
2727
export * from './all-transport-types';
2828
export * from './all-fle-types';
29+
import { isFastFailureConnectionError } from './fast-failure-connect';
2930

3031
const bson = {
3132
ObjectId,
@@ -57,5 +58,6 @@ export {
5758
bson,
5859
bsonStringifiers,
5960
ConnectInfo,
60-
ConnectionString
61+
ConnectionString,
62+
isFastFailureConnectionError
6163
};

packages/service-provider-server/src/cli-service-provider.spec.ts

Lines changed: 65 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { CommonErrors } from '@mongosh/errors';
22
import chai, { expect } from 'chai';
33
import { Collection, Db, MongoClient } from 'mongodb';
44
import sinonChai from 'sinon-chai';
5-
import sinon, { StubbedInstance, stubInterface } from 'ts-sinon';
5+
import sinon, { StubbedInstance, stubInterface, stubConstructor } from 'ts-sinon';
66
import CliServiceProvider, { connectMongoClient } from './cli-service-provider';
77
import { ConnectionString } from '@mongosh/service-provider-core';
8+
import { EventEmitter } from 'events';
89

910
chai.use(sinonChai);
1011

@@ -36,48 +37,50 @@ describe('CliServiceProvider', () => {
3637
let collectionStub: StubbedInstance<Collection>;
3738

3839
describe('connectMongoClient', () => {
40+
class FakeMongoClient extends EventEmitter {
41+
connect() {}
42+
db() {}
43+
close() {}
44+
}
45+
3946
it('connects once when no AutoEncryption set', async() => {
4047
const uri = 'localhost:27017';
41-
const mClientType = stubInterface<typeof MongoClient>();
42-
const mClient = stubInterface<MongoClient>();
43-
mClientType.connect.onFirstCall().resolves(mClient);
44-
const result = await connectMongoClient(uri, {}, mClientType);
45-
const calls = mClientType.connect.getCalls();
46-
expect(calls.length).to.equal(1);
47-
expect(calls[0].args).to.deep.equal([
48-
uri, {}
49-
]);
48+
const mClient = stubConstructor(FakeMongoClient);
49+
const mClientType = sinon.stub().returns(mClient);
50+
mClient.connect.onFirstCall().resolves(mClient);
51+
const result = await connectMongoClient(uri, {}, mClientType as any);
52+
expect(mClientType.getCalls()).to.have.lengthOf(1);
53+
expect(mClientType.getCalls()[0].args).to.deep.equal([uri, {}]);
54+
expect(mClient.connect.getCalls()).to.have.lengthOf(1);
5055
expect(result).to.equal(mClient);
5156
});
5257
it('connects once when bypassAutoEncryption is true', async() => {
5358
const uri = 'localhost:27017';
5459
const opts = { autoEncryption: { bypassAutoEncryption: true } };
55-
const mClientType = stubInterface<typeof MongoClient>();
56-
const mClient = stubInterface<MongoClient>();
57-
mClientType.connect.onFirstCall().resolves(mClient);
58-
const result = await connectMongoClient(uri, opts, mClientType);
59-
const calls = mClientType.connect.getCalls();
60-
expect(calls.length).to.equal(1);
61-
expect(calls[0].args).to.deep.equal([
62-
uri, opts
63-
]);
60+
const mClient = stubConstructor(FakeMongoClient);
61+
const mClientType = sinon.stub().returns(mClient);
62+
mClient.connect.onFirstCall().resolves(mClient);
63+
const result = await connectMongoClient(uri, opts, mClientType as any);
64+
expect(mClientType.getCalls()).to.have.lengthOf(1);
65+
expect(mClientType.getCalls()[0].args).to.deep.equal([uri, opts]);
66+
expect(mClient.connect.getCalls()).to.have.lengthOf(1);
6467
expect(result).to.equal(mClient);
6568
});
6669
it('connects twice when bypassAutoEncryption is false and enterprise via modules', async() => {
6770
const uri = 'localhost:27017';
6871
const opts = { autoEncryption: { bypassAutoEncryption: false } };
69-
const mClientType = stubInterface<typeof MongoClient>();
70-
const mClientFirst = stubInterface<MongoClient>();
72+
const mClientFirst = stubConstructor(FakeMongoClient);
73+
const mClientSecond = stubConstructor(FakeMongoClient);
74+
const mClientType = sinon.stub();
7175
const commandSpy = sinon.spy();
7276
mClientFirst.db.returns({ admin: () => ({ command: (...args) => {
7377
commandSpy(...args);
7478
return { modules: [ 'enterprise' ] };
7579
} } as any) } as any);
76-
const mClientSecond = stubInterface<MongoClient>();
77-
mClientType.connect.onFirstCall().resolves(mClientFirst);
78-
mClientType.connect.onSecondCall().resolves(mClientSecond);
79-
const result = await connectMongoClient(uri, opts, mClientType);
80-
const calls = mClientType.connect.getCalls();
80+
mClientType.onFirstCall().returns(mClientFirst);
81+
mClientType.onSecondCall().returns(mClientSecond);
82+
const result = await connectMongoClient(uri, opts, mClientType as any);
83+
const calls = mClientType.getCalls();
8184
expect(calls.length).to.equal(2);
8285
expect(calls[0].args).to.deep.equal([
8386
uri, {}
@@ -88,18 +91,18 @@ describe('CliServiceProvider', () => {
8891
it('errors when bypassAutoEncryption is falsy and not enterprise', async() => {
8992
const uri = 'localhost:27017';
9093
const opts = { autoEncryption: {} };
91-
const mClientType = stubInterface<typeof MongoClient>();
92-
const mClientFirst = stubInterface<MongoClient>();
94+
const mClientFirst = stubConstructor(FakeMongoClient);
95+
const mClientSecond = stubConstructor(FakeMongoClient);
96+
const mClientType = sinon.stub();
9397
const commandSpy = sinon.spy();
9498
mClientFirst.db.returns({ admin: () => ({ command: (...args) => {
9599
commandSpy(...args);
96100
return { modules: [] };
97101
} } as any) } as any);
98-
const mClientSecond = stubInterface<MongoClient>();
99-
mClientType.connect.onFirstCall().resolves(mClientFirst);
100-
mClientType.connect.onSecondCall().resolves(mClientSecond);
102+
mClientType.onFirstCall().returns(mClientFirst);
103+
mClientType.onSecondCall().returns(mClientSecond);
101104
try {
102-
await connectMongoClient(uri, opts, mClientType);
105+
await connectMongoClient(uri, opts, mClientType as any);
103106
} catch (e) {
104107
return expect(e.message.toLowerCase()).to.include('automatic encryption');
105108
}
@@ -108,23 +111,47 @@ describe('CliServiceProvider', () => {
108111
it('errors when bypassAutoEncryption is falsy, missing modules', async() => {
109112
const uri = 'localhost:27017';
110113
const opts = { autoEncryption: {} };
111-
const mClientType = stubInterface<typeof MongoClient>();
112-
const mClientFirst = stubInterface<MongoClient>();
114+
const mClientFirst = stubConstructor(FakeMongoClient);
115+
const mClientSecond = stubConstructor(FakeMongoClient);
116+
const mClientType = sinon.stub();
113117
const commandSpy = sinon.spy();
114118
mClientFirst.db.returns({ admin: () => ({ command: (...args) => {
115119
commandSpy(...args);
116120
return {};
117121
} } as any) } as any);
118-
const mClientSecond = stubInterface<MongoClient>();
119-
mClientType.connect.onFirstCall().resolves(mClientFirst);
120-
mClientType.connect.onSecondCall().resolves(mClientSecond);
122+
mClientType.onFirstCall().returns(mClientFirst);
123+
mClientType.onSecondCall().returns(mClientSecond);
121124
try {
122-
await connectMongoClient(uri, opts, mClientType);
125+
await connectMongoClient(uri, opts, mClientType as any);
123126
} catch (e) {
124127
return expect(e.message.toLowerCase()).to.include('automatic encryption');
125128
}
126129
expect.fail('Failed to throw expected error');
127130
});
131+
132+
it('fails fast if there is a fail-fast connection error', async() => {
133+
const err = Object.assign(new Error('ENOTFOUND'), { name: 'MongoNetworkError' });
134+
const uri = 'localhost:27017';
135+
const mClient = new FakeMongoClient();
136+
const mClientType = sinon.stub().returns(mClient);
137+
let rejectConnect;
138+
mClient.close = sinon.stub().callsFake(() => {
139+
rejectConnect(new Error('discarded error'));
140+
});
141+
mClient.connect = () => new Promise((resolve, reject) => {
142+
rejectConnect = reject;
143+
setImmediate(() => {
144+
mClient.emit('serverHeartbeatFailed', { failure: err });
145+
});
146+
});
147+
try {
148+
await connectMongoClient(uri, {}, mClientType as any);
149+
} catch (e) {
150+
expect((mClient.close as any).getCalls()).to.have.lengthOf(1);
151+
return expect(e).to.equal(err);
152+
}
153+
expect.fail('Failed to throw expected error');
154+
});
128155
});
129156

130157
describe('#constructor', () => {

packages/service-provider-server/src/cli-service-provider.ts

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import {
1919
Topology,
2020
ReadPreferenceFromOptions,
2121
ReadPreferenceLike,
22-
OperationOptions
22+
OperationOptions,
23+
ServerHeartbeatFailedEvent
2324
} from 'mongodb';
2425

2526
import {
@@ -75,7 +76,8 @@ import {
7576
bson as BSON,
7677
ConnectionString,
7778
FLE,
78-
AutoEncryptionOptions
79+
AutoEncryptionOptions,
80+
isFastFailureConnectionError
7981
} from '@mongosh/service-provider-core';
8082

8183
import { MongoshCommandFailed, MongoshInternalError, MongoshRuntimeError } from '@mongosh/errors';
@@ -131,21 +133,45 @@ const DEFAULT_BASE_OPTIONS: OperationOptions = Object.freeze({
131133
serializeFunctions: true
132134
});
133135

136+
/**
137+
* Takes an unconnected MongoClient and connects it, but fails fast for certain
138+
* errors.
139+
*/
140+
async function connectWithFailFast(client: MongoClient): Promise<void> {
141+
let failFastErr;
142+
const heartbeatFailureListener = ({ failure }: ServerHeartbeatFailedEvent) => {
143+
if (isFastFailureConnectionError(failure)) {
144+
failFastErr = failure;
145+
client.close();
146+
}
147+
};
148+
149+
client.addListener('serverHeartbeatFailed', heartbeatFailureListener);
150+
try {
151+
await client.connect();
152+
} catch (err) {
153+
throw failFastErr || err;
154+
} finally {
155+
client.removeListener('serverHeartbeatFailed', heartbeatFailureListener);
156+
}
157+
}
158+
134159
/**
135160
* Connect a MongoClient. If AutoEncryption is requested, first connect without the encryption options and verify that
136161
* the connection is to an enterprise cluster. If not, then error, otherwise close the connection and reconnect with the
137162
* options the user initially specified. Provide the client class as an additional argument in order to test.
138163
* @param uri {String}
139164
* @param clientOptions {MongoClientOptions}
140-
* @param mClient {MongoClient}
165+
* @param MClient {MongoClient}
141166
*/
142-
export async function connectMongoClient(uri: string, clientOptions: MongoClientOptions, mClient = MongoClient): Promise<MongoClient> {
167+
export async function connectMongoClient(uri: string, clientOptions: MongoClientOptions, MClient = MongoClient): Promise<MongoClient> {
143168
if (clientOptions.autoEncryption !== undefined &&
144169
!clientOptions.autoEncryption.bypassAutoEncryption) {
145170
// connect first without autoEncryptionOptions
146171
const optionsWithoutFLE = { ...clientOptions };
147172
delete optionsWithoutFLE.autoEncryption;
148-
const client = await mClient.connect(uri, optionsWithoutFLE);
173+
const client = new MClient(uri, optionsWithoutFLE);
174+
await connectWithFailFast(client);
149175
const buildInfo = await client.db('admin').admin().command({ buildInfo: 1 });
150176
if (
151177
!(buildInfo.modules?.includes('enterprise')) &&
@@ -156,7 +182,9 @@ export async function connectMongoClient(uri: string, clientOptions: MongoClient
156182
}
157183
await client.close();
158184
}
159-
return mClient.connect(uri, clientOptions);
185+
const client = new MClient(uri, clientOptions);
186+
await connectWithFailFast(client);
187+
return client;
160188
}
161189

162190
/**

0 commit comments

Comments
 (0)