nstarter-http-request 0.1.11 → 0.1.13

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.
Files changed (72) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -2
  3. package/dist/cjs/adapter/axios.adapter.js +97 -2
  4. package/dist/cjs/adapter/axios.adapter.js.map +1 -1
  5. package/dist/cjs/adapter/base.js +20 -1
  6. package/dist/cjs/adapter/base.js.map +1 -1
  7. package/dist/cjs/adapter/undici.adapter.js +97 -77
  8. package/dist/cjs/adapter/undici.adapter.js.map +1 -1
  9. package/dist/cjs/client.js +99 -56
  10. package/dist/cjs/client.js.map +1 -1
  11. package/dist/cjs/config.js +1 -1
  12. package/dist/cjs/config.js.map +1 -1
  13. package/dist/cjs/const/config.const.js +8 -3
  14. package/dist/cjs/const/config.const.js.map +1 -1
  15. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  16. package/dist/cjs/types/errors.js +30 -2
  17. package/dist/cjs/types/errors.js.map +1 -1
  18. package/dist/cjs/utils/index.js +2 -1
  19. package/dist/cjs/utils/index.js.map +1 -1
  20. package/dist/cjs/utils/redirect.js +138 -0
  21. package/dist/cjs/utils/redirect.js.map +1 -0
  22. package/dist/esm/adapter/axios.adapter.js +99 -4
  23. package/dist/esm/adapter/axios.adapter.js.map +1 -1
  24. package/dist/esm/adapter/base.js +20 -1
  25. package/dist/esm/adapter/base.js.map +1 -1
  26. package/dist/esm/adapter/undici.adapter.js +98 -78
  27. package/dist/esm/adapter/undici.adapter.js.map +1 -1
  28. package/dist/esm/client.js +100 -57
  29. package/dist/esm/client.js.map +1 -1
  30. package/dist/esm/config.js +1 -1
  31. package/dist/esm/config.js.map +1 -1
  32. package/dist/esm/const/config.const.js +7 -2
  33. package/dist/esm/const/config.const.js.map +1 -1
  34. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  35. package/dist/esm/types/errors.js +28 -1
  36. package/dist/esm/types/errors.js.map +1 -1
  37. package/dist/esm/utils/index.js +1 -0
  38. package/dist/esm/utils/index.js.map +1 -1
  39. package/dist/esm/utils/redirect.js +133 -0
  40. package/dist/esm/utils/redirect.js.map +1 -0
  41. package/dist/types/adapter/axios.adapter.d.ts +22 -0
  42. package/dist/types/adapter/axios.adapter.d.ts.map +1 -1
  43. package/dist/types/adapter/base.d.ts +6 -0
  44. package/dist/types/adapter/base.d.ts.map +1 -1
  45. package/dist/types/adapter/undici.adapter.d.ts +24 -12
  46. package/dist/types/adapter/undici.adapter.d.ts.map +1 -1
  47. package/dist/types/client.d.ts +11 -0
  48. package/dist/types/client.d.ts.map +1 -1
  49. package/dist/types/const/config.const.d.ts +6 -0
  50. package/dist/types/const/config.const.d.ts.map +1 -1
  51. package/dist/types/types/config.d.ts +2 -2
  52. package/dist/types/types/config.d.ts.map +1 -1
  53. package/dist/types/types/errors.d.ts +26 -1
  54. package/dist/types/types/errors.d.ts.map +1 -1
  55. package/dist/types/types/request_response.d.ts +1 -1
  56. package/dist/types/types/request_response.d.ts.map +1 -1
  57. package/dist/types/utils/index.d.ts +1 -0
  58. package/dist/types/utils/index.d.ts.map +1 -1
  59. package/dist/types/utils/redirect.d.ts +26 -0
  60. package/dist/types/utils/redirect.d.ts.map +1 -0
  61. package/package.json +1 -1
  62. package/src/adapter/axios.adapter.ts +128 -4
  63. package/src/adapter/base.ts +22 -1
  64. package/src/adapter/undici.adapter.ts +112 -91
  65. package/src/client.ts +114 -63
  66. package/src/config.ts +1 -1
  67. package/src/const/config.const.ts +8 -2
  68. package/src/types/config.ts +2 -3
  69. package/src/types/errors.ts +39 -1
  70. package/src/types/request_response.ts +1 -2
  71. package/src/utils/index.ts +1 -0
  72. package/src/utils/redirect.ts +163 -0
