diff --git a/package-lock.json b/package-lock.json index 03fc0c25d0f..61ea81c86c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42937,6 +42937,7 @@ "compass-preferences-model": "^2.32.1", "hadron-app-registry": "^9.3.1", "lodash": "^4.17.21", + "mongodb": "^6.12.0", "mongodb-build-info": "^1.7.2", "mongodb-connection-string-url": "^3.0.1", "mongodb-data-service": "^22.24.1", @@ -55289,6 +55290,7 @@ "hadron-app-registry": "^9.3.1", "lodash": "^4.17.21", "mocha": "^10.2.0", + "mongodb": "^6.12.0", "mongodb-build-info": "^1.7.2", "mongodb-connection-string-url": "^3.0.1", "mongodb-data-service": "^22.24.1", diff --git a/packages/compass-connections/package.json b/packages/compass-connections/package.json index 86a0018beb9..30e5d294d8d 100644 --- a/packages/compass-connections/package.json +++ b/packages/compass-connections/package.json @@ -62,6 +62,7 @@ "compass-preferences-model": "^2.32.1", "hadron-app-registry": "^9.3.1", "lodash": "^4.17.21", + "mongodb": "^6.12.0", "mongodb-build-info": "^1.7.2", "mongodb-connection-string-url": "^3.0.1", "mongodb-data-service": "^22.24.1", diff --git a/packages/compass-connections/src/stores/connections-store-redux.spec.tsx b/packages/compass-connections/src/stores/connections-store-redux.spec.tsx index a238f5c7ff6..e54b48226b3 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.spec.tsx +++ b/packages/compass-connections/src/stores/connections-store-redux.spec.tsx @@ -10,6 +10,8 @@ import { } from '@mongodb-js/testing-library-compass'; import React from 'react'; import { InMemoryConnectionStorage } from '@mongodb-js/connection-storage/provider'; +import { getDataServiceForConnection } from './connections-store-redux'; +import { type ConnectionInfo } from '@mongodb-js/connection-info'; const mockConnections = [ { @@ -34,6 +36,13 @@ const mockConnections = [ }, ]; +const connectionInfoWithAtlasMetadata = { + ...createDefaultConnectionInfo(), + atlasMetadata: { + clusterName: 'pineapple', + } as ConnectionInfo['atlasMetadata'], +}; + function renderCompassConnections(opts?: RenderConnectionsOptions) { return render(
@@ -274,6 +283,101 @@ describe('CompassConnections store', function () { await connectionStorage.load({ id: mockConnections[0].id }) ).to.have.nested.property('favorite.name', 'turtles'); }); + + it('should ignore server heartbeat failed events that are not non-retryable error codes', async function () { + const { connectionsStore } = renderCompassConnections({ + connectFn: async () => { + await wait(1); + return {}; + }, + }); + + // Wait till we're connected. + await connectionsStore.actions.connect(connectionInfoWithAtlasMetadata); + + const connections = connectionsStore.getState().connections; + expect(connections.ids).to.have.lengthOf(1); + + const dataService = getDataServiceForConnection( + connectionInfoWithAtlasMetadata.id + ); + + let didDisconnect = false; + let didCheckForConnected = false; + sinon.stub(dataService, 'disconnect').callsFake(async () => { + didDisconnect = true; + return Promise.resolve(); + }); + dataService.isConnected = () => { + // If this is called we know the error wasn't handled properly. + didCheckForConnected = true; + return true; + }; + + let didReceiveCallToHeartbeatFailedListener = false; + dataService.on('serverHeartbeatFailed', () => { + didReceiveCallToHeartbeatFailedListener = true; + }); + + // Send a heartbeat fail with an error that's not a non-retryable error code. + dataService['emit']('serverHeartbeatFailed', { + failure: new Error('code: 1234, Not the error we are looking for'), + }); + + // Wait for the listener to handle the message. + await waitFor(() => { + expect(didReceiveCallToHeartbeatFailedListener).to.be.true; + }); + await wait(1); + + expect(didDisconnect).to.be.false; + expect(didCheckForConnected).to.be.false; + }); + + it('should listen for non-retryable errors on server heartbeat failed events and disconnect the data service when encountered', async function () { + const { connectionsStore } = renderCompassConnections({ + connectFn: async () => { + await wait(1); + return {}; + }, + }); + + // Wait till we're connected. + await connectionsStore.actions.connect(connectionInfoWithAtlasMetadata); + + const connections = connectionsStore.getState().connections; + expect(connections.ids).to.have.lengthOf(1); + + const dataService = getDataServiceForConnection( + connectionInfoWithAtlasMetadata.id + ); + + let didDisconnect = false; + sinon.stub(dataService, 'disconnect').callsFake(async () => { + didDisconnect = true; + return Promise.resolve(); + }); + dataService.isConnected = () => true; + + // Send a heartbeat fail with an error that's a non-retryable error code. + dataService['emit']('serverHeartbeatFailed', { + failure: new Error('code: 3003, reason: Insufficient permissions'), + }); + + await waitFor(() => { + expect(didDisconnect).to.be.true; + }); + + await waitFor(function () { + const titleNode = screen.getByText('Unable to connect to pineapple'); + expect(titleNode).to.be.visible; + + const descriptionNode = screen.getByText( + 'Reason: Insufficient permissions. To use continue to use this connection either disconnect and reconnect, or refresh your page.' + ); + expect(descriptionNode).to.be.visible; + }); + }); }); describe('#saveAndConnect', function () { diff --git a/packages/compass-connections/src/stores/connections-store-redux.ts b/packages/compass-connections/src/stores/connections-store-redux.ts index 057c3a2762f..7626587fba7 100644 --- a/packages/compass-connections/src/stores/connections-store-redux.ts +++ b/packages/compass-connections/src/stores/connections-store-redux.ts @@ -3,6 +3,7 @@ import type { Reducer, AnyAction, Action } from 'redux'; import { createStore, applyMiddleware } from 'redux'; import type { ThunkAction } from 'redux-thunk'; import thunk from 'redux-thunk'; +import type { ServerHeartbeatFailedEvent } from 'mongodb'; import { getConnectionTitle, type ConnectionInfo, @@ -1465,6 +1466,57 @@ function isAtlasStreamsInstance( } } +// We listen for non-retry-able errors on failed server heartbeats. +// These can happen on compass web when: +// - A user's session has ended. +// - The user's roles have changed. +// - The cluster / group they are trying to connect to has since been deleted. +// When we encounter one we disconnect. This is to avoid polluting logs/metrics +// and to avoid constantly retrying to connect when we know it'll fail. +// These error codes can be found at +// https://github.com/10gen/mms/blob/de2a9c463cfe530efb8e2a0941033e8207b6cb11/server/src/main/com/xgen/cloud/services/clusterconnection/runtime/res/CustomCloseCodes.java +const NonRetryableErrorCodes = [3000, 3003, 4004, 1008] as const; +const NonRetryableErrorDescriptionFallbacks: { + [code in typeof NonRetryableErrorCodes[number]]: string; +} = { + 3000: 'Unauthorized', + 3003: 'Forbidden', + 4004: 'Not Found', + 1008: 'Violated policy', +}; + +function isNonRetryableHeartbeatFailure(evt: ServerHeartbeatFailedEvent) { + return NonRetryableErrorCodes.some((code) => + evt.failure.message.includes(`code: ${code},`) + ); +} + +function getDescriptionForNonRetryableError(error: Error): string { + // Give a description from the error message when provided, otherwise fallback + // to the generic error description. + const reason = error.message.match(/code: \d+, reason: (.*)$/)?.[1]; + return reason && reason.length > 0 + ? reason + : NonRetryableErrorDescriptionFallbacks[ + Number( + error.message.match(/code: (\d+),/)?.[1] + ) as typeof NonRetryableErrorCodes[number] + ] ?? 'Unknown'; +} + +const openConnectionClosedWithNonRetryableErrorToast = ( + connectionInfo: ConnectionInfo, + error: Error +) => { + openToast(`non-retryable-error-encountered--${connectionInfo.id}`, { + title: `Unable to connect to ${getConnectionTitle(connectionInfo)}`, + description: `Reason: ${getDescriptionForNonRetryableError( + error + )}. To use continue to use this connection either disconnect and reconnect, or refresh your page.`, + variant: 'warning', + }); +}; + export const connect = ( connectionInfo: ConnectionInfo ): ConnectionsThunkAction< @@ -1659,6 +1711,34 @@ const connectWithOptions = ( return; } + let showedNonRetryableErrorToast = false; + // Listen for non-retry-able errors on failed server heartbeats. + // These can happen on compass web when: + // - A user's session has ended. + // - The user's roles have changed. + // - The cluster / group they are trying to connect to has since been deleted. + // When we encounter one we disconnect. This is to avoid polluting logs/metrics + // and to avoid constantly retrying to connect when we know it'll fail. + dataService.on( + 'serverHeartbeatFailed', + (evt: ServerHeartbeatFailedEvent) => { + if (!isNonRetryableHeartbeatFailure(evt)) { + return; + } + + if (!dataService.isConnected() || showedNonRetryableErrorToast) { + return; + } + + openConnectionClosedWithNonRetryableErrorToast( + connectionInfo, + evt.failure + ); + showedNonRetryableErrorToast = true; + void dataService.disconnect(); + } + ); + dataService.on('oidcAuthFailed', (error) => { openToast('oidc-auth-failed', { title: `Failed to authenticate for ${getConnectionTitle( diff --git a/packages/data-service/src/data-service.ts b/packages/data-service/src/data-service.ts index 761f60809f4..195046b6776 100644 --- a/packages/data-service/src/data-service.ts +++ b/packages/data-service/src/data-service.ts @@ -140,6 +140,7 @@ export type ExplainExecuteOptions = ExecutionOptions & { export interface DataServiceEventMap { topologyDescriptionChanged: (evt: TopologyDescriptionChangedEvent) => void; + serverHeartbeatFailed: (evt: ServerHeartbeatFailedEvent) => void; connectionInfoSecretsChanged: () => void; close: () => void; oidcAuthFailed: (error: string) => void; @@ -2414,6 +2415,7 @@ class DataServiceImpl extends WithLogContext implements DataService { } ); } + this._emitter.emit('serverHeartbeatFailed', evt); }); client.on('commandSucceeded', (evt: CommandSucceededEvent) => {