Skip to content

Commit 84809c7

Browse files
committed
feat(NODE-6451): retry DNS timeout on SRV and TXT lookup
1 parent 41b066b commit 84809c7

File tree

3 files changed

+192
-4
lines changed

3 files changed

+192
-4
lines changed

src/connection_string.ts

+24-4
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,27 @@ const LB_REPLICA_SET_ERROR = 'loadBalanced option not supported with a replicaSe
5252
const LB_DIRECT_CONNECTION_ERROR =
5353
'loadBalanced option not supported when directConnection is provided';
5454

55+
function retryDNSTimeoutFor(api: 'resolveSrv'): (a: string) => Promise<dns.SrvRecord[]>;
56+
function retryDNSTimeoutFor(api: 'resolveTxt'): (a: string) => Promise<string[][]>;
57+
function retryDNSTimeoutFor(
58+
api: 'resolveSrv' | 'resolveTxt'
59+
): (a: string) => Promise<dns.SrvRecord[] | string[][]> {
60+
return async function dnsReqRetryTimeout(lookupAddress: string) {
61+
try {
62+
return await dns.promises[api](lookupAddress);
63+
} catch (firstDNSError) {
64+
if (firstDNSError.code === dns.TIMEOUT) {
65+
return await dns.promises[api](lookupAddress);
66+
} else {
67+
throw firstDNSError;
68+
}
69+
}
70+
};
71+
}
72+
73+
const resolveSrv = retryDNSTimeoutFor('resolveSrv');
74+
const resolveTxt = retryDNSTimeoutFor('resolveTxt');
75+
5576
/**
5677
* Lookup a `mongodb+srv` connection string, combine the parts and reparse it as a normal
5778
* connection string.
@@ -67,14 +88,13 @@ export async function resolveSRVRecord(options: MongoOptions): Promise<HostAddre
6788
// Asynchronously start TXT resolution so that we do not have to wait until
6889
// the SRV record is resolved before starting a second DNS query.
6990
const lookupAddress = options.srvHost;
70-
const txtResolutionPromise = dns.promises.resolveTxt(lookupAddress);
91+
const txtResolutionPromise = resolveTxt(lookupAddress);
7192

7293
txtResolutionPromise.then(undefined, squashError); // rejections will be handled later
7394

95+
const hostname = `_${options.srvServiceName}._tcp.${lookupAddress}`;
7496
// Resolve the SRV record and use the result as the list of hosts to connect to.
75-
const addresses = await dns.promises.resolveSrv(
76-
`_${options.srvServiceName}._tcp.${lookupAddress}`
77-
);
97+
const addresses = await resolveSrv(hostname);
7898

7999
if (addresses.length === 0) {
80100
throw new MongoAPIError('No addresses found at host');

src/mongo_client.ts

+8
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,10 @@ export class MongoClient extends TypedEventEmitter<MongoClientEvents> implements
521521
* This means the time to setup the `MongoClient` does not count against `timeoutMS`.
522522
* If you are using `timeoutMS` we recommend connecting your client explicitly in advance of any operation to avoid this inconsistent execution time.
523523
*
524+
* @remarks
525+
* The driver will look up corresponding SRV and TXT records if the connection string starts with `mongodb+srv://`.
526+
* If those look ups throw a DNS Timeout error, the driver will retry the look up once.
527+
*
524528
* @see docs.mongodb.org/manual/reference/connection-string/
525529
*/
526530
async connect(): Promise<this> {
@@ -727,6 +731,10 @@ export class MongoClient extends TypedEventEmitter<MongoClientEvents> implements
727731
* @remarks
728732
* The programmatically provided options take precedence over the URI options.
729733
*
734+
* @remarks
735+
* The driver will look up corresponding SRV and TXT records if the connection string starts with `mongodb+srv://`.
736+
* If those look ups throw a DNS Timeout error, the driver will retry the look up once.
737+
*
730738
* @see https://www.mongodb.com/docs/manual/reference/connection-string/
731739
*/
732740
static async connect(url: string, options?: MongoClientOptions): Promise<MongoClient> {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { expect } from 'chai';
2+
import * as dns from 'dns';
3+
import * as sinon from 'sinon';
4+
5+
import { MongoClient } from '../../mongodb';
6+
7+
// This serves as a placeholder for _whatever_ node.js may throw. We only rely upon `.code`
8+
class DNSTimeoutError extends Error {
9+
code = 'ETIMEOUT';
10+
}
11+
// This serves as a placeholder for _whatever_ node.js may throw. We only rely upon `.code`
12+
class DNSSomethingError extends Error {
13+
code = undefined;
14+
}
15+
16+
const CONNECTION_STRING = `mongodb+srv://test1.test.build.10gen.cc`;
17+
// 27018 localhost.test.build.10gen.cc.
18+
// 27017 localhost.test.build.10gen.cc.
19+
20+
describe('DNS timeout errors', () => {
21+
let client: MongoClient;
22+
23+
beforeEach(async function () {
24+
client = new MongoClient(CONNECTION_STRING, { serverSelectionTimeoutMS: 2000, tls: false });
25+
});
26+
27+
afterEach(async function () {
28+
sinon.restore();
29+
await client.close();
30+
});
31+
32+
const restoreDNS =
33+
api =>
34+
async (...args) => {
35+
sinon.restore();
36+
return await dns.promises[api](...args);
37+
};
38+
39+
describe('when SRV record look up times out', () => {
40+
beforeEach(() => {
41+
sinon
42+
.stub(dns.promises, 'resolveSrv')
43+
.onFirstCall()
44+
.rejects(new DNSTimeoutError())
45+
.onSecondCall()
46+
.callsFake(restoreDNS('resolveSrv'));
47+
});
48+
49+
afterEach(async function () {
50+
sinon.restore();
51+
});
52+
53+
it('retries timeout error', async () => {
54+
await client.connect();
55+
});
56+
});
57+
58+
describe('when TXT record look up times out', () => {
59+
beforeEach(() => {
60+
sinon
61+
.stub(dns.promises, 'resolveTxt')
62+
.onFirstCall()
63+
.rejects(new DNSTimeoutError())
64+
.onSecondCall()
65+
.callsFake(restoreDNS('resolveTxt'));
66+
});
67+
68+
afterEach(async function () {
69+
sinon.restore();
70+
});
71+
72+
it('retries timeout error', async () => {
73+
await client.connect();
74+
});
75+
});
76+
77+
describe('when SRV record look up times out twice', () => {
78+
beforeEach(() => {
79+
sinon
80+
.stub(dns.promises, 'resolveSrv')
81+
.onFirstCall()
82+
.rejects(new DNSTimeoutError())
83+
.onSecondCall()
84+
.rejects(new DNSTimeoutError())
85+
.onThirdCall()
86+
.callsFake(restoreDNS('resolveSrv'));
87+
});
88+
89+
afterEach(async function () {
90+
sinon.restore();
91+
});
92+
93+
it('throws timeout error', async () => {
94+
const error = await client.connect().catch(error => error);
95+
expect(error).to.be.instanceOf(DNSTimeoutError);
96+
});
97+
});
98+
99+
describe('when TXT record look up times out twice', () => {
100+
beforeEach(() => {
101+
sinon
102+
.stub(dns.promises, 'resolveTxt')
103+
.onFirstCall()
104+
.rejects(new DNSTimeoutError())
105+
.onSecondCall()
106+
.rejects(new DNSTimeoutError())
107+
.onThirdCall()
108+
.callsFake(restoreDNS('resolveTxt'));
109+
});
110+
111+
afterEach(async function () {
112+
sinon.restore();
113+
});
114+
115+
it('throws timeout error', async () => {
116+
const error = await client.connect().catch(error => error);
117+
expect(error).to.be.instanceOf(DNSTimeoutError);
118+
});
119+
});
120+
121+
describe('when SRV record look up throws a non-timeout error', () => {
122+
beforeEach(() => {
123+
sinon
124+
.stub(dns.promises, 'resolveSrv')
125+
.onFirstCall()
126+
.rejects(new DNSSomethingError())
127+
.onSecondCall()
128+
.callsFake(restoreDNS('resolveSrv'));
129+
});
130+
131+
afterEach(async function () {
132+
sinon.restore();
133+
});
134+
135+
it('throws that error', async () => {
136+
const error = await client.connect().catch(error => error);
137+
expect(error).to.be.instanceOf(DNSSomethingError);
138+
});
139+
});
140+
141+
describe('when TXT record look up throws a non-timeout error', () => {
142+
beforeEach(() => {
143+
sinon
144+
.stub(dns.promises, 'resolveTxt')
145+
.onFirstCall()
146+
.rejects(new DNSSomethingError())
147+
.onSecondCall()
148+
.callsFake(restoreDNS('resolveTxt'));
149+
});
150+
151+
afterEach(async function () {
152+
sinon.restore();
153+
});
154+
155+
it('throws that error', async () => {
156+
const error = await client.connect().catch(error => error);
157+
expect(error).to.be.instanceOf(DNSSomethingError);
158+
});
159+
});
160+
});

0 commit comments

Comments
 (0)