Skip to content

Commit a1169f3

Browse files
authored
feat: httpclient_next proxy (#5763)
lazy initialize httpclient_next, since plugins may have accessed app.httpclient ealier than config.httpclient.lookup modification
1 parent 13d9a19 commit a1169f3

File tree

3 files changed

+209
-3
lines changed

3 files changed

+209
-3
lines changed

‎lib/egg.js‎

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -297,18 +297,62 @@ class EggApplication extends EggCore {
297297
*/
298298
createHttpClient(options = {}) {
299299
let httpClient;
300-
options.lookup = options.lookup ?? this.config.httpclient.lookup;
301-
302300
if (this.config.httpclient.useHttpClientNext || this.config.httpclient.allowH2) {
303-
httpClient = new this.HttpClientNext(this, options);
301+
httpClient = this._createHttpClientNextProxy(options);
304302
} else if (this.config.httpclient?.enableDNSCache) {
305303
httpClient = new DNSCacheHttpClient(this, options);
306304
} else {
305+
options.lookup = options.lookup ?? this.config.httpclient.lookup;
307306
httpClient = new this.HttpClient(this, options);
308307
}
309308
return httpClient;
310309
}
311310

311+
_createHttpClientNextProxy(options = {}) {
312+
const self = this;
313+
let realClient = null;
314+
const init = () => {
315+
if (realClient) return;
316+
options.lookup = options.lookup ?? self.config.httpclient.lookup;
317+
realClient = new self.HttpClientNext(self, options);
318+
};
319+
return new Proxy({}, {
320+
get(_target, prop) {
321+
init();
322+
const value = realClient[prop];
323+
if (typeof value === 'function') {
324+
return value.bind(realClient);
325+
}
326+
return value;
327+
},
328+
set(_target, prop, value) {
329+
init();
330+
realClient[prop] = value;
331+
return true;
332+
},
333+
has(_target, prop) {
334+
init();
335+
return prop in realClient;
336+
},
337+
ownKeys() {
338+
init();
339+
return Reflect.ownKeys(realClient);
340+
},
341+
getOwnPropertyDescriptor(_target, prop) {
342+
init();
343+
return Object.getOwnPropertyDescriptor(realClient, prop);
344+
},
345+
deleteProperty(_target, prop) {
346+
init();
347+
return delete realClient[prop];
348+
},
349+
getPrototypeOf() {
350+
init();
351+
return Object.getPrototypeOf(realClient);
352+
},
353+
});
354+
}
355+
312356
/**
313357
* HttpClient instance
314358
* @see https://github.com/node-modules/urllib

‎test/lib/core/dns_resolver.test.js‎

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
const utils = require('../../utils');
2+
const mm = require('egg-mock');
3+
const dns = require('dns');
4+
const dnsPromise = require('dns').promises;
25
const assert = require('assert');
36

47
describe('test/lib/core/dns_resolver.test.js', () => {
@@ -22,6 +25,8 @@ describe('test/lib/core/dns_resolver.test.js', () => {
2225
url2 = url2.replace('127.0.0.2', 'localhost');
2326
});
2427

28+
afterEach(mm.restore);
29+
2530
after(() => {
2631
if (server1?.server?.listening) server1.server.close();
2732
if (server2?.server?.listening) server2.server.close();
@@ -76,4 +81,114 @@ describe('test/lib/core/dns_resolver.test.js', () => {
7681
assert(err.message.includes('fetch failed'));
7782
}
7883
});
84+
85+
it('should not fail even if dns.lookup fail, because lookup is overridden', async () => {
86+
mm.error(dnsPromise, 'lookup', 'mock dns lookup error');
87+
const res = await app.curl(url2 + '/get_headers', { dataType: 'json' });
88+
assert(res.status === 200);
89+
});
90+
});
91+
92+
describe('test/lib/core/dns_resolver with dns error', () => {
93+
let server;
94+
let app;
95+
let url;
96+
let originalDNSServers;
97+
98+
const cache = new Map();
99+
before(async () => {
100+
server = await utils.startNewLocalServer('127.0.0.1');
101+
if (!server) {
102+
throw new Error('start local server failed');
103+
}
104+
app = utils.app('apps/dns_resolver');
105+
await app.ready();
106+
app.config.httpclient.lookup = function(hostname, options, callback) {
107+
if (cache.has(hostname)) {
108+
const record = cache.get(hostname);
109+
if (options && options.all) {
110+
const addresses = record.map(r => ({ address: r.address, family: 4 }));
111+
callback(null, addresses);
112+
return;
113+
}
114+
callback(null, record[0].address, 4);
115+
return;
116+
}
117+
dnsPromise.resolve4(hostname, { ttl: true }).then(addresses => {
118+
if (addresses && addresses.length !== 0) {
119+
if (Array.isArray(addresses)) {
120+
cache.set(hostname, addresses);
121+
} else {
122+
cache.set(hostname, [ addresses ]);
123+
}
124+
if (options && options.all) {
125+
const addrList = addresses.map(r => ({ address: r.address, family: 4 }));
126+
callback(null, addrList);
127+
return;
128+
}
129+
callback(null, addresses[0].address, 4);
130+
} else {
131+
callback(new Error('no addresses found'));
132+
}
133+
}).catch(err => {
134+
callback(err);
135+
});
136+
137+
};
138+
url = server.url;
139+
url = url.replace('127.0.0.1', 'localhost');
140+
originalDNSServers = dns.getServers();
141+
dns.setServers([ '223.5.5.5', '223.6.6.6' ]);
142+
});
143+
144+
afterEach(mm.restore);
145+
146+
after(() => {
147+
dns.setServers(originalDNSServers);
148+
if (server?.server?.listening) server.server.close();
149+
});
150+
151+
152+
it('should cache work when dns fails', async () => {
153+
const res1 = await app.curl(url + '/get_headers', { dataType: 'json' });
154+
assert(res1.status === 200);
155+
assert(cache.has('localhost'));
156+
157+
mm.error(dnsPromise, 'resolve4', 'mock dns lookup error');
158+
const res2 = await app.curl(url + '/get_headers', { dataType: 'json' });
159+
assert(res2.status === 200);
160+
cache.delete('localhost');
161+
// should fail now
162+
// assert.error(app.curl(url + '/get_headers', { dataType: 'json' }))
163+
let shouldFail = false;
164+
try {
165+
await app.curl(url + '/get_headers', { dataType: 'json' });
166+
} catch (err) {
167+
shouldFail = true;
168+
assert(err.message.includes('mock dns lookup error'));
169+
}
170+
assert(shouldFail);
171+
});
172+
173+
it('should cache work when name server fails', async () => {
174+
const successRes = await app.curl(url + '/get_headers', { dataType: 'json' });
175+
assert(successRes.status === 200);
176+
assert(cache.has('localhost'));
177+
// can't resolve localhost now, but cache still works
178+
dns.setServers([ '8.8.8.8' ]);
179+
const res = await app.curl(url + '/get_headers', { dataType: 'json' });
180+
assert(res.status === 200);
181+
182+
// clear cache, should fail now
183+
cache.delete('localhost');
184+
let shouldFail = false;
185+
try {
186+
await app.curl(url + '/get_headers', { dataType: 'json' });
187+
} catch (err) {
188+
shouldFail = true;
189+
assert(err.message.includes('queryA ENOTFOUND localhost'));
190+
}
191+
assert(shouldFail);
192+
});
79193
});
194+

‎test/lib/core/httpclient.test.js‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,53 @@ describe('test/lib/core/httpclient.test.js', () => {
382382
assert(err.message.includes('url should start with http, but got unknown url'));
383383
});
384384
});
385+
386+
it('should Proxy be fully functional', () => {
387+
const httpclient = app.httpclient;
388+
389+
// Test get trap - method access
390+
assert(typeof httpclient.request === 'function');
391+
assert(typeof httpclient.curl === 'function');
392+
assert(typeof httpclient.safeCurl === 'function');
393+
394+
// Test has trap - 'in' operator
395+
assert('request' in httpclient);
396+
assert('curl' in httpclient);
397+
assert('safeCurl' in httpclient);
398+
399+
const ownKeys = Reflect.ownKeys(httpclient);
400+
assert(ownKeys.length > 0);
401+
402+
httpclient.testProp = 'test';
403+
const customDescriptor = Object.getOwnPropertyDescriptor(httpclient, 'testProp');
404+
assert(customDescriptor);
405+
assert.equal(customDescriptor.value, 'test');
406+
assert.equal(customDescriptor.writable, true);
407+
assert.equal(customDescriptor.enumerable, true);
408+
assert.equal(customDescriptor.configurable, true);
409+
410+
const proto = Object.getPrototypeOf(httpclient);
411+
assert(proto);
412+
assert(proto instanceof HttpclientNext);
413+
assert(proto.constructor);
414+
415+
httpclient.customProperty = 'test-value';
416+
assert.equal(httpclient.customProperty, 'test-value');
417+
418+
delete httpclient.customProperty;
419+
assert.equal(httpclient.customProperty, undefined);
420+
421+
delete httpclient.testProp;
422+
assert.equal(httpclient.testProp, undefined);
423+
424+
// Test that methods are properly bound
425+
const { request } = httpclient;
426+
assert(typeof request === 'function');
427+
428+
// Test Object.getOwnPropertyNames() which uses ownKeys trap
429+
const propNames = Object.getOwnPropertyNames(httpclient);
430+
assert(Array.isArray(propNames));
431+
});
385432
});
386433

387434
describe('overwrite httpclient support allowH2=true', () => {

0 commit comments

Comments
 (0)