Skip to content

Commit c2c0cb9

Browse files
nbbeekendariakp
authored andcommitted
feat(NODE-6312): add error transformation for server timeouts (#4192)
1 parent 2ffd5eb commit c2c0cb9

File tree

3 files changed

+225
-3
lines changed

3 files changed

+225
-3
lines changed

src/cmap/connection.ts

+29
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
} from '../constants';
2222
import {
2323
MongoCompatibilityError,
24+
MONGODB_ERROR_CODES,
2425
MongoMissingDependencyError,
2526
MongoNetworkError,
2627
MongoNetworkTimeoutError,
@@ -545,6 +546,11 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
545546
}
546547

547548
if (document.ok === 0) {
549+
if (options.timeoutContext?.csotEnabled() && document.isMaxTimeExpiredError) {
550+
throw new MongoOperationTimeoutError('Server reported a timeout error', {
551+
cause: new MongoServerError((object ??= document.toObject(bsonOptions)))
552+
});
553+
}
548554
throw new MongoServerError((object ??= document.toObject(bsonOptions)));
549555
}
550556

@@ -618,6 +624,29 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
618624
): Promise<Document> {
619625
this.throwIfAborted();
620626
for await (const document of this.sendCommand(ns, command, options, responseType)) {
627+
if (options.timeoutContext?.csotEnabled()) {
628+
if (MongoDBResponse.is(document)) {
629+
// TODO(NODE-5684): test coverage to be added once cursors are enabling CSOT
630+
if (document.isMaxTimeExpiredError) {
631+
throw new MongoOperationTimeoutError('Server reported a timeout error', {
632+
cause: new MongoServerError(document.toObject())
633+
});
634+
}
635+
} else {
636+
if (
637+
(Array.isArray(document?.writeErrors) &&
638+
document.writeErrors.some(
639+
error => error?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired
640+
)) ||
641+
document?.writeConcernError?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired
642+
) {
643+
throw new MongoOperationTimeoutError('Server reported a timeout error', {
644+
cause: new MongoServerError(document)
645+
});
646+
}
647+
}
648+
}
649+
621650
return document;
622651
}
623652
throw new MongoUnexpectedServerResponseError('Unable to get response from server');

src/cmap/wire_protocol/responses.ts

+35-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
pluckBSONSerializeOptions,
1111
type Timestamp
1212
} from '../../bson';
13-
import { MongoUnexpectedServerResponseError } from '../../error';
13+
import { MONGODB_ERROR_CODES, MongoUnexpectedServerResponseError } from '../../error';
1414
import { type ClusterTime } from '../../sdam/common';
1515
import { decorateDecryptionResult, ns } from '../../utils';
1616
import {
@@ -110,6 +110,40 @@ export class MongoDBResponse extends OnDemandDocument {
110110
// {ok:1}
111111
static empty = new MongoDBResponse(new Uint8Array([13, 0, 0, 0, 16, 111, 107, 0, 1, 0, 0, 0, 0]));
112112

113+
/**
114+
* Returns true iff:
115+
* - ok is 0 and the top-level code === 50
116+
* - ok is 1 and the writeErrors array contains a code === 50
117+
* - ok is 1 and the writeConcern object contains a code === 50
118+
*/
119+
get isMaxTimeExpiredError() {
120+
// {ok: 0, code: 50 ... }
121+
const isTopLevel = this.ok === 0 && this.code === MONGODB_ERROR_CODES.MaxTimeMSExpired;
122+
if (isTopLevel) return true;
123+
124+
if (this.ok === 0) return false;
125+
126+
// {ok: 1, writeConcernError: {code: 50 ... }}
127+
const isWriteConcern =
128+
this.get('writeConcernError', BSONType.object)?.getNumber('code') ===
129+
MONGODB_ERROR_CODES.MaxTimeMSExpired;
130+
if (isWriteConcern) return true;
131+
132+
const writeErrors = this.get('writeErrors', BSONType.array);
133+
if (writeErrors?.size()) {
134+
for (let i = 0; i < writeErrors.size(); i++) {
135+
const isWriteError =
136+
writeErrors.get(i, BSONType.object)?.getNumber('code') ===
137+
MONGODB_ERROR_CODES.MaxTimeMSExpired;
138+
139+
// {ok: 1, writeErrors: [{code: 50 ... }]}
140+
if (isWriteError) return true;
141+
}
142+
}
143+
144+
return false;
145+
}
146+
113147
/**
114148
* Drivers can safely assume that the `recoveryToken` field is always a BSON document but drivers MUST NOT modify the
115149
* contents of the document.

test/integration/client-side-operations-timeout/node_csot.test.ts

+161-2
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,23 @@
11
/* Anything javascript specific relating to timeouts */
22
import { expect } from 'chai';
3+
import * as semver from 'semver';
4+
import * as sinon from 'sinon';
35

46
import {
7+
BSON,
58
type ClientSession,
69
type Collection,
10+
Connection,
711
type Db,
812
type FindCursor,
913
LEGACY_HELLO_COMMAND,
1014
type MongoClient,
11-
MongoOperationTimeoutError
15+
MongoOperationTimeoutError,
16+
MongoServerError
1217
} from '../../mongodb';
18+
import { type FailPoint } from '../../tools/utils';
1319

14-
describe('CSOT driver tests', () => {
20+
describe('CSOT driver tests', { requires: { mongodb: '>=4.4' } }, () => {
1521
describe('timeoutMS inheritance', () => {
1622
let client: MongoClient;
1723
let db: Db;
@@ -161,4 +167,157 @@ describe('CSOT driver tests', () => {
161167
});
162168
});
163169
});
170+
171+
describe('server-side maxTimeMS errors are transformed', () => {
172+
let client: MongoClient;
173+
let commandsSucceeded;
174+
let commandsFailed;
175+
176+
beforeEach(async function () {
177+
client = this.configuration.newClient({ timeoutMS: 500_000, monitorCommands: true });
178+
commandsSucceeded = [];
179+
commandsFailed = [];
180+
client.on('commandSucceeded', event => {
181+
if (event.commandName === 'configureFailPoint') return;
182+
commandsSucceeded.push(event);
183+
});
184+
client.on('commandFailed', event => commandsFailed.push(event));
185+
});
186+
187+
afterEach(async function () {
188+
await client
189+
.db()
190+
.collection('a')
191+
.drop()
192+
.catch(() => null);
193+
await client.close();
194+
commandsSucceeded = undefined;
195+
commandsFailed = undefined;
196+
});
197+
198+
describe('when a maxTimeExpired error is returned at the top-level', () => {
199+
// {ok: 0, code: 50, codeName: "MaxTimeMSExpired", errmsg: "operation time limit exceeded"}
200+
const failpoint: FailPoint = {
201+
configureFailPoint: 'failCommand',
202+
mode: { times: 1 },
203+
data: {
204+
failCommands: ['ping'],
205+
errorCode: 50
206+
}
207+
};
208+
209+
beforeEach(async function () {
210+
if (semver.satisfies(this.configuration.version, '>=4.4'))
211+
await client.db('admin').command(failpoint);
212+
else {
213+
this.skipReason = 'Requires server version later than 4.4';
214+
this.skip();
215+
}
216+
});
217+
218+
afterEach(async function () {
219+
if (semver.satisfies(this.configuration.version, '>=4.4'))
220+
await client.db('admin').command({ ...failpoint, mode: 'off' });
221+
});
222+
223+
it('throws a MongoOperationTimeoutError error and emits command failed', async () => {
224+
const error = await client
225+
.db()
226+
.command({ ping: 1 })
227+
.catch(error => error);
228+
expect(error).to.be.instanceOf(MongoOperationTimeoutError);
229+
expect(error.cause).to.be.instanceOf(MongoServerError);
230+
expect(error.cause).to.have.property('code', 50);
231+
232+
expect(commandsFailed).to.have.lengthOf(1);
233+
expect(commandsFailed).to.have.nested.property('[0].failure.cause.code', 50);
234+
});
235+
});
236+
237+
describe('when a maxTimeExpired error is returned inside a writeErrors array', () => {
238+
// The server should always return one maxTimeExpiredError at the front of the writeErrors array
239+
// But for the sake of defensive programming we will find any maxTime error in the array.
240+
241+
beforeEach(async () => {
242+
const writeErrorsReply = BSON.serialize({
243+
ok: 1,
244+
writeErrors: [
245+
{ code: 2, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' },
246+
{ code: 3, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' },
247+
{ code: 4, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' },
248+
{ code: 50, codeName: 'MaxTimeMSExpired', errmsg: 'operation time limit exceeded' }
249+
]
250+
});
251+
const commandSpy = sinon.spy(Connection.prototype, 'command');
252+
const readManyStub = sinon
253+
// @ts-expect-error: readMany is private
254+
.stub(Connection.prototype, 'readMany')
255+
.callsFake(async function* (...args) {
256+
const realIterator = readManyStub.wrappedMethod.call(this, ...args);
257+
const cmd = commandSpy.lastCall.args.at(1);
258+
if ('giveMeWriteErrors' in cmd) {
259+
await realIterator.next().catch(() => null); // dismiss response
260+
yield { parse: () => writeErrorsReply };
261+
} else {
262+
yield (await realIterator.next()).value;
263+
}
264+
});
265+
});
266+
267+
afterEach(() => sinon.restore());
268+
269+
it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => {
270+
const error = await client
271+
.db('admin')
272+
.command({ giveMeWriteErrors: 1 })
273+
.catch(error => error);
274+
expect(error).to.be.instanceOf(MongoOperationTimeoutError);
275+
expect(error.cause).to.be.instanceOf(MongoServerError);
276+
expect(error.cause).to.have.nested.property('writeErrors[3].code', 50);
277+
278+
expect(commandsSucceeded).to.have.lengthOf(1);
279+
expect(commandsSucceeded).to.have.nested.property('[0].reply.writeErrors[3].code', 50);
280+
});
281+
});
282+
283+
describe('when a maxTimeExpired error is returned inside a writeConcernError embedded document', () => {
284+
// {ok: 1, writeConcernError: {code: 50, codeName: "MaxTimeMSExpired"}}
285+
const failpoint: FailPoint = {
286+
configureFailPoint: 'failCommand',
287+
mode: { times: 1 },
288+
data: {
289+
failCommands: ['insert'],
290+
writeConcernError: { code: 50, errmsg: 'times up buster', errorLabels: [] }
291+
}
292+
};
293+
294+
beforeEach(async function () {
295+
if (semver.satisfies(this.configuration.version, '>=4.4'))
296+
await client.db('admin').command(failpoint);
297+
else {
298+
this.skipReason = 'Requires server version later than 4.4';
299+
this.skip();
300+
}
301+
});
302+
303+
afterEach(async function () {
304+
if (semver.satisfies(this.configuration.version, '>=4.4'))
305+
await client.db('admin').command({ ...failpoint, mode: 'off' });
306+
});
307+
308+
it('throws a MongoOperationTimeoutError error and emits command succeeded', async () => {
309+
const error = await client
310+
.db()
311+
.collection('a')
312+
.insertOne({})
313+
.catch(error => error);
314+
expect(error).to.be.instanceOf(MongoOperationTimeoutError);
315+
expect(error.cause).to.be.instanceOf(MongoServerError);
316+
expect(error.cause).to.have.nested.property('writeConcernError.code', 50);
317+
318+
expect(commandsSucceeded).to.have.lengthOf(1);
319+
expect(commandsSucceeded).to.have.nested.property('[0].reply.writeConcernError.code', 50);
320+
});
321+
});
322+
});
164323
});

0 commit comments

Comments
 (0)