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.
Files changed (60) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/cjs/adapter/axios.adapter.js +93 -1
  3. package/dist/cjs/adapter/axios.adapter.js.map +1 -1
  4. package/dist/cjs/adapter/base.js +20 -1
  5. package/dist/cjs/adapter/base.js.map +1 -1
  6. package/dist/cjs/adapter/undici.adapter.js +85 -73
  7. package/dist/cjs/adapter/undici.adapter.js.map +1 -1
  8. package/dist/cjs/client.js +99 -56
  9. package/dist/cjs/client.js.map +1 -1
  10. package/dist/cjs/const/config.const.js +7 -1
  11. package/dist/cjs/const/config.const.js.map +1 -1
  12. package/dist/cjs/tsconfig.tsbuildinfo +1 -1
  13. package/dist/cjs/types/errors.js +47 -2
  14. package/dist/cjs/types/errors.js.map +1 -1
  15. package/dist/cjs/utils/index.js +2 -1
  16. package/dist/cjs/utils/index.js.map +1 -1
  17. package/dist/cjs/utils/redirect.js +138 -0
  18. package/dist/cjs/utils/redirect.js.map +1 -0
  19. package/dist/esm/adapter/axios.adapter.js +95 -3
  20. package/dist/esm/adapter/axios.adapter.js.map +1 -1
  21. package/dist/esm/adapter/base.js +20 -1
  22. package/dist/esm/adapter/base.js.map +1 -1
  23. package/dist/esm/adapter/undici.adapter.js +86 -74
  24. package/dist/esm/adapter/undici.adapter.js.map +1 -1
  25. package/dist/esm/client.js +100 -57
  26. package/dist/esm/client.js.map +1 -1
  27. package/dist/esm/const/config.const.js +6 -0
  28. package/dist/esm/const/config.const.js.map +1 -1
  29. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  30. package/dist/esm/types/errors.js +44 -1
  31. package/dist/esm/types/errors.js.map +1 -1
  32. package/dist/esm/utils/index.js +1 -0
  33. package/dist/esm/utils/index.js.map +1 -1
  34. package/dist/esm/utils/redirect.js +133 -0
  35. package/dist/esm/utils/redirect.js.map +1 -0
  36. package/dist/types/adapter/axios.adapter.d.ts +22 -0
  37. package/dist/types/adapter/axios.adapter.d.ts.map +1 -1
  38. package/dist/types/adapter/base.d.ts +6 -0
  39. package/dist/types/adapter/base.d.ts.map +1 -1
  40. package/dist/types/adapter/undici.adapter.d.ts +20 -11
  41. package/dist/types/adapter/undici.adapter.d.ts.map +1 -1
  42. package/dist/types/client.d.ts +11 -0
  43. package/dist/types/client.d.ts.map +1 -1
  44. package/dist/types/const/config.const.d.ts +6 -0
  45. package/dist/types/const/config.const.d.ts.map +1 -1
  46. package/dist/types/types/errors.d.ts +39 -1
  47. package/dist/types/types/errors.d.ts.map +1 -1
  48. package/dist/types/utils/index.d.ts +1 -0
  49. package/dist/types/utils/index.d.ts.map +1 -1
  50. package/dist/types/utils/redirect.d.ts +26 -0
  51. package/dist/types/utils/redirect.d.ts.map +1 -0
  52. package/package.json +1 -1
  53. package/src/adapter/axios.adapter.ts +124 -3
  54. package/src/adapter/base.ts +22 -1
  55. package/src/adapter/undici.adapter.ts +99 -92
  56. package/src/client.ts +114 -63
  57. package/src/const/config.const.ts +7 -0
  58. package/src/types/errors.ts +64 -1
  59. package/src/utils/index.ts +1 -0
  60. package/src/utils/redirect.ts +163 -0
@@ -4,10 +4,13 @@
4
4
 
5
5
  import type {
6
6
  HttpRequestOptions,
7
- HttpResponse,
8
- ResponseType,
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
- const data = await this.readResponseBody<T>(response, options.responseType);
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 HttpError(
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
- connect: {
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
- connect: {
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
- * 按 axios 默认行为处理非 2xx 状态码。
341
+ * 2xx 和 304 作为成功结果返回。undici 未跟跳的 3xx 交回调用方。
342
342
  */
343
343
  private ensureSuccessStatus(response: HttpResponse): void {
344
- if (response.status >= 200 && response.status < 300) {
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
- * 按 axios maxContentLength 行为限制非流式响应体大小。
362
+ * 解析 Content-Length 响应头。仅接受合法的非负整数,重复头/非法值视为未知。
359
363
  */
360
- private ensureResponseBodySize(response: HttpResponse, responseType?: ResponseType): void {
361
- if (responseType === 'stream') {
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
- throw new HttpError(
378
- `maxContentLength size of ${ maxResponseBodySize } exceeded`,
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 getBodySize(body: any): number {
390
- if (body === undefined || body === null) {
391
- return 0;
392
- }
393
- if (Buffer.isBuffer(body)) {
394
- return body.byteLength;
395
- }
396
- if (body instanceof ArrayBuffer) {
397
- return body.byteLength;
398
- }
399
- if (ArrayBuffer.isView(body)) {
400
- return body.byteLength;
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
- if (typeof Blob !== 'undefined' && body instanceof Blob) {
403
- return body.size;
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
- if (typeof body === 'string') {
406
- return Buffer.byteLength(body);
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.byteLength(JSON.stringify(body));
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 responseType 响应类型
434
+ * @param options 请求选项
415
435
  */
416
- private async readResponseBody<T>(response: Dispatcher.ResponseData, responseType?: ResponseType): Promise<T> {
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
- switch (responseType) {
443
+ const buffer = await this.readBodyWithLimit(response, options);
444
+ const decoder = new TextDecoder();
445
+ switch (options.responseType) {
419
446
  case 'text': {
420
- return (await response.body.text()) as T;
447
+ return decoder.decode(buffer) as T;
421
448
  }
422
449
  case 'arraybuffer': {
423
- return Buffer.from(await response.body.arrayBuffer()) as T;
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.body.blob()) as T;
453
+ return (await this.toFetchResponse(buffer, response).blob()) as T;
431
454
  }
432
455
  case 'formdata': {
433
- return (await response.body.formData()) as T;
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
- const text = await response.body.text();
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
- 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
  */
@@ -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
  */
@@ -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
  */
@@ -1,6 +1,7 @@
1
1
  export * as IpRangeUtils from './ip.range';
2
2
  export * as CommonUtils from './common';
3
3
  export * as DomainUtils from './domain';
4
+ export * as RedirectUtils from './redirect';
4
5
  export * as TraceContextUtils from './trace.context';
5
6
 
6
7
  // 导出 TraceContext 类供外部直接使用