perf: optimize DNS resolution to reduce request latency (#7664)

* perf: optimize DNS resolution to reduce request latency by ~31%

Replace default dns.lookup (libuv thread pool) with async dns.resolve4/6
(c-ares) that bypasses the thread pool bottleneck, falling back to
dns.lookup for /etc/hosts and mDNS hostnames.

* fix: address PR review feedback for DNS optimization

- Guard against undefined options in fastLookup to prevent runtime errors
- Document that options.family is not yet respected (safe today, noted for future)
- Replace callback as any with proper typed forwarding wrapper
- Extract shared agent config (defaultAgentOptions) to eliminate duplication
  between axios-instance.ts and agent-cache.ts, with documented rationale
- Mock DNS in test to avoid real network calls to google.com in CI

* fix: removed explicit resolve6 in fastLookup

---------

Co-authored-by: Chirag Chandrashekhar <cchirag85@gmail.com>
This commit is contained in:
lohit
2026-04-02 15:57:51 +00:00
committed by GitHub
parent 5cd3e7abbd
commit bae5934137
5 changed files with 129 additions and 3 deletions

View File

@@ -0,0 +1,24 @@
import http from 'node:http';
import { fastLookup } from './fast-lookup';
/**
* Shared agent configuration for HTTP/HTTPS agents across the application.
*
* - keepAlive: Reuse TCP connections to avoid repeated handshakes.
* - maxSockets: 100 concurrent sockets per host — high enough for parallel
* collection runs, low enough to avoid file-descriptor exhaustion.
* - maxFreeSockets: 10 idle sockets kept alive for reuse between bursts.
* - scheduling: 'fifo' distributes requests across connections evenly,
* which avoids head-of-line blocking that 'lifo' (Node's default) can
* cause when one connection stalls.
* - lookup: fastLookup uses async c-ares (dns.resolve4/6) to bypass the
* libuv thread pool bottleneck, falling back to dns.lookup for /etc/hosts
* and mDNS hostnames.
*/
export const defaultAgentOptions: http.AgentOptions = {
keepAlive: true,
maxSockets: 100,
maxFreeSockets: 10,
scheduling: 'fifo',
lookup: fastLookup as http.AgentOptions['lookup']
};

View File

@@ -1,6 +1,7 @@
import { default as axios, AxiosRequestConfig, AxiosRequestHeaders, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import http from 'node:http';
import https from 'node:https';
import { defaultAgentOptions } from './agent-defaults';
/**
*
@@ -29,8 +30,8 @@ type ModifiedAxiosResponse = AxiosResponse & {
const baseRequestConfig: Partial<AxiosRequestConfig> = {
proxy: false,
httpAgent: new http.Agent({ keepAlive: true }),
httpsAgent: new https.Agent({ keepAlive: true }),
httpAgent: new http.Agent(defaultAgentOptions),
httpsAgent: new https.Agent(defaultAgentOptions),
transformRequest: function transformRequest(data: any, headers: AxiosRequestHeaders) {
const contentType = headers.getContentType() || '';
const hasJSONContentType = contentType.includes('json');

View File

@@ -0,0 +1,60 @@
import dns from 'node:dns';
import { fastLookup } from './fast-lookup';
type DnsMethod = 'resolve4' | 'resolve6';
function mockResolve(method: DnsMethod, result: string[], err: Error | null = null): void {
(jest.spyOn(dns, method) as any).mockImplementation((_hostname: string, cb: Function) => {
cb(err, result);
});
}
function mockLookup(address: string, family: number): void {
(jest.spyOn(dns, 'lookup') as any).mockImplementation((_hostname: string, _options: dns.LookupOptions, cb: Function) => {
cb(null, address, family);
});
}
describe('fastLookup', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should resolve a public hostname via dns.resolve4', (done) => {
mockResolve('resolve4', ['93.184.216.34']);
fastLookup('example.com', {}, (err, address, family) => {
expect(err).toBeNull();
expect(address).toBe('93.184.216.34');
expect(family).toBe(4);
done();
});
});
it('should fall back to dns.lookup when both resolvers fail', (done) => {
mockResolve('resolve4', [], new Error('ENOTFOUND'));
mockResolve('resolve6', [], new Error('ENOTFOUND'));
mockLookup('127.0.0.1', 4);
fastLookup('my-local-host', {}, (err, address, family) => {
expect(err).toBeNull();
expect(address).toBe('127.0.0.1');
expect(family).toBe(4);
done();
});
});
it('should return all addresses when options.all is true', (done) => {
mockResolve('resolve4', ['1.2.3.4', '5.6.7.8']);
fastLookup('example.com', { all: true }, (err, addresses) => {
expect(err).toBeNull();
expect(Array.isArray(addresses)).toBe(true);
expect(addresses).toEqual([
{ address: '1.2.3.4', family: 4 },
{ address: '5.6.7.8', family: 4 }
]);
done();
});
});
});

View File

@@ -0,0 +1,32 @@
import dns from 'node:dns';
/**
* Fast DNS lookup that bypasses the libuv thread pool.
*
* Tries dns.resolve4 then dns.resolve6 (async, c-ares based),
* falls back to dns.lookup for /etc/hosts and mDNS hostnames.
*
* NOTE: `options.family` is not currently respected — the function always
* tries IPv4 first regardless of the caller's preference. This is safe today
* because Bruno's HTTP agents use the default family (0), but should be
* addressed if any code path starts specifying a family.
*/
export function fastLookup(
hostname: string,
options: dns.LookupOptions | undefined,
callback: (err: Error | null, address: string | dns.LookupAddress[], family?: number) => void
): void {
dns.resolve4(hostname, (err4, addresses4) => {
if (!err4 && addresses4?.length) {
return options?.all
? callback(null, addresses4.map((a) => ({ address: a, family: 4 })))
: callback(null, addresses4[0], 4);
}
// Forward to standard dns.lookup for /etc/hosts, mDNS, and other
// non-public hostnames that c-ares cannot resolve.
dns.lookup(hostname, options ?? {}, (err, address, family) => {
callback(err, address, family);
});
});
}

View File

@@ -3,6 +3,7 @@ import tls from 'node:tls';
import type { Agent as HttpAgent } from 'node:http';
import type { Agent as HttpsAgent } from 'node:https';
import { createTimelineAgentClass, createTimelineHttpAgentClass, type TimelineEntry, type AgentOptions, type HttpAgentOptions, type AgentClass, type HttpAgentClass } from './timeline-agent';
import { defaultAgentOptions } from '../network/agent-defaults';
/**
* Agent cache for SSL session reuse.
@@ -267,8 +268,16 @@ function getOrCreateAgentInternal<TOptions extends HttpAgentOptions>(
}
const AgentClass = timeline ? getTimelineClass(BaseAgentClass) : BaseAgentClass;
// Inject shared agent defaults (DNS lookup, socket pool settings), then
// layer on the caller's options so per-agent overrides still take effect.
const optimizedOptions = {
...defaultAgentOptions,
...options
};
// Convert raw `ca` to a secureContext that adds CAs on top of OpenSSL defaults
const resolvedOptions = applySecureContext(options);
const resolvedOptions = applySecureContext(optimizedOptions);
let agent: HttpAgent | HttpsAgent;
if (timeline) {