Skip to content

Commit 55b952f

Browse files
authored
perf: optimize DNS resolution to reduce request latency (#7550)
1 parent 5cd3e7a commit 55b952f

File tree

5 files changed

+129
-3
lines changed

5 files changed

+129
-3
lines changed
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import http from 'node:http';
2+
import { fastLookup } from './fast-lookup';
3+
4+
/**
5+
* Shared agent configuration for HTTP/HTTPS agents across the application.
6+
*
7+
* - keepAlive: Reuse TCP connections to avoid repeated handshakes.
8+
* - maxSockets: 100 concurrent sockets per host — high enough for parallel
9+
* collection runs, low enough to avoid file-descriptor exhaustion.
10+
* - maxFreeSockets: 10 idle sockets kept alive for reuse between bursts.
11+
* - scheduling: 'fifo' distributes requests across connections evenly,
12+
* which avoids head-of-line blocking that 'lifo' (Node's default) can
13+
* cause when one connection stalls.
14+
* - lookup: fastLookup uses async c-ares (dns.resolve4/6) to bypass the
15+
* libuv thread pool bottleneck, falling back to dns.lookup for /etc/hosts
16+
* and mDNS hostnames.
17+
*/
18+
export const defaultAgentOptions: http.AgentOptions = {
19+
keepAlive: true,
20+
maxSockets: 100,
21+
maxFreeSockets: 10,
22+
scheduling: 'fifo',
23+
lookup: fastLookup as http.AgentOptions['lookup']
24+
};

packages/bruno-requests/src/network/axios-instance.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { default as axios, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
22
import http from 'node:http';
33
import https from 'node:https';
4+
import { defaultAgentOptions } from './agent-defaults';
45

56
/**
67
*
@@ -29,8 +30,8 @@ type ModifiedAxiosResponse = AxiosResponse & {
2930

3031
const baseRequestConfig: Partial<AxiosRequestConfig> = {
3132
proxy: false,
32-
httpAgent: new http.Agent({ keepAlive: true }),
33-
httpsAgent: new https.Agent({ keepAlive: true }),
33+
httpAgent: new http.Agent(defaultAgentOptions),
34+
httpsAgent: new https.Agent(defaultAgentOptions),
3435
transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) {
3536
const contentType = headers.getContentType() || '';
3637
const hasJSONContentType = contentType.includes('json');
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import dns from 'node:dns';
2+
import { fastLookup } from './fast-lookup';
3+
4+
type DnsMethod = 'resolve4' | 'resolve6';
5+
6+
function mockResolve(method: DnsMethod, result: string[], err: Error | null = null): void {
7+
(jest.spyOn(dns, method) as any).mockImplementation((_hostname: string, cb: Function) => {
8+
cb(err, result);
9+
});
10+
}
11+
12+
function mockLookup(address: string, family: number): void {
13+
(jest.spyOn(dns, 'lookup') as any).mockImplementation((_hostname: string, _options: dns.LookupOptions, cb: Function) => {
14+
cb(null, address, family);
15+
});
16+
}
17+
18+
describe('fastLookup', () => {
19+
afterEach(() => {
20+
jest.restoreAllMocks();
21+
});
22+
23+
it('should resolve a public hostname via dns.resolve4', (done) => {
24+
mockResolve('resolve4', ['93.184.216.34']);
25+
26+
fastLookup('example.com', {}, (err, address, family) => {
27+
expect(err).toBeNull();
28+
expect(address).toBe('93.184.216.34');
29+
expect(family).toBe(4);
30+
done();
31+
});
32+
});
33+
34+
it('should fall back to dns.lookup when both resolvers fail', (done) => {
35+
mockResolve('resolve4', [], new Error('ENOTFOUND'));
36+
mockResolve('resolve6', [], new Error('ENOTFOUND'));
37+
mockLookup('127.0.0.1', 4);
38+
39+
fastLookup('my-local-host', {}, (err, address, family) => {
40+
expect(err).toBeNull();
41+
expect(address).toBe('127.0.0.1');
42+
expect(family).toBe(4);
43+
done();
44+
});
45+
});
46+
47+
it('should return all addresses when options.all is true', (done) => {
48+
mockResolve('resolve4', ['1.2.3.4', '5.6.7.8']);
49+
50+
fastLookup('example.com', { all: true }, (err, addresses) => {
51+
expect(err).toBeNull();
52+
expect(Array.isArray(addresses)).toBe(true);
53+
expect(addresses).toEqual([
54+
{ address: '1.2.3.4', family: 4 },
55+
{ address: '5.6.7.8', family: 4 }
56+
]);
57+
done();
58+
});
59+
});
60+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import dns from 'node:dns';
2+
3+
/**
4+
* Fast DNS lookup that bypasses the libuv thread pool.
5+
*
6+
* Tries dns.resolve4 then dns.resolve6 (async, c-ares based),
7+
* falls back to dns.lookup for /etc/hosts and mDNS hostnames.
8+
*
9+
* NOTE: `options.family` is not currently respected — the function always
10+
* tries IPv4 first regardless of the caller's preference. This is safe today
11+
* because Bruno's HTTP agents use the default family (0), but should be
12+
* addressed if any code path starts specifying a family.
13+
*/
14+
export function fastLookup(
15+
hostname: string,
16+
options: dns.LookupOptions | undefined,
17+
callback: (err: Error | null, address: string | dns.LookupAddress[], family?: number) => void
18+
): void {
19+
dns.resolve4(hostname, (err4, addresses4) => {
20+
if (!err4 && addresses4?.length) {
21+
return options?.all
22+
? callback(null, addresses4.map((a) => ({ address: a, family: 4 })))
23+
: callback(null, addresses4[0], 4);
24+
}
25+
26+
// Forward to standard dns.lookup for /etc/hosts, mDNS, and other
27+
// non-public hostnames that c-ares cannot resolve.
28+
dns.lookup(hostname, options ?? {}, (err, address, family) => {
29+
callback(err, address, family);
30+
});
31+
});
32+
}

packages/bruno-requests/src/utils/agent-cache.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import tls from 'node:tls';
33
import type { Agent as HttpAgent } from 'node:http';
44
import type { Agent as HttpsAgent } from 'node:https';
55
import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent';
6+
import { defaultAgentOptions } from '../network/agent-defaults';
67

78
/**
89
* Agent cache for SSL session reuse.
@@ -267,8 +268,16 @@ function getOrCreateAgentInternal<TOptions extends HttpAgentOptions>(
267268
}
268269

269270
const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
271+
272+
// Inject shared agent defaults (DNS lookup, socket pool settings), then
273+
// layer on the caller's options so per-agent overrides still take effect.
274+
const optimizedOptions = {
275+
...defaultAgentOptions,
276+
...options
277+
};
278+
270279
// Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults
271-
const resolvedOptions = applySecureContext(options);
280+
const resolvedOptions = applySecureContext(optimizedOptions);
272281

273282
let agent: HttpAgent | HttpsAgent;
274283
if (timeline) {

0 commit comments

Comments
 (0)