diff --git a/lib/egg.js b/lib/egg.js index 8bb0be2e7c..4607862af1 100644 --- a/lib/egg.js +++ b/lib/egg.js @@ -297,18 +297,62 @@ class EggApplication extends EggCore { */ createHttpClient(options = {}) { let httpClient; - options.lookup = options.lookup ?? this.config.httpclient.lookup; - if (this.config.httpclient.useHttpClientNext || this.config.httpclient.allowH2) { - httpClient = new this.HttpClientNext(this, options); + httpClient = this._createHttpClientNextProxy(options); } else if (this.config.httpclient?.enableDNSCache) { httpClient = new DNSCacheHttpClient(this, options); } else { + options.lookup = options.lookup ?? this.config.httpclient.lookup; httpClient = new this.HttpClient(this, options); } return httpClient; } + _createHttpClientNextProxy(options = {}) { + const self = this; + let realClient = null; + const init = () => { + if (realClient) return; + options.lookup = options.lookup ?? self.config.httpclient.lookup; + realClient = new self.HttpClientNext(self, options); + }; + return new Proxy({}, { + get(_target, prop) { + init(); + const value = realClient[prop]; + if (typeof value === 'function') { + return value.bind(realClient); + } + return value; + }, + set(_target, prop, value) { + init(); + realClient[prop] = value; + return true; + }, + has(_target, prop) { + init(); + return prop in realClient; + }, + ownKeys() { + init(); + return Reflect.ownKeys(realClient); + }, + getOwnPropertyDescriptor(_target, prop) { + init(); + return Object.getOwnPropertyDescriptor(realClient, prop); + }, + deleteProperty(_target, prop) { + init(); + return delete realClient[prop]; + }, + getPrototypeOf() { + init(); + return Object.getPrototypeOf(realClient); + }, + }); + } + /** * HttpClient instance * @see https://github.com/node-modules/urllib diff --git a/test/lib/core/dns_resolver.test.js b/test/lib/core/dns_resolver.test.js index 1e58a8173f..37168891a1 100644 --- a/test/lib/core/dns_resolver.test.js +++ b/test/lib/core/dns_resolver.test.js @@ -1,4 +1,7 @@ const utils = require('../../utils'); +const mm = require('egg-mock'); +const dns = require('dns'); +const dnsPromise = require('dns').promises; const assert = require('assert'); describe('test/lib/core/dns_resolver.test.js', () => { @@ -22,6 +25,8 @@ describe('test/lib/core/dns_resolver.test.js', () => { url2 = url2.replace('127.0.0.2', 'localhost'); }); + afterEach(mm.restore); + after(() => { if (server1?.server?.listening) server1.server.close(); if (server2?.server?.listening) server2.server.close(); @@ -76,4 +81,114 @@ describe('test/lib/core/dns_resolver.test.js', () => { assert(err.message.includes('fetch failed')); } }); + + it('should not fail even if dns.lookup fail, because lookup is overridden', async () => { + mm.error(dnsPromise, 'lookup', 'mock dns lookup error'); + const res = await app.curl(url2 + '/get_headers', { dataType: 'json' }); + assert(res.status === 200); + }); +}); + +describe('test/lib/core/dns_resolver with dns error', () => { + let server; + let app; + let url; + let originalDNSServers; + + const cache = new Map(); + before(async () => { + server = await utils.startNewLocalServer('127.0.0.1'); + if (!server) { + throw new Error('start local server failed'); + } + app = utils.app('apps/dns_resolver'); + await app.ready(); + app.config.httpclient.lookup = function(hostname, options, callback) { + if (cache.has(hostname)) { + const record = cache.get(hostname); + if (options && options.all) { + const addresses = record.map(r => ({ address: r.address, family: 4 })); + callback(null, addresses); + return; + } + callback(null, record[0].address, 4); + return; + } + dnsPromise.resolve4(hostname, { ttl: true }).then(addresses => { + if (addresses && addresses.length !== 0) { + if (Array.isArray(addresses)) { + cache.set(hostname, addresses); + } else { + cache.set(hostname, [ addresses ]); + } + if (options && options.all) { + const addrList = addresses.map(r => ({ address: r.address, family: 4 })); + callback(null, addrList); + return; + } + callback(null, addresses[0].address, 4); + } else { + callback(new Error('no addresses found')); + } + }).catch(err => { + callback(err); + }); + + }; + url = server.url; + url = url.replace('127.0.0.1', 'localhost'); + originalDNSServers = dns.getServers(); + dns.setServers([ '223.5.5.5', '223.6.6.6' ]); + }); + + afterEach(mm.restore); + + after(() => { + dns.setServers(originalDNSServers); + if (server?.server?.listening) server.server.close(); + }); + + + it('should cache work when dns fails', async () => { + const res1 = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert(res1.status === 200); + assert(cache.has('localhost')); + + mm.error(dnsPromise, 'resolve4', 'mock dns lookup error'); + const res2 = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert(res2.status === 200); + cache.delete('localhost'); + // should fail now + // assert.error(app.curl(url + '/get_headers', { dataType: 'json' })) + let shouldFail = false; + try { + await app.curl(url + '/get_headers', { dataType: 'json' }); + } catch (err) { + shouldFail = true; + assert(err.message.includes('mock dns lookup error')); + } + assert(shouldFail); + }); + + it('should cache work when name server fails', async () => { + const successRes = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert(successRes.status === 200); + assert(cache.has('localhost')); + // can't resolve localhost now, but cache still works + dns.setServers([ '8.8.8.8' ]); + const res = await app.curl(url + '/get_headers', { dataType: 'json' }); + assert(res.status === 200); + + // clear cache, should fail now + cache.delete('localhost'); + let shouldFail = false; + try { + await app.curl(url + '/get_headers', { dataType: 'json' }); + } catch (err) { + shouldFail = true; + assert(err.message.includes('queryA ENOTFOUND localhost')); + } + assert(shouldFail); + }); }); + diff --git a/test/lib/core/httpclient.test.js b/test/lib/core/httpclient.test.js index 8bc26be14c..3004dacb01 100644 --- a/test/lib/core/httpclient.test.js +++ b/test/lib/core/httpclient.test.js @@ -382,6 +382,53 @@ describe('test/lib/core/httpclient.test.js', () => { assert(err.message.includes('url should start with http, but got unknown url')); }); }); + + it('should Proxy be fully functional', () => { + const httpclient = app.httpclient; + + // Test get trap - method access + assert(typeof httpclient.request === 'function'); + assert(typeof httpclient.curl === 'function'); + assert(typeof httpclient.safeCurl === 'function'); + + // Test has trap - 'in' operator + assert('request' in httpclient); + assert('curl' in httpclient); + assert('safeCurl' in httpclient); + + const ownKeys = Reflect.ownKeys(httpclient); + assert(ownKeys.length > 0); + + httpclient.testProp = 'test'; + const customDescriptor = Object.getOwnPropertyDescriptor(httpclient, 'testProp'); + assert(customDescriptor); + assert.equal(customDescriptor.value, 'test'); + assert.equal(customDescriptor.writable, true); + assert.equal(customDescriptor.enumerable, true); + assert.equal(customDescriptor.configurable, true); + + const proto = Object.getPrototypeOf(httpclient); + assert(proto); + assert(proto instanceof HttpclientNext); + assert(proto.constructor); + + httpclient.customProperty = 'test-value'; + assert.equal(httpclient.customProperty, 'test-value'); + + delete httpclient.customProperty; + assert.equal(httpclient.customProperty, undefined); + + delete httpclient.testProp; + assert.equal(httpclient.testProp, undefined); + + // Test that methods are properly bound + const { request } = httpclient; + assert(typeof request === 'function'); + + // Test Object.getOwnPropertyNames() which uses ownKeys trap + const propNames = Object.getOwnPropertyNames(httpclient); + assert(Array.isArray(propNames)); + }); }); describe('overwrite httpclient support allowH2=true', () => {