Skip to content

Commit fd902d3

Browse files
feat(NODE-6451): retry SRV and TXT lookup for DNS timeout errors (#4375)
Co-authored-by: Aditi Khare <[email protected]>
1 parent 70d476a commit fd902d3

File tree

3 files changed

+196
-4
lines changed

3 files changed

+196
-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,164 @@
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+
const metadata: MongoDBMetadataUI = { requires: { topology: '!single' } };
8+
9+
// This serves as a placeholder for _whatever_ node.js may throw. We only rely upon `.code`
10+
class DNSTimeoutError extends Error {
11+
code = 'ETIMEOUT';
12+
}
13+
// This serves as a placeholder for _whatever_ node.js may throw. We only rely upon `.code`
14+
class DNSSomethingError extends Error {
15+
code = undefined;
16+
}
17+
18+
const CONNECTION_STRING = `mongodb+srv://test1.test.build.10gen.cc`;
19+
20+
describe('DNS timeout errors', () => {
21+
let client: MongoClient;
22+
let stub;
23+
24+
beforeEach(async function () {
25+
client = new MongoClient(CONNECTION_STRING, { serverSelectionTimeoutMS: 2000, tls: false });
26+
});
27+
28+
afterEach(async function () {
29+
stub = undefined;
30+
sinon.restore();
31+
await client.close();
32+
});
33+
34+
const restoreDNS =
35+
api =>
36+
async (...args) => {
37+
sinon.restore();
38+
return await dns.promises[api](...args);
39+
};
40+
41+
describe('when SRV record look up times out', () => {
42+
beforeEach(() => {
43+
stub = sinon
44+
.stub(dns.promises, 'resolveSrv')
45+
.onFirstCall()
46+
.rejects(new DNSTimeoutError())
47+
.onSecondCall()
48+
.callsFake(restoreDNS('resolveSrv'));
49+
});
50+
51+
afterEach(async function () {
52+
sinon.restore();
53+
});
54+
55+
it('retries timeout error', metadata, async () => {
56+
await client.connect();
57+
expect(stub).to.have.been.calledTwice;
58+
});
59+
});
60+
61+
describe('when TXT record look up times out', () => {
62+
beforeEach(() => {
63+
stub = sinon
64+
.stub(dns.promises, 'resolveTxt')
65+
.onFirstCall()
66+
.rejects(new DNSTimeoutError())
67+
.onSecondCall()
68+
.callsFake(restoreDNS('resolveTxt'));
69+
});
70+
71+
afterEach(async function () {
72+
sinon.restore();
73+
});
74+
75+
it('retries timeout error', metadata, async () => {
76+
await client.connect();
77+
expect(stub).to.have.been.calledTwice;
78+
});
79+
});
80+
81+
describe('when SRV record look up times out twice', () => {
82+
beforeEach(() => {
83+
stub = sinon
84+
.stub(dns.promises, 'resolveSrv')
85+
.onFirstCall()
86+
.rejects(new DNSTimeoutError())
87+
.onSecondCall()
88+
.rejects(new DNSTimeoutError());
89+
});
90+
91+
afterEach(async function () {
92+
sinon.restore();
93+
});
94+
95+
it('throws timeout error', metadata, async () => {
96+
const error = await client.connect().catch(error => error);
97+
expect(error).to.be.instanceOf(DNSTimeoutError);
98+
expect(stub).to.have.been.calledTwice;
99+
});
100+
});
101+
102+
describe('when TXT record look up times out twice', () => {
103+
beforeEach(() => {
104+
stub = sinon
105+
.stub(dns.promises, 'resolveTxt')
106+
.onFirstCall()
107+
.rejects(new DNSTimeoutError())
108+
.onSecondCall()
109+
.rejects(new DNSTimeoutError());
110+
});
111+
112+
afterEach(async function () {
113+
sinon.restore();
114+
});
115+
116+
it('throws timeout error', metadata, async () => {
117+
const error = await client.connect().catch(error => error);
118+
expect(error).to.be.instanceOf(DNSTimeoutError);
119+
expect(stub).to.have.been.calledTwice;
120+
});
121+
});
122+
123+
describe('when SRV record look up throws a non-timeout error', () => {
124+
beforeEach(() => {
125+
stub = sinon
126+
.stub(dns.promises, 'resolveSrv')
127+
.onFirstCall()
128+
.rejects(new DNSSomethingError())
129+
.onSecondCall()
130+
.callsFake(restoreDNS('resolveSrv'));
131+
});
132+
133+
afterEach(async function () {
134+
sinon.restore();
135+
});
136+
137+
it('throws that error', metadata, async () => {
138+
const error = await client.connect().catch(error => error);
139+
expect(error).to.be.instanceOf(DNSSomethingError);
140+
expect(stub).to.have.been.calledOnce;
141+
});
142+
});
143+
144+
describe('when TXT record look up throws a non-timeout error', () => {
145+
beforeEach(() => {
146+
stub = sinon
147+
.stub(dns.promises, 'resolveTxt')
148+
.onFirstCall()
149+
.rejects(new DNSSomethingError())
150+
.onSecondCall()
151+
.callsFake(restoreDNS('resolveTxt'));
152+
});
153+
154+
afterEach(async function () {
155+
sinon.restore();
156+
});
157+
158+
it('throws that error', metadata, async () => {
159+
const error = await client.connect().catch(error => error);
160+
expect(error).to.be.instanceOf(DNSSomethingError);
161+
expect(stub).to.have.been.calledOnce;
162+
});
163+
});
164+
});

0 commit comments

Comments
 (0)