Skip to content

Commit d2964ba

Browse files
committed
feat(NODE-5687): add error transformation for server timeouts
1 parent 938b594 commit d2964ba

File tree

3 files changed

+197
-2
lines changed

3 files changed

+197
-2
lines changed

src/cmap/connection.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
} from '../constants';
1616
import {
1717
MongoCompatibilityError,
18+
MONGODB_ERROR_CODES,
1819
MongoMissingDependencyError,
1920
MongoNetworkError,
2021
MongoNetworkTimeoutError,
@@ -537,6 +538,11 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
537538
}
538539

539540
if (document.ok === 0) {
541+
if (options.timeoutContext?.csotEnabled() && document.isMaxTimeExpiredError) {
542+
throw new MongoOperationTimeoutError('Server reported a timeout error', {
543+
cause: new MongoServerError((object ??= document.toObject(bsonOptions)))
544+
});
545+
}
540546
throw new MongoServerError((object ??= document.toObject(bsonOptions)));
541547
}
542548

@@ -606,6 +612,25 @@ export class Connection extends TypedEventEmitter<ConnectionEvents> {
606612
): Promise<Document> {
607613
this.throwIfAborted();
608614
for await (const document of this.sendCommand(ns, command, options, responseType)) {
615+
if (options.timeoutContext?.csotEnabled()) {
616+
if (MongoDBResponse.is(document)) {
617+
if (document.isMaxTimeExpiredError) {
618+
throw new MongoOperationTimeoutError('Server reported a timeout error', {
619+
cause: new MongoServerError(document.toObject())
620+
});
621+
}
622+
} else {
623+
if (
624+
document?.writeErrors?.[0]?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired ||
625+
document?.writeConcernError?.code === MONGODB_ERROR_CODES.MaxTimeMSExpired
626+
) {
627+
throw new MongoOperationTimeoutError('Server reported a timeout error', {
628+
cause: new MongoServerError(document)
629+
});
630+
}
631+
}
632+
}
633+
609634
return document;
610635
}
611636
throw new MongoUnexpectedServerResponseError('Unable to get response from server');

src/cmap/wire_protocol/responses.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
pluckBSONSerializeOptions,
99
type Timestamp
1010
} from '../../bson';
11-
import { MongoUnexpectedServerResponseError } from '../../error';
11+
import { MONGODB_ERROR_CODES, MongoUnexpectedServerResponseError } from '../../error';
1212
import { type ClusterTime } from '../../sdam/common';
1313
import { decorateDecryptionResult, ns } from '../../utils';
1414
import { type JSTypeOf, OnDemandDocument } from './on_demand/document';
@@ -104,6 +104,27 @@ export class MongoDBResponse extends OnDemandDocument {
104104
// {ok:1}
105105
static empty = new MongoDBResponse(new Uint8Array([13, 0, 0, 0, 16, 111, 107, 0, 1, 0, 0, 0, 0]));
106106

107+
/**
108+
* Returns true iff:
109+
* - ok is 0 and the top-level code === 50
110+
* - ok is 1 and the writeErrors array contains a code === 50
111+
* - ok is 1 and the writeConcern object contains a code === 50
112+
*/
113+
get isMaxTimeExpiredError() {
114+
return (
115+
// {ok: 0, code: 50 ... }
116+
(this.ok === 0 && this.code === MONGODB_ERROR_CODES.MaxTimeMSExpired) ||
117+
// {ok: 1, writeErrors: [{code: 50 ... }]}
118+
(this.ok === 1 &&
119+
this.get('writeErrors', BSONType.array)?.get(0, BSONType.object)?.getNumber('code') ===
120+
MONGODB_ERROR_CODES.MaxTimeMSExpired) ||
121+
// {ok: 1, writeConcernError: {code: 50 ... }}
122+
(this.ok === 1 &&
123+
this.get('writeConcernError', BSONType.object)?.getNumber('code') ===
124+
MONGODB_ERROR_CODES.MaxTimeMSExpired)
125+
);
126+
}
127+
107128
/**
108129
* Drivers can safely assume that the `recoveryToken` field is always a BSON document but drivers MUST NOT modify the
109130
* contents of the document.

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

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
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

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

0 commit comments

Comments
 (0)