@@ -4,10 +4,12 @@
4
4
 
5
5
  import type {
6
6
  HttpRequestOptions,
7
- HttpResponse,
8
- ResponseType,
7
+ HttpResponse
8
+ } from '../types';
9
+ import {
10
+ HttpError,
11
+ ResponseTooLargeError
9
12
  } from '../types';
10
- import { HttpError } from '../types';
11
13
  import { BaseHttpAdapter } from './base';
12
14
  import undici, {
13
15
  Agent,
@@ -16,6 +18,7 @@ import undici, {
16
18
  } from 'undici';
17
19
  import { STATUS_CODES } from 'http';
18
20
  import { EClientAdapter } from '../const';
21
+ import { RedirectUtils } from '../utils';
19
22
 
20
23
  interface RequestTimeoutState {
21
24
  signal?: AbortSignal;
@@ -50,7 +53,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
50
53
  const headers = this.prepareHeaders(options.headers);
51
54
  const body = this.prepareBody(options.body, headers);
52
55
  const timeoutState = this.createRequestTimeoutState(options);
53
- const dispatcher = this.buildDispatcher(options, timeoutState.timeout);
56
+ const dispatcher = this.buildDispatcher(options);
54
57
  const undiciOptions = this.buildUndiciOptions(options, headers, body, dispatcher, timeoutState);
55
58
 
56
59
  try {
@@ -69,7 +72,8 @@ export class UndiciAdapter extends BaseHttpAdapter {
69
72
  timeoutState.cleanup();
70
73
  }
71
74
 
72
- const data = await this.readResponseBody<T>(response, options.responseType);
75
+ // 读取过程中会按 maxResponseBodySize 提前拒绝/中断,超限时不会把完整响应体读入内存
76
+ const data = await this.readResponseBody<T>(response, options);
73
77
 
74
78
  const httpResponse: HttpResponse<T> = {
75
79
  status: response.statusCode,
@@ -79,7 +83,6 @@ export class UndiciAdapter extends BaseHttpAdapter {
79
83
  config: options
80
84
  };
81
85
 
82
- this.ensureResponseBodySize(httpResponse, options.responseType);
83
86
  this.ensureSuccessStatus(httpResponse);
84
87
 
85
88
  return httpResponse;
@@ -96,6 +99,8 @@ export class UndiciAdapter extends BaseHttpAdapter {
96
99
  throw this.handleUndiciError(error, options);
97
100
  } finally {
98
101
  timeoutState.cleanup();
102
+ // buildDispatcher 为每个请求单独创建 Agent/ProxyAgent,用完需要关闭,否则连接池与 keep-alive 连接会一直保留。
103
+ dispatcher?.close().catch(() => undefined);
99
104
  }
100
105
  }
101
106
 
@@ -153,15 +158,15 @@ export class UndiciAdapter extends BaseHttpAdapter {
153
158
  /**
154
159
  * 构建 Dispatcher(代理或 TLS 配置)
155
160
  * @param options 请求选项
156
- * @param timeout 请求总超时时间
157
161
  */
158
- private buildDispatcher(options: HttpRequestOptions, timeout?: number): Dispatcher | undefined {
162
+ private buildDispatcher(options: HttpRequestOptions): Dispatcher | undefined {
159
163
  let dispatcher: Dispatcher | undefined;
164
+ const timeout = this.resolveUndiciTimeout(options);
160
165
 
161
166
  // 如果 rejectUnauthorized 为 false,需要创建 Agent 来设置 TLS 选项
162
167
  if (!this.securityConfig.rejectUnauthorized) {
163
168
  dispatcher = new Agent({
164
- ...(this.isPositiveTimeout(timeout) ? { connectTimeout: timeout } : {}),
169
+ connectTimeout: timeout,
165
170
  connect: {
166
171
  rejectUnauthorized: false
167
172
  }
@@ -198,7 +203,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
198
203
 
199
204
  const agent = new ProxyAgent({
200
205
  uri: proxyUrl,
201
- ...(this.isPositiveTimeout(timeout) ? { connectTimeout: timeout } : {}),
206
+ connectTimeout: timeout,
202
207
  connect: {
203
208
  rejectUnauthorized: this.securityConfig.rejectUnauthorized
204
209
  }
@@ -222,7 +227,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
222
227
  private createForwardProxyAgent(proxyUrl: string, timeout?: number): ProxyAgent {
223
228
  const agent = new ProxyAgent({
224
229
  uri: proxyUrl,
225
- ...(this.isPositiveTimeout(timeout) ? { connectTimeout: timeout } : {}),
230
+ connectTimeout: timeout,
226
231
  connect: {
227
232
  rejectUnauthorized: this.securityConfig.rejectUnauthorized
228
233
  }
@@ -246,7 +251,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
246
251
  dispatcher?: Dispatcher,
247
252
  timeoutState?: RequestTimeoutState
248
253
  ): Parameters<typeof undici.request>[1] {
249
- const timeout = this.resolveTimeout(options);
254
+ const timeout = this.resolveUndiciTimeout(options);
250
255
  return {
251
256
  method: options.method as Dispatcher.HttpMethod,
252
257
  headers,
@@ -267,6 +272,7 @@ export class UndiciAdapter extends BaseHttpAdapter {
267
272
  const timeout = this.resolveTimeout(options);
268
273
  if (!this.isPositiveTimeout(timeout)) {
269
274
  return {
275
+ timeout,
270
276
  timedOut: false,
271
277
  cleanup: () => {}
272
278
  };
@@ -293,10 +299,18 @@ export class UndiciAdapter extends BaseHttpAdapter {
293
299
  /**
294
300
  * 获取请求级超时时间。
295
301
  */
296
- private resolveTimeout(options: HttpRequestOptions): number {
302
+ private resolveTimeout(options: HttpRequestOptions): number | undefined {
297
303
  return options.timeout ?? this.requestConfig.timeout;
298
304
  }
299
305
 
306
+ /**
307
+ * 获取传给 undici 的阶段超时时间。0 用于内部禁用 undici 默认阶段超时。
308
+ */
309
+ private resolveUndiciTimeout(options: HttpRequestOptions): number {
310
+ const timeout = this.resolveTimeout(options);
311
+ return this.isPositiveTimeout(timeout) ? timeout : 0;
312
+ }
313
+
300
314
  /**
301
315
  * 判断超时时间是否启用。
302
316
  */
@@ -329,10 +343,14 @@ export class UndiciAdapter extends BaseHttpAdapter {
329
343
  }
330
344
 
331
345
  /**
332
- * 按 axios 默认行为处理非 2xx 状态码。
346
+ * 2xx 和 304 作为成功结果返回。undici 未跟跳的 3xx 交回调用方。
333
347
  */
334
348
  private ensureSuccessStatus(response: HttpResponse): void {
335
- if (response.status >= 200 && response.status < 300) {
349
+ if (
350
+ (response.status >= 200 && response.status < 300)
351
+ || response.status === 304
352
+ || RedirectUtils.isRedirectStatus(response.status)
353
+ ) {
336
354
  return;
337
355
  }
338
356
 
@@ -346,119 +364,122 @@ export class UndiciAdapter extends BaseHttpAdapter {
346
364
  }
347
365
 
348
366
  /**
349
- * 按 axios maxContentLength 行为限制非流式响应体大小。
367
+ * 解析 Content-Length 响应头。仅接受合法的非负整数,重复头/非法值视为未知。
350
368
  */
351
- private ensureResponseBodySize(response: HttpResponse, responseType?: ResponseType): void {
352
- if (responseType === 'stream') {
353
- return;
354
- }
355
- const maxResponseBodySize = this.requestConfig.maxResponseBodySize;
356
- if (
357
- typeof maxResponseBodySize !== 'number' ||
358
- !Number.isFinite(maxResponseBodySize) ||
359
- maxResponseBodySize <= 0
360
- ) {
361
- // NsHttpClient 会先做配置合并和校验;这里防御直接实例化 Adapter 的异常配置。
362
- return;
363
- }
364
- const bodySize = this.getBodySize(response.data);
365
- if (bodySize <= maxResponseBodySize) {
366
- return;
369
+ private parseContentLength(value: string | string[] | undefined): number | undefined {
370
+ if (typeof value !== 'string' || !/^\d+$/.test(value.trim())) {
371
+ return undefined;
367
372
  }
368
- throw new HttpError(
369
- `maxContentLength size of ${ maxResponseBodySize } exceeded`,
370
- 'ERR_BAD_RESPONSE',
371
- response.status,
372
- response,
373
- response.config
374
- );
373
+ const length = Number(value.trim());
374
+ return Number.isSafeInteger(length) ? length : undefined;
375
375
  }
376
376
 
377
377
  /**
378
- * 计算已读取响应体的字节大小。
378
+ * 丢弃未读完的响应体并终止下载。
379
+ * undici 的 BodyReadable.destroy() 不带错误时会以 RequestAbortedError 触发 error 事件,
380
+ * 没有监听器会变成未捕获异常,因此先挂空监听器。
379
381
  */
380
- private getBodySize(body: any): number {
381
- if (body === undefined || body === null) {
382
- return 0;
383
- }
384
- if (Buffer.isBuffer(body)) {
385
- return body.byteLength;
386
- }
387
- if (body instanceof ArrayBuffer) {
388
- return body.byteLength;
389
- }
390
- if (ArrayBuffer.isView(body)) {
391
- return body.byteLength;
382
+ private discardBody(response: Dispatcher.ResponseData): void {
383
+ response.body.on('error', () => undefined);
384
+ response.body.destroy();
385
+ }
386
+
387
+ /**
388
+ * 带大小上限地读取非流式响应体。
389
+ *
390
+ * 1. Content-Length 预检:响应头已声明超限时,不读取响应体,直接销毁并拒绝。
391
+ * 2. 逐分块累计字节数,累计值超过上限(等于上限放行)立即销毁响应流并拒绝,后续数据不再下载;
392
+ * 该行为对齐 axios(Node http 适配器)的 maxContentLength。
393
+ */
394
+ private async readBodyWithLimit(response: Dispatcher.ResponseData, options: HttpRequestOptions): Promise<Buffer> {
395
+ const maxRespBodySize = this.resolveMaxResponseBodySize();
396
+
397
+ // HEAD / 204 / 304 没有响应体,其 Content-Length 描述的是对应实体而不是本次响应体:
398
+ // 不能用于预检;服务端携带时 undici 还会一直等待并不存在的响应体,因此不进入读取,直接丢弃。
399
+ if (options.method === 'HEAD' || response.statusCode === 204 || response.statusCode === 304) {
400
+ this.discardBody(response);
401
+ return Buffer.alloc(0);
392
402
  }
393
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
394
- return body.size;
403
+
404
+ if (maxRespBodySize !== undefined) {
405
+ const contentLength = this.parseContentLength(response.headers['content-length']);
406
+ if (contentLength !== undefined && contentLength > maxRespBodySize) {
407
+ this.discardBody(response);
408
+ throw new ResponseTooLargeError(maxRespBodySize, options, response.statusCode);
409
+ }
395
410
  }
396
- if (typeof body === 'string') {
397
- return Buffer.byteLength(body);
411
+
412
+ const chunks: Buffer[] = [];
413
+ let total = 0;
414
+ for await (const chunk of response.body) {
415
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
416
+ total += buffer.byteLength;
417
+ if (maxRespBodySize !== undefined && total > maxRespBodySize) {
418
+ this.discardBody(response);
419
+ throw new ResponseTooLargeError(maxRespBodySize, options, response.statusCode);
420
+ }
421
+ chunks.push(buffer);
398
422
  }
399
- return Buffer.byteLength(JSON.stringify(body));
423
+ return Buffer.concat(chunks, total);
424
+ }
425
+
426
+ /**
427
+ * 基于已读取(已受大小限制)的字节构造 Response,复用运行时的 blob/formData 解析逻辑。
428
+ */
429
+ private toFetchResponse(buffer: Buffer, response: Dispatcher.ResponseData): Response {
430
+ const contentType = response.headers['content-type'];
431
+ return new Response(buffer, {
432
+ headers: typeof contentType === 'string' ? { 'content-type': contentType } : undefined
433
+ });
400
434
  }
401
435
 
402
436
  /**
403
437
  * 读取响应体
404
438
  * @param response Undici 响应对象
405
- * @param responseType 响应类型
439
+ * @param options 请求选项
406
440
  */
407
- private async readResponseBody<T>(response: Dispatcher.ResponseData, responseType?: ResponseType): Promise<T> {
441
+ private async readResponseBody<T>(response: Dispatcher.ResponseData, options: HttpRequestOptions): Promise<T> {
442
+ if (options.responseType === 'stream') {
443
+ // undici 返回 BodyReadable (继承自 Node.js Readable),流式响应由调用方自行消费,不受 maxResponseBodySize 限制
444
+ return response.body as T;
445
+ }
446
+
408
447
  try {
409
- switch (responseType) {
448
+ const buffer = await this.readBodyWithLimit(response, options);
449
+ const decoder = new TextDecoder();
450
+ switch (options.responseType) {
410
451
  case 'text': {
411
- return (await response.body.text()) as T;
452
+ return decoder.decode(buffer) as T;
412
453
  }
413
454
  case 'arraybuffer': {
414
- return Buffer.from(await response.body.arrayBuffer()) as T;
415
- }
416
- case 'stream': {
417
- // undici 返回 BodyReadable (继承自 Node.js Readable)
418
- return response.body as T;
455
+ return buffer as T;
419
456
  }
420
457
  case 'blob': {
421
- return (await response.body.blob()) as T;
458
+ return (await this.toFetchResponse(buffer, response).blob()) as T;
422
459
  }
423
460
  case 'formdata': {
424
- return (await response.body.formData()) as T;
425
- }
426
- case 'json': {
427
- return await this.readJsonResponseBody<T>(response);
461
+ return (await this.toFetchResponse(buffer, response).formData()) as T;
428
462
  }
463
+ case 'json':
429
464
  default: {
430
- const text = await response.body.text();
465
+ // 按 axios 的默认兼容行为:空响应体或非 JSON 文本不应让成功状态码请求因为解析失败变成异常
466
+ const text = decoder.decode(buffer);
431
467
  try {
432
468
  return JSON.parse(text) as T;
433
469
  } catch {
434
- // 如果不是有效的 JSON,返回原文本
435
470
  return text as T;
436
471
  }
437
472
  }
438
-
439
473
  }
440
474
  } catch (error) {
475
+ // 响应体超限错误需要原样抛出,保留错误码、文案与不可重试标记
476
+ if (error instanceof ResponseTooLargeError) {
477
+ throw error;
478
+ }
441
479
  throw new Error(`Failed to read response body: ${ error instanceof Error ? error.message : String(error) }`);
442
480
  }
443
481
  }
444
482
 
445
- /**
446
- * 按 axios 的默认兼容行为读取 JSON 响应。
447
- * 空响应体或非 JSON 文本不应让成功状态码请求因为解析失败变成异常。
448
- */
449
- private async readJsonResponseBody<T>(response: Dispatcher.ResponseData): Promise<T> {
450
- const text = await response.body.text();
451
- if (!text) {
452
- return text as T;
453
- }
454
-
455
- try {
456
- return JSON.parse(text) as T;
457
- } catch {
458
- return text as T;
459
- }
460
- }
461
-
462
483
  /**
463
484
  * 获取状态文本
464
485
  * @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
- const forwardProxyDecision = this.resolveForwardProxyDecision(options.url);
193
-
194
- // 安全验证
195
- const { safeUrl, result: ssrfResult } = await this.ssrfGuard.validateAndGetSafeUrl(
196
- options.url,
197
- {
198
- skipDnsValidation: forwardProxyDecision.shouldProxy,
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
- this.logger.debug('URL validation passed', {
204
- originalUrl: options.url,
205
- safeUrl,
206
- resolvedIps: ssrfResult.resolvedIps,
207
- dnsValidationSkipped: ssrfResult.dnsValidationSkipped,
208
- dnsValidationSkipReason: ssrfResult.dnsValidationSkipReason,
209
- scenario: options.scenario
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
- if (ssrfResult.dnsValidationSkipped && forwardProxyDecision.shouldProxy) {
213
- this.logger.debug('Skipped target DNS validation because request uses forward proxy', {
214
- hostname: forwardProxyDecision.hostname,
215
- proxyUrl: forwardProxyDecision.proxyUrl
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
- // 使用安全的 URL 发起请求
220
- const safeOptions: HttpRequestOptions = {
221
- ...options,
222
- url: safeUrl
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
- // 处理 W3C Trace Context traceparent 头
226
- if (options.traceparent) {
227
- // 验证 traceparent 格式
228
- if (!TraceContextUtils.isValidTraceParent(options.traceparent)) {
229
- this.logger.warn('Invalid traceparent format, ignoring', {
230
- traceparent: options.traceparent
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
- this.logger.debug('Traceparent header added', {
239
- traceparent: options.traceparent,
240
- sampled: TraceContextUtils.isSampled(options.traceparent)
241
- });
242
- }
243
- }
237
+ // 使用安全的 URL 发起请求
238
+ const safeOptions: HttpRequestOptions = {
239
+ ...options,
240
+ url: safeUrl
241
+ };
244
242
 
245
- // 应用正向代理配置
246
- if (this.forwardProxy) {
247
- this.applyForwardProxy(safeOptions, forwardProxyDecision);
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
- const response = await this.adapter.request<T>(safeOptions);
263
+ // 应用正向代理配置
264
+ if (this.forwardProxy) {
265
+ this.applyForwardProxy(safeOptions, forwardProxyDecision);
266
+ }
252
267
 
253
- // 首次响应时间(TTFB)
254
- const ttfb = Date.now() - startTime;
268
+ // 发起请求
269
+ const response = await this.adapter.request<T>(safeOptions);
255
270
 
256
- // 如果是流式响应,包装流对象以追踪完整生命周期
257
- if (options.responseType === 'stream' && this.isStreamResponse(response.data)) {
258
- return this.wrapStreamResponse(safeOptions, response, startTime, ttfb);
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
- this.logResponse(safeOptions, response, ttfb);
296
+ // 首次响应时间(TTFB)
297
+ const ttfb = Date.now() - startTime;
263
298
 
264
- return response;
265
- } catch (error) {
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
  */
package/src/config.ts CHANGED
@@ -70,7 +70,7 @@ export function validateConfig(config: HttpClientConfig): void {
70
70
  }
71
71
 
72
72
  // 验证请求配置
73
- if (config.request.timeout <= 0) {
73
+ if (config.request.timeout !== undefined && config.request.timeout <= 0) {
74
74
  throw new Error('Timeout must be positive.');
75
75
  }
76
76
 
@@ -11,7 +11,7 @@ import { DEFAULT_DNS_CACHE_TTL, DEFAULT_DNS_CACHE_MAX_SIZE } from './dns.const';
11
11
  * DNS 缓存最大 TTL(毫秒)- 5 分钟
12
12
  * 避免过长的缓存时间导致内存积压
13
13
  */
14
- export const MAX_DNS_CACHE_TTL = 5 * 60 * 1000; // 300000ms (5 分钟)
14
+ export const MAX_DNS_CACHE_TTL = 5 * 60 * 1000; // 300000ms (5 分钟)
15
15
 
16
16
  /**
17
17
  * 默认安全配置
@@ -41,7 +41,6 @@ export const DEFAULT_LOGGING_CONFIG: LoggingConfig = {
41
41
  * 默认请求配置
42
42
  */
43
43
  export const DEFAULT_REQUEST_CONFIG: RequestConfig = {
44
- timeout: 30000, // 30 秒
45
44
  retries: 0,
46
45
  retryDelay: 1000, // 1 秒
47
46
  maxRedirects: 5,
@@ -49,6 +48,13 @@ export const DEFAULT_REQUEST_CONFIG: RequestConfig = {
49
48
  maxResponseBodySize: 50 * 1024 * 1024 // 50MB
50
49
  };
51
50
 
51
+ /**
52
+ * 响应体大小预检的线上长度容差比例。
53
+ * axios 的 onDownloadProgress.total 是压缩后的 Content-Length,而 maxContentLength 按解压后计数;
54
+ * 不可压缩数据经 gzip 等压缩后可能略大于解压大小,预检仅在超过上限该比例时提前中止。
55
+ */
56
+ export const MAX_RESPONSE_SIZE_TOLERANCE_RATIO = 0.01;
57
+
52
58
  /**
53
59
  * 默认 HTTP 客户端配置
54
60
  */
@@ -68,8 +68,8 @@ export interface LoggingConfig {
68
68
  * 请求配置
69
69
  */
70
70
  export interface RequestConfig {
71
- /** 超时时间(毫秒) */
72
- timeout: number;
71
+ /** 超时时间(毫秒);不配置表示不限制请求超时 */
72
+ timeout?: number;
73
73
  /** 重试次数 */
74
74
  retries: number;
75
75
  /** 重试延迟(毫秒) */
@@ -107,4 +107,3 @@ export interface PartialHttpClientConfig {
107
107
  logging?: Partial<LoggingConfig>;
108
108
  request?: Partial<RequestConfig>;
109
109
  }
110
-