axios-cached-dns-resolve
Version:
Caches dns resolutions made with async dns.resolve instead of default sync dns.lookup, refreshes in background
227 lines (197 loc) • 6.47 kB
JavaScript
/* eslint-disable no-plusplus */
import dns from 'dns'
import URL from 'url'
import net from 'net'
import stringify from 'json-stringify-safe'
import LRUCache from 'lru-cache'
import util from 'util'
import { init as initLogger } from './logging.js'
const dnsResolve = util.promisify(dns.resolve)
const dnsLookup = util.promisify(dns.lookup)
export const config = {
disabled: process.env.AXIOS_DNS_DISABLE === 'true',
dnsTtlMs: process.env.AXIOS_DNS_CACHE_TTL_MS || 5000, // when to refresh actively used dns entries (5 sec)
cacheGraceExpireMultiplier: process.env.AXIOS_DNS_CACHE_EXPIRE_MULTIPLIER || 2, // maximum grace to use entry beyond TTL
dnsIdleTtlMs: process.env.AXIOS_DNS_CACHE_IDLE_TTL_MS || 1000 * 60 * 60, // when to remove entry entirely if not being used (1 hour)
backgroundScanMs: process.env.AXIOS_DNS_BACKGROUND_SCAN_MS || 2400, // how frequently to scan for expired TTL and refresh (2.4 sec)
dnsCacheSize: process.env.AXIOS_DNS_CACHE_SIZE || 100, // maximum number of entries to keep in cache
// pino logging options
logging: {
name: 'axios-cache-dns-resolve',
// enabled: true,
level: process.env.AXIOS_DNS_LOG_LEVEL || 'info', // default 'info' others trace, debug, info, warn, error, and fatal
// timestamp: true,
prettyPrint: process.env.NODE_ENV === 'DEBUG' || false,
formatters: {
level(label/* , number */) {
return { level: label }
},
},
},
cache: undefined,
}
export const cacheConfig = {
max: config.dnsCacheSize,
ttl: (config.dnsTtlMs * config.cacheGraceExpireMultiplier), // grace for refresh
}
export const stats = {
dnsEntries: 0,
refreshed: 0,
hits: 0,
misses: 0,
idleExpired: 0,
errors: 0,
lastError: 0,
lastErrorTs: 0,
}
let log
let backgroundRefreshId
let cachePruneId
init()
export function init() {
log = initLogger(config.logging)
if (config.cache) return
config.cache = new LRUCache(cacheConfig)
startBackgroundRefresh()
startPeriodicCachePrune()
cachePruneId = setInterval(() => config.cache.purgeStale(), config.dnsIdleTtlMs)
}
export function startBackgroundRefresh() {
if (backgroundRefreshId) clearInterval(backgroundRefreshId)
backgroundRefreshId = setInterval(backgroundRefresh, config.backgroundScanMs)
}
export function startPeriodicCachePrune() {
if (cachePruneId) clearInterval(cachePruneId)
cachePruneId = setInterval(() => config.cache.purgeStale(), config.dnsIdleTtlMs)
}
export function getStats() {
stats.dnsEntries = config.cache.size
return stats
}
export function getDnsCacheEntries() {
return Array.from(config.cache.values())
}
// const dnsEntry = {
// host: 'www.amazon.com',
// ips: [
// '52.54.40.141',
// '34.205.98.207',
// '3.82.118.51',
// ],
// nextIdx: 0,
// lastUsedTs: 1555771516581, Date.now()
// updatedTs: 1555771516581,
// }
export function registerInterceptor(axios) {
if (config.disabled || !axios || !axios.interceptors) return // supertest
axios.interceptors.request.use(async (reqConfig) => {
try {
let url
if (reqConfig.baseURL) {
url = URL.parse(reqConfig.baseURL)
} else {
url = URL.parse(reqConfig.url)
}
if (net.isIP(url.hostname)) return reqConfig // skip
reqConfig.headers.Host = url.hostname // set hostname in header
url.hostname = await getAddress(url.hostname)
delete url.host // clear hostname
if (reqConfig.baseURL) {
reqConfig.baseURL = URL.format(url)
} else {
reqConfig.url = URL.format(url)
}
} catch (err) {
recordError(err, `Error getAddress, ${err.message}`)
}
return reqConfig
})
}
export async function getAddress(host) {
let dnsEntry = config.cache.get(host)
if (dnsEntry) {
++stats.hits
dnsEntry.lastUsedTs = Date.now()
// eslint-disable-next-line no-plusplus
const ip = dnsEntry.ips[dnsEntry.nextIdx++ % dnsEntry.ips.length] // round-robin
config.cache.set(host, dnsEntry)
return ip
}
++stats.misses
if (log.isLevelEnabled('debug')) log.debug(`cache miss ${host}`)
const ips = await resolve(host)
dnsEntry = {
host,
ips,
nextIdx: 0,
lastUsedTs: Date.now(),
updatedTs: Date.now(),
}
// eslint-disable-next-line no-plusplus
const ip = dnsEntry.ips[dnsEntry.nextIdx++ % dnsEntry.ips.length] // round-robin
config.cache.set(host, dnsEntry)
return ip
}
let backgroundRefreshing = false
export async function backgroundRefresh() {
if (backgroundRefreshing) return // don't start again if currently iterating slowly
backgroundRefreshing = true
try {
config.cache.forEach(async (value, key) => {
try {
if (value.updatedTs + config.dnsTtlMs > Date.now()) {
return // continue/skip
}
if (value.lastUsedTs + config.dnsIdleTtlMs <= Date.now()) {
++stats.idleExpired
config.cache.delete(key)
return // continue
}
const ips = await resolve(value.host)
value.ips = ips
value.updatedTs = Date.now()
config.cache.set(key, value)
++stats.refreshed
} catch (err) {
// best effort
recordError(err, `Error backgroundRefresh host: ${key}, ${stringify(value)}, ${err.message}`)
}
})
} catch (err) {
// best effort
recordError(err, `Error backgroundRefresh, ${err.message}`)
} finally {
backgroundRefreshing = false
}
}
/**
*
* @param host
* @returns {*[]}
*/
async function resolve(host) {
let ips
try {
ips = await dnsResolve(host)
} catch (e) {
let lookupResp = await dnsLookup(host, { all: true }) // pass options all: true for all addresses
lookupResp = extractAddresses(lookupResp)
if (!Array.isArray(lookupResp) || lookupResp.length < 1) throw new Error(`fallback to dnsLookup returned no address ${host}`)
ips = lookupResp
}
return ips
}
// dns.lookup
// ***************** { address: '142.250.190.68', family: 4 }
// , { all: true } /***************** [ { address: '142.250.190.68', family: 4 } ]
function extractAddresses(lookupResp) {
if (!Array.isArray(lookupResp)) throw new Error('lookup response did not contain array of addresses')
return lookupResp.filter((e) => e.address != null).map((e) => e.address)
}
function recordError(err, errMesg) {
++stats.errors
stats.lastError = err
stats.lastErrorTs = new Date().toISOString()
log.error(err, errMesg)
}
/* eslint-enable no-plusplus */