From 7c0317f356d11df81638489d1a5a6c1065f464ae Mon Sep 17 00:00:00 2001 From: Neal Beeken Date: Mon, 24 Nov 2025 13:55:07 -0500 Subject: [PATCH] feat(devtools-connect): fail fast on specific error and codes from compass-web COMPASS-9793 --- packages/devtools-connect/src/connect.spec.ts | 37 ++++++++++ packages/devtools-connect/src/connect.ts | 16 ++++- .../src/fast-failure-connect.spec.ts | 67 +++++++++++++++++++ .../src/fast-failure-connect.ts | 41 ++++++++++-- packages/devtools-connect/tsconfig.json | 2 +- 5 files changed, 153 insertions(+), 10 deletions(-) diff --git a/packages/devtools-connect/src/connect.spec.ts b/packages/devtools-connect/src/connect.spec.ts index 7af6a2d8..38e85ba5 100644 --- a/packages/devtools-connect/src/connect.spec.ts +++ b/packages/devtools-connect/src/connect.spec.ts @@ -375,6 +375,43 @@ describe('devtools connect', function () { expect(result.client).to.equal(mClient); }); + it('allows useSystemCA to be configured to false', async function () { + const uri = 'localhost:27017'; + const mClient = stubConstructor(FakeMongoClient); + const mClientType = sinon.stub().returns(mClient); + mClient.connect.onFirstCall().resolves(mClient); + const result = await connectMongoClient( + uri, + { ...defaultOpts, useSystemCA: false }, + bus, + mClientType as any, + ); + expect(mClientType.getCalls()).to.have.lengthOf(1); + expect(mClientType.getCalls()[0].args[1].ca).to.equal(undefined); + expect(mClient.connect.getCalls()).to.have.lengthOf(1); + expect(result.client).to.equal(mClient); + }); + + it('allows useSystemCA to be configured to true', async function () { + const uri = 'localhost:27017'; + const mClient = stubConstructor(FakeMongoClient); + const mClientType = sinon.stub().returns(mClient); + mClient.connect.onFirstCall().resolves(mClient); + const result = await connectMongoClient( + uri, + { ...defaultOpts, useSystemCA: true }, + bus, + mClientType as any, + ); + expect(mClientType.getCalls()).to.have.lengthOf(1); + expect(mClientType.getCalls()[0].args[1].ca).to.be.a('string'); + expect(mClientType.getCalls()[0].args[1].ca).to.include( + '-----BEGIN CERTIFICATE-----', + ); + expect(mClient.connect.getCalls()).to.have.lengthOf(1); + expect(result.client).to.equal(mClient); + }); + describe('retryable TLS errors', function () { it('retries TLS errors without system CA integration enabled -- MongoClient error', async function () { const uri = 'localhost:27017'; diff --git a/packages/devtools-connect/src/connect.ts b/packages/devtools-connect/src/connect.ts index 085f32a6..533dfd9a 100644 --- a/packages/devtools-connect/src/connect.ts +++ b/packages/devtools-connect/src/connect.ts @@ -406,6 +406,10 @@ export interface DevtoolsConnectOptions extends MongoClientOptions { * provided in `proxy` for OIDC traffic. */ applyProxyToOIDC?: boolean | DevtoolsProxyOptions | AgentWithInitialize; + /** + * Whether to use the system certificate store. Defaults to `true`. + */ + useSystemCA?: boolean; } export type ConnectMongoClientResult = { @@ -426,7 +430,15 @@ export async function connectMongoClient( ): Promise { detectAndLogMissingOptionalDependencies(logger); - const options = { uri, clientOptions, logger, MongoClientClass }; + const options = { + uri, + clientOptions, + logger, + MongoClientClass, + useSystemCA: clientOptions.useSystemCA ?? true, + }; + delete clientOptions.useSystemCA; + // Connect once with the system certificate store added, and if that fails with // a TLS error, try again. In theory adding certificates into the certificate store // should not cause failures, but in practice we have observed some, hence this @@ -435,7 +447,7 @@ export async function connectMongoClient( // failure situations) we do not spend an unreasonable amount of time in the first // connection attempt. try { - return await connectMongoClientImpl({ ...options, useSystemCA: true }); + return await connectMongoClientImpl({ ...options }); } catch (error: unknown) { if (isPotentialTLSCertificateError(error)) { logger.emit('devtools-connect:retry-after-tls-error', { diff --git a/packages/devtools-connect/src/fast-failure-connect.spec.ts b/packages/devtools-connect/src/fast-failure-connect.spec.ts index 2e20eb41..212098aa 100644 --- a/packages/devtools-connect/src/fast-failure-connect.spec.ts +++ b/packages/devtools-connect/src/fast-failure-connect.spec.ts @@ -46,4 +46,71 @@ describe('isFastFailureConnectionError', function () { isFastFailureConnectionError(new Error('could not connect')), ).to.equal(false); }); + + describe('isCompassSocketServiceError', function () { + class CompassSocketServiceError extends Error { + constructor( + msg: string, + public code: number, + ) { + super(msg); + this.name = 'CompassSocketServiceError'; + } + } + + it('returns true for UNAUTHORIZED (3000)', function () { + const error = new CompassSocketServiceError('Unauthorized', 3000); + expect(isFastFailureConnectionError(error)).to.equal(true); + }); + + it('returns true for FORBIDDEN (3003)', function () { + const error = new CompassSocketServiceError('Forbidden', 3003); + expect(isFastFailureConnectionError(error)).to.equal(true); + }); + + it('returns true for NOT_FOUND (4004)', function () { + const error = new CompassSocketServiceError('Not found', 4004); + expect(isFastFailureConnectionError(error)).to.equal(true); + }); + + it('returns true for VIOLATED_POLICY (1008)', function () { + const error = new CompassSocketServiceError('Violated policy', 1008); + expect(isFastFailureConnectionError(error)).to.equal(true); + }); + + it('returns true for DO_NOT_TRY_AGAIN (4101)', function () { + const error = new CompassSocketServiceError('Do not try again', 4101); + expect(isFastFailureConnectionError(error)).to.equal(true); + }); + + it('returns false for CompassSocketServiceError with non-fail-fast code', function () { + const error = new CompassSocketServiceError('Some other error', 9999); + expect(isFastFailureConnectionError(error)).to.equal(false); + }); + + it('returns true when CompassSocketServiceError is the cause of MongoNetworkError', function () { + const cause = new CompassSocketServiceError('Unauthorized', 3000); + const error = new MongoNetworkError('Connection failed'); + (error as any).cause = cause; + expect(isFastFailureConnectionError(error)).to.equal(true); + }); + + it('returns true when CompassSocketServiceError is nested deeply', function () { + const cause = new CompassSocketServiceError('Forbidden', 3003); + const wrappedError = new Error('Wrapped error'); + (wrappedError as any).cause = cause; + const error = new MongoNetworkError('Connection failed'); + (error as any).cause = wrappedError; + expect(isFastFailureConnectionError(error)).to.equal(true); + }); + + it('returns true when CompassSocketServiceError is in an AggregateError', function () { + const cause = new CompassSocketServiceError('Not found', 4004); + const aggregateError = new AggregateError( + [new Error('Other error'), cause], + 'Multiple errors', + ); + expect(isFastFailureConnectionError(aggregateError)).to.equal(true); + }); + }); }); diff --git a/packages/devtools-connect/src/fast-failure-connect.ts b/packages/devtools-connect/src/fast-failure-connect.ts index e937af3b..dc792fb3 100644 --- a/packages/devtools-connect/src/fast-failure-connect.ts +++ b/packages/devtools-connect/src/fast-failure-connect.ts @@ -1,21 +1,48 @@ // It probably makes sense to put this into its own package/repository once // other tools start using it. +const NODE_SOCKET_NON_RETRY_CODES = [ + 'ECONNREFUSED', + 'ENOTFOUND', + 'ENETUNREACH', + 'EINVAL', +]; +const COMPASS_SOCKET_SERVICE_NON_RETRY_CODES = [ + 3000, // UNAUTHORIZED + 3003, // FORBIDDEN + 4004, // NOT_FOUND + 1008, // VIOLATED_POLICY + 4101, // DO_NOT_TRY_AGAIN +]; + +const isCompassSocketServiceError = handleNestedErrors( + (error: Error & { code?: string | number }): boolean => { + if (error.name === 'CompassSocketServiceError') { + return ( + typeof error.code === 'number' && + COMPASS_SOCKET_SERVICE_NON_RETRY_CODES.includes(error.code) + ); + } + return false; + }, +); + function isFastFailureConnectionSingleError( - error: Error & { code?: string }, + error: Error & { code?: string | number }, ): boolean { switch (error.name) { case 'MongoNetworkError': - return /\b(ECONNREFUSED|ENOTFOUND|ENETUNREACH|EINVAL)\b/.test( - error.message, - ); + return new RegExp( + String.raw`\b(${NODE_SOCKET_NON_RETRY_CODES.join('|')})\b`, + ).test(error.message); case 'MongoError': return /The apiVersion parameter is required/.test(error.message); default: return ( - ['ECONNREFUSED', 'ENOTFOUND', 'ENETUNREACH', 'EINVAL'].includes( - error.code ?? '', - ) || isPotentialTLSCertificateError(error) + (typeof error.code === 'string' && + NODE_SOCKET_NON_RETRY_CODES.includes(error.code)) || + isPotentialTLSCertificateError(error) || + isCompassSocketServiceError(error) ); } } diff --git a/packages/devtools-connect/tsconfig.json b/packages/devtools-connect/tsconfig.json index 415b3a29..d52fffc9 100644 --- a/packages/devtools-connect/tsconfig.json +++ b/packages/devtools-connect/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@mongodb-js/tsconfig-devtools/tsconfig.common.json", "compilerOptions": { "target": "es2020", - "lib": ["es2020", "DOM"], + "lib": ["es2021", "DOM"], "module": "commonjs", "moduleResolution": "node" }