nstarter-http-request 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/dist/cjs/adapter/axios.adapter.js +93 -1
- package/dist/cjs/adapter/axios.adapter.js.map +1 -1
- package/dist/cjs/adapter/base.js +20 -1
- package/dist/cjs/adapter/base.js.map +1 -1
- package/dist/cjs/adapter/undici.adapter.js +85 -73
- package/dist/cjs/adapter/undici.adapter.js.map +1 -1
- package/dist/cjs/client.js +99 -56
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/const/config.const.js +7 -1
- package/dist/cjs/const/config.const.js.map +1 -1
- package/dist/cjs/tsconfig.tsbuildinfo +1 -1
- package/dist/cjs/types/errors.js +47 -2
- package/dist/cjs/types/errors.js.map +1 -1
- package/dist/cjs/utils/index.js +2 -1
- package/dist/cjs/utils/index.js.map +1 -1
- package/dist/cjs/utils/redirect.js +138 -0
- package/dist/cjs/utils/redirect.js.map +1 -0
- package/dist/esm/adapter/axios.adapter.js +95 -3
- package/dist/esm/adapter/axios.adapter.js.map +1 -1
- package/dist/esm/adapter/base.js +20 -1
- package/dist/esm/adapter/base.js.map +1 -1
- package/dist/esm/adapter/undici.adapter.js +86 -74
- package/dist/esm/adapter/undici.adapter.js.map +1 -1
- package/dist/esm/client.js +100 -57
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/const/config.const.js +6 -0
- package/dist/esm/const/config.const.js.map +1 -1
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/esm/types/errors.js +44 -1
- package/dist/esm/types/errors.js.map +1 -1
- package/dist/esm/utils/index.js +1 -0
- package/dist/esm/utils/index.js.map +1 -1
- package/dist/esm/utils/redirect.js +133 -0
- package/dist/esm/utils/redirect.js.map +1 -0
- package/dist/types/adapter/axios.adapter.d.ts +22 -0
- package/dist/types/adapter/axios.adapter.d.ts.map +1 -1
- package/dist/types/adapter/base.d.ts +6 -0
- package/dist/types/adapter/base.d.ts.map +1 -1
- package/dist/types/adapter/undici.adapter.d.ts +20 -11
- package/dist/types/adapter/undici.adapter.d.ts.map +1 -1
- package/dist/types/client.d.ts +11 -0
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/const/config.const.d.ts +6 -0
- package/dist/types/const/config.const.d.ts.map +1 -1
- package/dist/types/types/errors.d.ts +39 -1
- package/dist/types/types/errors.d.ts.map +1 -1
- package/dist/types/utils/index.d.ts +1 -0
- package/dist/types/utils/index.d.ts.map +1 -1
- package/dist/types/utils/redirect.d.ts +26 -0
- package/dist/types/utils/redirect.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/adapter/axios.adapter.ts +124 -3
- package/src/adapter/base.ts +22 -1
- package/src/adapter/undici.adapter.ts +99 -92
- package/src/client.ts +114 -63
- package/src/const/config.const.ts +7 -0
- package/src/types/errors.ts +64 -1
- package/src/utils/index.ts +1 -0
- package/src/utils/redirect.ts +163 -0
|
@@ -4,10 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
HttpRequestOptions,
|
|
7
|
-
HttpResponse
|
|
8
|
-
|
|
7
|
+
HttpResponse
|
|
8
|
+
} from '../types';
|
|
9
|
+
import {
|
|
10
|
+
HttpError,
|
|
11
|
+
RequestTimeoutError,
|
|
12
|
+
ResponseTooLargeError
|
|
9
13
|
} from '../types';
|
|
10
|
-
import { HttpError } from '../types';
|
|
11
14
|
import { BaseHttpAdapter } from './base';
|
|
12
15
|
import undici, {
|
|
13
16
|
Agent,
|
|
@@ -16,6 +19,7 @@ import undici, {
|
|
|
16
19
|
} from 'undici';
|
|
17
20
|
import { STATUS_CODES } from 'http';
|
|
18
21
|
import { EClientAdapter } from '../const';
|
|
22
|
+
import { RedirectUtils } from '../utils';
|
|
19
23
|
|
|
20
24
|
interface RequestTimeoutState {
|
|
21
25
|
signal?: AbortSignal;
|
|
@@ -69,7 +73,8 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
69
73
|
timeoutState.cleanup();
|
|
70
74
|
}
|
|
71
75
|
|
|
72
|
-
|
|
76
|
+
// 读取过程中会按 maxResponseBodySize 提前拒绝/中断,超限时不会把完整响应体读入内存
|
|
77
|
+
const data = await this.readResponseBody<T>(response, options);
|
|
73
78
|
|
|
74
79
|
const httpResponse: HttpResponse<T> = {
|
|
75
80
|
status: response.statusCode,
|
|
@@ -79,23 +84,18 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
79
84
|
config: options
|
|
80
85
|
};
|
|
81
86
|
|
|
82
|
-
this.ensureResponseBodySize(httpResponse, options.responseType);
|
|
83
87
|
this.ensureSuccessStatus(httpResponse);
|
|
84
88
|
|
|
85
89
|
return httpResponse;
|
|
86
90
|
} catch (error: unknown) {
|
|
87
91
|
if (timeoutState.timedOut) {
|
|
88
|
-
throw new
|
|
89
|
-
`timeout of ${ timeoutState.timeout }ms exceeded`,
|
|
90
|
-
'ECONNABORTED',
|
|
91
|
-
undefined,
|
|
92
|
-
undefined,
|
|
93
|
-
options
|
|
94
|
-
);
|
|
92
|
+
throw new RequestTimeoutError(timeoutState.timeout, options, error);
|
|
95
93
|
}
|
|
96
94
|
throw this.handleUndiciError(error, options);
|
|
97
95
|
} finally {
|
|
98
96
|
timeoutState.cleanup();
|
|
97
|
+
// buildDispatcher 为每个请求单独创建 Agent/ProxyAgent,用完需要关闭,否则连接池与 keep-alive 连接会一直保留。
|
|
98
|
+
dispatcher?.close().catch(() => undefined);
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -199,7 +199,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
199
199
|
const agent = new ProxyAgent({
|
|
200
200
|
uri: proxyUrl,
|
|
201
201
|
connectTimeout: timeout,
|
|
202
|
-
|
|
202
|
+
requestTls: {
|
|
203
203
|
rejectUnauthorized: this.securityConfig.rejectUnauthorized
|
|
204
204
|
}
|
|
205
205
|
});
|
|
@@ -223,7 +223,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
223
223
|
const agent = new ProxyAgent({
|
|
224
224
|
uri: proxyUrl,
|
|
225
225
|
connectTimeout: timeout,
|
|
226
|
-
|
|
226
|
+
requestTls: {
|
|
227
227
|
rejectUnauthorized: this.securityConfig.rejectUnauthorized
|
|
228
228
|
}
|
|
229
229
|
});
|
|
@@ -338,10 +338,14 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
338
338
|
}
|
|
339
339
|
|
|
340
340
|
/**
|
|
341
|
-
*
|
|
341
|
+
* 2xx 和 304 作为成功结果返回。undici 未跟跳的 3xx 交回调用方。
|
|
342
342
|
*/
|
|
343
343
|
private ensureSuccessStatus(response: HttpResponse): void {
|
|
344
|
-
if (
|
|
344
|
+
if (
|
|
345
|
+
(response.status >= 200 && response.status < 300)
|
|
346
|
+
|| response.status === 304
|
|
347
|
+
|| RedirectUtils.isRedirectStatus(response.status)
|
|
348
|
+
) {
|
|
345
349
|
return;
|
|
346
350
|
}
|
|
347
351
|
|
|
@@ -355,119 +359,122 @@ export class UndiciAdapter extends BaseHttpAdapter {
|
|
|
355
359
|
}
|
|
356
360
|
|
|
357
361
|
/**
|
|
358
|
-
*
|
|
362
|
+
* 解析 Content-Length 响应头。仅接受合法的非负整数,重复头/非法值视为未知。
|
|
359
363
|
*/
|
|
360
|
-
private
|
|
361
|
-
if (
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const maxResponseBodySize = this.requestConfig.maxResponseBodySize;
|
|
365
|
-
if (
|
|
366
|
-
typeof maxResponseBodySize !== 'number' ||
|
|
367
|
-
!Number.isFinite(maxResponseBodySize) ||
|
|
368
|
-
maxResponseBodySize <= 0
|
|
369
|
-
) {
|
|
370
|
-
// NsHttpClient 会先做配置合并和校验;这里防御直接实例化 Adapter 的异常配置。
|
|
371
|
-
return;
|
|
372
|
-
}
|
|
373
|
-
const bodySize = this.getBodySize(response.data);
|
|
374
|
-
if (bodySize <= maxResponseBodySize) {
|
|
375
|
-
return;
|
|
364
|
+
private parseContentLength(value: string | string[] | undefined): number | undefined {
|
|
365
|
+
if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) {
|
|
366
|
+
return undefined;
|
|
376
367
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
'ERR_BAD_RESPONSE',
|
|
380
|
-
response.status,
|
|
381
|
-
response,
|
|
382
|
-
response.config
|
|
383
|
-
);
|
|
368
|
+
const length = Number(value.trim());
|
|
369
|
+
return Number.isSafeInteger(length) ? length : undefined;
|
|
384
370
|
}
|
|
385
371
|
|
|
386
372
|
/**
|
|
387
|
-
*
|
|
373
|
+
* 丢弃未读完的响应体并终止下载。
|
|
374
|
+
* undici 的 BodyReadable.destroy() 不带错误时会以 RequestAbortedError 触发 error 事件,
|
|
375
|
+
* 没有监听器会变成未捕获异常,因此先挂空监听器。
|
|
388
376
|
*/
|
|
389
|
-
private
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
377
|
+
private discardBody(response: Dispatcher.ResponseData): void {
|
|
378
|
+
response.body.on('error', () => undefined);
|
|
379
|
+
response.body.destroy();
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 带大小上限地读取非流式响应体。
|
|
384
|
+
*
|
|
385
|
+
* 1. Content-Length 预检:响应头已声明超限时,不读取响应体,直接销毁并拒绝。
|
|
386
|
+
* 2. 逐分块累计字节数,累计值超过上限(等于上限放行)立即销毁响应流并拒绝,后续数据不再下载;
|
|
387
|
+
* 该行为对齐 axios(Node http 适配器)的 maxContentLength。
|
|
388
|
+
*/
|
|
389
|
+
private async readBodyWithLimit(response: Dispatcher.ResponseData, options: HttpRequestOptions): Promise<Buffer> {
|
|
390
|
+
const maxRespBodySize = this.resolveMaxResponseBodySize();
|
|
391
|
+
|
|
392
|
+
// HEAD / 204 / 304 没有响应体,其 Content-Length 描述的是对应实体而不是本次响应体:
|
|
393
|
+
// 不能用于预检;服务端携带时 undici 还会一直等待并不存在的响应体,因此不进入读取,直接丢弃。
|
|
394
|
+
if (options.method === 'HEAD' || response.statusCode === 204 || response.statusCode === 304) {
|
|
395
|
+
this.discardBody(response);
|
|
396
|
+
return Buffer.alloc(0);
|
|
401
397
|
}
|
|
402
|
-
|
|
403
|
-
|
|
398
|
+
|
|
399
|
+
if (maxRespBodySize !== undefined) {
|
|
400
|
+
const contentLength = this.parseContentLength(response.headers['content-length']);
|
|
401
|
+
if (contentLength !== undefined && contentLength > maxRespBodySize) {
|
|
402
|
+
this.discardBody(response);
|
|
403
|
+
throw new ResponseTooLargeError(maxRespBodySize, options, response.statusCode);
|
|
404
|
+
}
|
|
404
405
|
}
|
|
405
|
-
|
|
406
|
-
|
|
406
|
+
|
|
407
|
+
const chunks: Buffer[] = [];
|
|
408
|
+
let total = 0;
|
|
409
|
+
for await (const chunk of response.body) {
|
|
410
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
411
|
+
total += buffer.byteLength;
|
|
412
|
+
if (maxRespBodySize !== undefined && total > maxRespBodySize) {
|
|
413
|
+
this.discardBody(response);
|
|
414
|
+
throw new ResponseTooLargeError(maxRespBodySize, options, response.statusCode);
|
|
415
|
+
}
|
|
416
|
+
chunks.push(buffer);
|
|
407
417
|
}
|
|
408
|
-
return Buffer.
|
|
418
|
+
return Buffer.concat(chunks, total);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 基于已读取(已受大小限制)的字节构造 Response,复用运行时的 blob/formData 解析逻辑。
|
|
423
|
+
*/
|
|
424
|
+
private toFetchResponse(buffer: Buffer, response: Dispatcher.ResponseData): Response {
|
|
425
|
+
const contentType = response.headers['content-type'];
|
|
426
|
+
return new Response(buffer, {
|
|
427
|
+
headers: typeof contentType === 'string' ? { 'content-type': contentType } : undefined
|
|
428
|
+
});
|
|
409
429
|
}
|
|
410
430
|
|
|
411
431
|
/**
|
|
412
432
|
* 读取响应体
|
|
413
433
|
* @param response Undici 响应对象
|
|
414
|
-
* @param
|
|
434
|
+
* @param options 请求选项
|
|
415
435
|
*/
|
|
416
|
-
private async readResponseBody<T>(response: Dispatcher.ResponseData,
|
|
436
|
+
private async readResponseBody<T>(response: Dispatcher.ResponseData, options: HttpRequestOptions): Promise<T> {
|
|
437
|
+
if (options.responseType === 'stream') {
|
|
438
|
+
// undici 返回 BodyReadable (继承自 Node.js Readable),流式响应由调用方自行消费,不受 maxResponseBodySize 限制
|
|
439
|
+
return response.body as T;
|
|
440
|
+
}
|
|
441
|
+
|
|
417
442
|
try {
|
|
418
|
-
|
|
443
|
+
const buffer = await this.readBodyWithLimit(response, options);
|
|
444
|
+
const decoder = new TextDecoder();
|
|
445
|
+
switch (options.responseType) {
|
|
419
446
|
case 'text': {
|
|
420
|
-
return
|
|
447
|
+
return decoder.decode(buffer) as T;
|
|
421
448
|
}
|
|
422
449
|
case 'arraybuffer': {
|
|
423
|
-
return
|
|
424
|
-
}
|
|
425
|
-
case 'stream': {
|
|
426
|
-
// undici 返回 BodyReadable (继承自 Node.js Readable)
|
|
427
|
-
return response.body as T;
|
|
450
|
+
return buffer as T;
|
|
428
451
|
}
|
|
429
452
|
case 'blob': {
|
|
430
|
-
return (await response.
|
|
453
|
+
return (await this.toFetchResponse(buffer, response).blob()) as T;
|
|
431
454
|
}
|
|
432
455
|
case 'formdata': {
|
|
433
|
-
return (await response.
|
|
434
|
-
}
|
|
435
|
-
case 'json': {
|
|
436
|
-
return await this.readJsonResponseBody<T>(response);
|
|
456
|
+
return (await this.toFetchResponse(buffer, response).formData()) as T;
|
|
437
457
|
}
|
|
458
|
+
case 'json':
|
|
438
459
|
default: {
|
|
439
|
-
|
|
460
|
+
// 按 axios 的默认兼容行为:空响应体或非 JSON 文本不应让成功状态码请求因为解析失败变成异常
|
|
461
|
+
const text = decoder.decode(buffer);
|
|
440
462
|
try {
|
|
441
463
|
return JSON.parse(text) as T;
|
|
442
464
|
} catch {
|
|
443
|
-
// 如果不是有效的 JSON,返回原文本
|
|
444
465
|
return text as T;
|
|
445
466
|
}
|
|
446
467
|
}
|
|
447
|
-
|
|
448
468
|
}
|
|
449
469
|
} catch (error) {
|
|
470
|
+
// 响应体超限错误需要原样抛出,保留错误码、文案与不可重试标记
|
|
471
|
+
if (error instanceof ResponseTooLargeError) {
|
|
472
|
+
throw error;
|
|
473
|
+
}
|
|
450
474
|
throw new Error(`Failed to read response body: ${ error instanceof Error ? error.message : String(error) }`);
|
|
451
475
|
}
|
|
452
476
|
}
|
|
453
477
|
|
|
454
|
-
/**
|
|
455
|
-
* 按 axios 的默认兼容行为读取 JSON 响应。
|
|
456
|
-
* 空响应体或非 JSON 文本不应让成功状态码请求因为解析失败变成异常。
|
|
457
|
-
*/
|
|
458
|
-
private async readJsonResponseBody<T>(response: Dispatcher.ResponseData): Promise<T> {
|
|
459
|
-
const text = await response.body.text();
|
|
460
|
-
if (!text) {
|
|
461
|
-
return text as T;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
try {
|
|
465
|
-
return JSON.parse(text) as T;
|
|
466
|
-
} catch {
|
|
467
|
-
return text as T;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
478
|
/**
|
|
472
479
|
* 获取状态文本
|
|
473
480
|
* @param statusCode 状态码
|
package/src/client.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { EClientAdapter } from './const';
|
|
|
32
32
|
import { HttpError } from './types';
|
|
33
33
|
import {
|
|
34
34
|
DomainUtils,
|
|
35
|
+
RedirectUtils,
|
|
35
36
|
TraceContextUtils
|
|
36
37
|
} from './utils';
|
|
37
38
|
import {
|
|
@@ -39,6 +40,7 @@ import {
|
|
|
39
40
|
pipeline,
|
|
40
41
|
Readable
|
|
41
42
|
} from 'node:stream';
|
|
43
|
+
import { finished } from 'node:stream/promises';
|
|
42
44
|
import { MetricsTransform } from './stream';
|
|
43
45
|
|
|
44
46
|
type ForwardProxyDecision = {
|
|
@@ -189,84 +191,120 @@ export class NsHttpClient implements IHttpClient {
|
|
|
189
191
|
const startTime = Date.now();
|
|
190
192
|
|
|
191
193
|
try {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
skipDnsValidationReason: forwardProxyDecision.shouldProxy ? 'forward_proxy' : undefined
|
|
200
|
-
}
|
|
201
|
-
);
|
|
194
|
+
return await this.safeRequest<T>(options, this.config.request.maxRedirects);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
// 记录错误日志
|
|
197
|
+
this.logError(options, error, Date.now() - startTime);
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
202
201
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
202
|
+
/**
|
|
203
|
+
*
|
|
204
|
+
* @param options 请求选项
|
|
205
|
+
* @param remainedRedirects 剩余可重定向次数
|
|
206
|
+
* @returns 响应结果
|
|
207
|
+
*/
|
|
208
|
+
private async safeRequest<T>(options: HttpRequestOptions, remainedRedirects: number): Promise<HttpResponse<T>> {
|
|
209
|
+
const startTime = Date.now();
|
|
210
|
+
const forwardProxyDecision = this.resolveForwardProxyDecision(options.url);
|
|
211
211
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
212
|
+
// 安全验证
|
|
213
|
+
const { safeUrl, result: ssrfResult } = await this.ssrfGuard.validateAndGetSafeUrl(
|
|
214
|
+
options.url,
|
|
215
|
+
{
|
|
216
|
+
skipDnsValidation: forwardProxyDecision.shouldProxy,
|
|
217
|
+
skipDnsValidationReason: forwardProxyDecision.shouldProxy ? 'forward_proxy' : undefined
|
|
217
218
|
}
|
|
219
|
+
);
|
|
218
220
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
221
|
+
this.logger.debug('URL validation passed', {
|
|
222
|
+
originalUrl: options.url,
|
|
223
|
+
safeUrl,
|
|
224
|
+
resolvedIps: ssrfResult.resolvedIps,
|
|
225
|
+
dnsValidationSkipped: ssrfResult.dnsValidationSkipped,
|
|
226
|
+
dnsValidationSkipReason: ssrfResult.dnsValidationSkipReason,
|
|
227
|
+
scenario: options.scenario
|
|
228
|
+
});
|
|
224
229
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
});
|
|
232
|
-
} else {
|
|
233
|
-
safeOptions.headers = {
|
|
234
|
-
...safeOptions.headers,
|
|
235
|
-
traceparent: options.traceparent
|
|
236
|
-
};
|
|
230
|
+
if (ssrfResult.dnsValidationSkipped && forwardProxyDecision.shouldProxy) {
|
|
231
|
+
this.logger.debug('Skipped target DNS validation because request uses forward proxy', {
|
|
232
|
+
hostname: forwardProxyDecision.hostname,
|
|
233
|
+
proxyUrl: forwardProxyDecision.proxyUrl
|
|
234
|
+
});
|
|
235
|
+
}
|
|
237
236
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
237
|
+
// 使用安全的 URL 发起请求
|
|
238
|
+
const safeOptions: HttpRequestOptions = {
|
|
239
|
+
...options,
|
|
240
|
+
url: safeUrl
|
|
241
|
+
};
|
|
244
242
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
243
|
+
// 处理 W3C Trace Context traceparent 头
|
|
244
|
+
if (options.traceparent) {
|
|
245
|
+
// 验证 traceparent 格式
|
|
246
|
+
if (!TraceContextUtils.isValidTraceParent(options.traceparent)) {
|
|
247
|
+
this.logger.warn('Invalid traceparent format, ignoring', {
|
|
248
|
+
traceparent: options.traceparent
|
|
249
|
+
});
|
|
250
|
+
} else {
|
|
251
|
+
safeOptions.headers = {
|
|
252
|
+
...safeOptions.headers,
|
|
253
|
+
traceparent: options.traceparent
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
this.logger.debug('Traceparent header added', {
|
|
257
|
+
traceparent: options.traceparent,
|
|
258
|
+
sampled: TraceContextUtils.isSampled(options.traceparent)
|
|
259
|
+
});
|
|
248
260
|
}
|
|
261
|
+
}
|
|
249
262
|
|
|
250
|
-
|
|
251
|
-
|
|
263
|
+
// 应用正向代理配置
|
|
264
|
+
if (this.forwardProxy) {
|
|
265
|
+
this.applyForwardProxy(safeOptions, forwardProxyDecision);
|
|
266
|
+
}
|
|
252
267
|
|
|
253
|
-
|
|
254
|
-
|
|
268
|
+
// 发起请求
|
|
269
|
+
const response = await this.adapter.request<T>(safeOptions);
|
|
255
270
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
271
|
+
// 需要重定向时,继续跟跳
|
|
272
|
+
if (RedirectUtils.isRedirectStatus(response.status)) {
|
|
273
|
+
try {
|
|
274
|
+
if (remainedRedirects === undefined || remainedRedirects <= 0) {
|
|
275
|
+
throw new Error('max redirects');
|
|
276
|
+
}
|
|
277
|
+
const redirectOptions = RedirectUtils.buildRedirectOptions(
|
|
278
|
+
safeOptions,
|
|
279
|
+
response,
|
|
280
|
+
this.adapter.name,
|
|
281
|
+
this.config.request.timeout
|
|
282
|
+
);
|
|
283
|
+
if (redirectOptions) {
|
|
284
|
+
RedirectUtils.consumeRedirectTimeout(redirectOptions, startTime);
|
|
285
|
+
// 下一跳前,把中间响应的流先丢掉,以免占用连接
|
|
286
|
+
await this.discardResponseStream(response);
|
|
287
|
+
const nextRemainedRedirects = remainedRedirects - 1;
|
|
288
|
+
return await this.safeRequest<T>(redirectOptions, nextRemainedRedirects);
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
await this.discardResponseStream(response);
|
|
292
|
+
throw error;
|
|
259
293
|
}
|
|
294
|
+
}
|
|
260
295
|
|
|
261
|
-
|
|
262
|
-
|
|
296
|
+
// 首次响应时间(TTFB)
|
|
297
|
+
const ttfb = Date.now() - startTime;
|
|
263
298
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
this.logError(options, error, Date.now() - startTime);
|
|
268
|
-
throw error;
|
|
299
|
+
// 如果是流式响应,包装流对象以追踪完整生命周期
|
|
300
|
+
if (options.responseType === 'stream' && this.isStreamResponse(response.data)) {
|
|
301
|
+
return this.wrapStreamResponse(safeOptions, response, startTime, ttfb);
|
|
269
302
|
}
|
|
303
|
+
|
|
304
|
+
// 记录非流式响应日志
|
|
305
|
+
this.logResponse(safeOptions, response, ttfb);
|
|
306
|
+
|
|
307
|
+
return response;
|
|
270
308
|
}
|
|
271
309
|
|
|
272
310
|
/**
|
|
@@ -354,6 +392,19 @@ export class NsHttpClient implements IHttpClient {
|
|
|
354
392
|
});
|
|
355
393
|
}
|
|
356
394
|
|
|
395
|
+
/**
|
|
396
|
+
* 销毁不再交给调用方的响应流
|
|
397
|
+
*/
|
|
398
|
+
private async discardResponseStream(response: HttpResponse): Promise<void> {
|
|
399
|
+
const data = response.data;
|
|
400
|
+
if (!(data instanceof Readable) || data.destroyed) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
const done = finished(data);
|
|
404
|
+
data.destroy();
|
|
405
|
+
await done.catch(() => undefined);
|
|
406
|
+
}
|
|
407
|
+
|
|
357
408
|
/**
|
|
358
409
|
* 判断响应数据是否为流对象(Node.js Readable)
|
|
359
410
|
*/
|
|
@@ -48,6 +48,13 @@ export const DEFAULT_REQUEST_CONFIG: RequestConfig = {
|
|
|
48
48
|
maxResponseBodySize: 50 * 1024 * 1024 // 50MB
|
|
49
49
|
};
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* 响应体大小预检的线上长度容差比例。
|
|
53
|
+
* axios 的 onDownloadProgress.total 是压缩后的 Content-Length,而 maxContentLength 按解压后计数;
|
|
54
|
+
* 不可压缩数据经 gzip 等压缩后可能略大于解压大小,预检仅在超过上限该比例时提前中止。
|
|
55
|
+
*/
|
|
56
|
+
export const MAX_RESPONSE_SIZE_TOLERANCE_RATIO = 0.01;
|
|
57
|
+
|
|
51
58
|
/**
|
|
52
59
|
* 默认 HTTP 客户端配置
|
|
53
60
|
*/
|
package/src/types/errors.ts
CHANGED
|
@@ -8,12 +8,21 @@ import type { HttpRequestOptions, HttpResponse } from './request_response';
|
|
|
8
8
|
* HTTP 错误
|
|
9
9
|
*/
|
|
10
10
|
export class HttpError extends Error {
|
|
11
|
+
/**
|
|
12
|
+
* @param message 错误信息
|
|
13
|
+
* @param code 错误码
|
|
14
|
+
* @param status HTTP 状态码
|
|
15
|
+
* @param response 响应(不应携带不再需要的大体积响应体)
|
|
16
|
+
* @param request 请求选项
|
|
17
|
+
* @param retriable 是否允许重试,默认 true;重试无法改变结果的确定性失败应传 false
|
|
18
|
+
*/
|
|
11
19
|
constructor(
|
|
12
20
|
message: string,
|
|
13
21
|
public readonly code: string,
|
|
14
22
|
public readonly status?: number,
|
|
15
23
|
public readonly response?: HttpResponse,
|
|
16
|
-
public readonly request?: HttpRequestOptions
|
|
24
|
+
public readonly request?: HttpRequestOptions,
|
|
25
|
+
public readonly retriable = true
|
|
17
26
|
) {
|
|
18
27
|
super(message);
|
|
19
28
|
this.name = 'HttpError';
|
|
@@ -21,6 +30,60 @@ export class HttpError extends Error {
|
|
|
21
30
|
}
|
|
22
31
|
}
|
|
23
32
|
|
|
33
|
+
/**
|
|
34
|
+
* 响应体超过 maxResponseBodySize 限制的错误。
|
|
35
|
+
*
|
|
36
|
+
* - 错误码与文案对齐 axios 的 maxContentLength(ERR_BAD_RESPONSE)。
|
|
37
|
+
* - 重试不会改变响应体大小,因此标记为不可重试,避免每次重试都重复下载。
|
|
38
|
+
* - 不携带 response:仅保留状态码,防止超限数据随错误对象存活(与 axios 抛出该错误时一致)。
|
|
39
|
+
*/
|
|
40
|
+
export class ResponseTooLargeError extends HttpError {
|
|
41
|
+
/**
|
|
42
|
+
* @param maxResponseBodySize 响应体大小上限
|
|
43
|
+
* @param request 请求选项
|
|
44
|
+
* @param status 响应状态码(未知时不传)
|
|
45
|
+
*/
|
|
46
|
+
constructor(
|
|
47
|
+
public readonly maxResponseBodySize: number,
|
|
48
|
+
request: HttpRequestOptions,
|
|
49
|
+
status?: number
|
|
50
|
+
) {
|
|
51
|
+
super(
|
|
52
|
+
`maxContentLength size of ${ maxResponseBodySize } exceeded`,
|
|
53
|
+
'ERR_BAD_RESPONSE',
|
|
54
|
+
status,
|
|
55
|
+
undefined,
|
|
56
|
+
request,
|
|
57
|
+
false
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 整次请求超时错误
|
|
64
|
+
*/
|
|
65
|
+
export class RequestTimeoutError extends HttpError {
|
|
66
|
+
/**
|
|
67
|
+
* @param timeout 整次请求的超时时间(毫秒)
|
|
68
|
+
* @param request 请求选项
|
|
69
|
+
* @param cause 底层错误
|
|
70
|
+
*/
|
|
71
|
+
constructor(
|
|
72
|
+
public readonly timeout: number | undefined,
|
|
73
|
+
request: HttpRequestOptions,
|
|
74
|
+
public readonly cause?: unknown
|
|
75
|
+
) {
|
|
76
|
+
// cause 保留底层错误,调用方可以据此判断超时发生在哪个阶段, 比如TCP 建连阶段超时为 UND_ERR_CONNECT_TIMEOUT
|
|
77
|
+
super(
|
|
78
|
+
`timeout of ${ timeout }ms exceeded`,
|
|
79
|
+
'ECONNABORTED',
|
|
80
|
+
undefined,
|
|
81
|
+
undefined,
|
|
82
|
+
request
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
24
87
|
/**
|
|
25
88
|
* 安全验证错误
|
|
26
89
|
*/
|
package/src/utils/index.ts
CHANGED