undici 6.19.7 → 6.20.0

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.
@@ -984,6 +984,203 @@ client.dispatch(
984
984
  );
985
985
  ```
986
986
 
987
+ ##### `Response Error Interceptor`
988
+
989
+ **Introduction**
990
+
991
+ The Response Error Interceptor is designed to handle HTTP response errors efficiently. It intercepts responses and throws detailed errors for responses with status codes indicating failure (4xx, 5xx). This interceptor enhances error handling by providing structured error information, including response headers, data, and status codes.
992
+
993
+ **ResponseError Class**
994
+
995
+ The `ResponseError` class extends the `UndiciError` class and encapsulates detailed error information. It captures the response status code, headers, and data, providing a structured way to handle errors.
996
+
997
+ **Definition**
998
+
999
+ ```js
1000
+ class ResponseError extends UndiciError {
1001
+ constructor (message, code, { headers, data }) {
1002
+ super(message);
1003
+ this.name = 'ResponseError';
1004
+ this.message = message || 'Response error';
1005
+ this.code = 'UND_ERR_RESPONSE';
1006
+ this.statusCode = code;
1007
+ this.data = data;
1008
+ this.headers = headers;
1009
+ }
1010
+ }
1011
+ ```
1012
+
1013
+ **Interceptor Handler**
1014
+
1015
+ The interceptor's handler class extends `DecoratorHandler` and overrides methods to capture response details and handle errors based on the response status code.
1016
+
1017
+ **Methods**
1018
+
1019
+ - **onConnect**: Initializes response properties.
1020
+ - **onHeaders**: Captures headers and status code. Decodes body if content type is `application/json` or `text/plain`.
1021
+ - **onData**: Appends chunks to the body if status code indicates an error.
1022
+ - **onComplete**: Finalizes error handling, constructs a `ResponseError`, and invokes the `onError` method.
1023
+ - **onError**: Propagates errors to the handler.
1024
+
1025
+ **Definition**
1026
+
1027
+ ```js
1028
+ class Handler extends DecoratorHandler {
1029
+ // Private properties
1030
+ #handler;
1031
+ #statusCode;
1032
+ #contentType;
1033
+ #decoder;
1034
+ #headers;
1035
+ #body;
1036
+
1037
+ constructor (opts, { handler }) {
1038
+ super(handler);
1039
+ this.#handler = handler;
1040
+ }
1041
+
1042
+ onConnect (abort) {
1043
+ this.#statusCode = 0;
1044
+ this.#contentType = null;
1045
+ this.#decoder = null;
1046
+ this.#headers = null;
1047
+ this.#body = '';
1048
+ return this.#handler.onConnect(abort);
1049
+ }
1050
+
1051
+ onHeaders (statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
1052
+ this.#statusCode = statusCode;
1053
+ this.#headers = headers;
1054
+ this.#contentType = headers['content-type'];
1055
+
1056
+ if (this.#statusCode < 400) {
1057
+ return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers);
1058
+ }
1059
+
1060
+ if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
1061
+ this.#decoder = new TextDecoder('utf-8');
1062
+ }
1063
+ }
1064
+
1065
+ onData (chunk) {
1066
+ if (this.#statusCode < 400) {
1067
+ return this.#handler.onData(chunk);
1068
+ }
1069
+ this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? '';
1070
+ }
1071
+
1072
+ onComplete (rawTrailers) {
1073
+ if (this.#statusCode >= 400) {
1074
+ this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? '';
1075
+ if (this.#contentType === 'application/json') {
1076
+ try {
1077
+ this.#body = JSON.parse(this.#body);
1078
+ } catch {
1079
+ // Do nothing...
1080
+ }
1081
+ }
1082
+
1083
+ let err;
1084
+ const stackTraceLimit = Error.stackTraceLimit;
1085
+ Error.stackTraceLimit = 0;
1086
+ try {
1087
+ err = new ResponseError('Response Error', this.#statusCode, this.#headers, this.#body);
1088
+ } finally {
1089
+ Error.stackTraceLimit = stackTraceLimit;
1090
+ }
1091
+
1092
+ this.#handler.onError(err);
1093
+ } else {
1094
+ this.#handler.onComplete(rawTrailers);
1095
+ }
1096
+ }
1097
+
1098
+ onError (err) {
1099
+ this.#handler.onError(err);
1100
+ }
1101
+ }
1102
+
1103
+ module.exports = (dispatch) => (opts, handler) => opts.throwOnError
1104
+ ? dispatch(opts, new Handler(opts, { handler }))
1105
+ : dispatch(opts, handler);
1106
+ ```
1107
+
1108
+ **Tests**
1109
+
1110
+ Unit tests ensure the interceptor functions correctly, handling both error and non-error responses appropriately.
1111
+
1112
+ **Example Tests**
1113
+
1114
+ - **No Error if `throwOnError` is False**:
1115
+
1116
+ ```js
1117
+ test('should not error if request is not meant to throw error', async (t) => {
1118
+ const opts = { throwOnError: false };
1119
+ const handler = { onError: () => {}, onData: () => {}, onComplete: () => {} };
1120
+ const interceptor = createResponseErrorInterceptor((opts, handler) => handler.onComplete());
1121
+ assert.doesNotThrow(() => interceptor(opts, handler));
1122
+ });
1123
+ ```
1124
+
1125
+ - **Error if Status Code is in Specified Error Codes**:
1126
+
1127
+ ```js
1128
+ test('should error if request status code is in the specified error codes', async (t) => {
1129
+ const opts = { throwOnError: true, statusCodes: [500] };
1130
+ const response = { statusCode: 500 };
1131
+ let capturedError;
1132
+ const handler = {
1133
+ onError: (err) => { capturedError = err; },
1134
+ onData: () => {},
1135
+ onComplete: () => {}
1136
+ };
1137
+
1138
+ const interceptor = createResponseErrorInterceptor((opts, handler) => {
1139
+ if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
1140
+ handler.onError(new Error('Response Error'));
1141
+ } else {
1142
+ handler.onComplete();
1143
+ }
1144
+ });
1145
+
1146
+ interceptor({ ...opts, response }, handler);
1147
+
1148
+ await new Promise(resolve => setImmediate(resolve));
1149
+
1150
+ assert(capturedError, 'Expected error to be captured but it was not.');
1151
+ assert.strictEqual(capturedError.message, 'Response Error');
1152
+ assert.strictEqual(response.statusCode, 500);
1153
+ });
1154
+ ```
1155
+
1156
+ - **No Error if Status Code is Not in Specified Error Codes**:
1157
+
1158
+ ```js
1159
+ test('should not error if request status code is not in the specified error codes', async (t) => {
1160
+ const opts = { throwOnError: true, statusCodes: [500] };
1161
+ const response = { statusCode: 404 };
1162
+ const handler = {
1163
+ onError: () => {},
1164
+ onData: () => {},
1165
+ onComplete: () => {}
1166
+ };
1167
+
1168
+ const interceptor = createResponseErrorInterceptor((opts, handler) => {
1169
+ if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
1170
+ handler.onError(new Error('Response Error'));
1171
+ } else {
1172
+ handler.onComplete();
1173
+ }
1174
+ });
1175
+
1176
+ assert.doesNotThrow(() => interceptor({ ...opts, response }, handler));
1177
+ });
1178
+ ```
1179
+
1180
+ **Conclusion**
1181
+
1182
+ The Response Error Interceptor provides a robust mechanism for handling HTTP response errors by capturing detailed error information and propagating it through a structured `ResponseError` class. This enhancement improves error handling and debugging capabilities in applications using the interceptor.
1183
+
987
1184
  ## Instance Events
988
1185
 
989
1186
  ### Event: `'connect'`
@@ -19,7 +19,7 @@ Extends: [`Dispatch.DispatchOptions`](Dispatcher.md#parameter-dispatchoptions).
19
19
 
20
20
  #### `RetryOptions`
21
21
 
22
- - **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => void` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed.
22
+ - **retry** `(err: Error, context: RetryContext, callback: (err?: Error | null) => void) => number | null` (optional) - Function to be called after every retry. It should pass error if no more retries should be performed.
23
23
  - **maxRetries** `number` (optional) - Maximum number of retries. Default: `5`
24
24
  - **maxTimeout** `number` (optional) - Maximum number of milliseconds to wait before retrying. Default: `30000` (30 seconds)
25
25
  - **minTimeout** `number` (optional) - Minimum number of milliseconds to wait before retrying. Default: `500` (half a second)
@@ -50,9 +50,9 @@ class UpgradeHandler extends AsyncResource {
50
50
  }
51
51
 
52
52
  onUpgrade (statusCode, rawHeaders, socket) {
53
- const { callback, opaque, context } = this
53
+ assert(statusCode === 101)
54
54
 
55
- assert.strictEqual(statusCode, 101)
55
+ const { callback, opaque, context } = this
56
56
 
57
57
  removeSignal(this)
58
58
 
@@ -4,6 +4,9 @@ const net = require('node:net')
4
4
  const assert = require('node:assert')
5
5
  const util = require('./util')
6
6
  const { InvalidArgumentError, ConnectTimeoutError } = require('./errors')
7
+ const timers = require('../util/timers')
8
+
9
+ function noop () {}
7
10
 
8
11
  let tls // include tls conditionally since it is not always available
9
12
 
@@ -91,9 +94,11 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
91
94
  servername = servername || options.servername || util.getServerName(host) || null
92
95
 
93
96
  const sessionKey = servername || hostname
97
+ assert(sessionKey)
98
+
94
99
  const session = customSession || sessionCache.get(sessionKey) || null
95
100
 
96
- assert(sessionKey)
101
+ port = port || 443
97
102
 
98
103
  socket = tls.connect({
99
104
  highWaterMark: 16384, // TLS in node can't have bigger HWM anyway...
@@ -104,7 +109,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
104
109
  // TODO(HTTP/2): Add support for h2c
105
110
  ALPNProtocols: allowH2 ? ['http/1.1', 'h2'] : ['http/1.1'],
106
111
  socket: httpSocket, // upgrade socket connection
107
- port: port || 443,
112
+ port,
108
113
  host: hostname
109
114
  })
110
115
 
@@ -115,11 +120,14 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
115
120
  })
116
121
  } else {
117
122
  assert(!httpSocket, 'httpSocket can only be sent on TLS update')
123
+
124
+ port = port || 80
125
+
118
126
  socket = net.connect({
119
127
  highWaterMark: 64 * 1024, // Same as nodejs fs streams.
120
128
  ...options,
121
129
  localAddress,
122
- port: port || 80,
130
+ port,
123
131
  host: hostname
124
132
  })
125
133
  }
@@ -130,12 +138,12 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
130
138
  socket.setKeepAlive(true, keepAliveInitialDelay)
131
139
  }
132
140
 
133
- const cancelTimeout = setupTimeout(() => onConnectTimeout(socket), timeout)
141
+ const clearConnectTimeout = setupConnectTimeout(new WeakRef(socket), { timeout, hostname, port })
134
142
 
135
143
  socket
136
144
  .setNoDelay(true)
137
145
  .once(protocol === 'https:' ? 'secureConnect' : 'connect', function () {
138
- cancelTimeout()
146
+ queueMicrotask(clearConnectTimeout)
139
147
 
140
148
  if (callback) {
141
149
  const cb = callback
@@ -144,7 +152,7 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
144
152
  }
145
153
  })
146
154
  .on('error', function (err) {
147
- cancelTimeout()
155
+ queueMicrotask(clearConnectTimeout)
148
156
 
149
157
  if (callback) {
150
158
  const cb = callback
@@ -157,36 +165,70 @@ function buildConnector ({ allowH2, maxCachedSessions, socketPath, timeout, sess
157
165
  }
158
166
  }
159
167
 
160
- function setupTimeout (onConnectTimeout, timeout) {
161
- if (!timeout) {
162
- return () => {}
163
- }
168
+ /**
169
+ * @param {WeakRef<net.Socket>} socketWeakRef
170
+ * @param {object} opts
171
+ * @param {number} opts.timeout
172
+ * @param {string} opts.hostname
173
+ * @param {number} opts.port
174
+ * @returns {() => void}
175
+ */
176
+ const setupConnectTimeout = process.platform === 'win32'
177
+ ? (socketWeakRef, opts) => {
178
+ if (!opts.timeout) {
179
+ return noop
180
+ }
164
181
 
165
- let s1 = null
166
- let s2 = null
167
- const timeoutId = setTimeout(() => {
168
- // setImmediate is added to make sure that we prioritize socket error events over timeouts
169
- s1 = setImmediate(() => {
170
- if (process.platform === 'win32') {
182
+ let s1 = null
183
+ let s2 = null
184
+ const fastTimer = timers.setFastTimeout(() => {
185
+ // setImmediate is added to make sure that we prioritize socket error events over timeouts
186
+ s1 = setImmediate(() => {
171
187
  // Windows needs an extra setImmediate probably due to implementation differences in the socket logic
172
- s2 = setImmediate(() => onConnectTimeout())
173
- } else {
174
- onConnectTimeout()
188
+ s2 = setImmediate(() => onConnectTimeout(socketWeakRef.deref(), opts))
189
+ })
190
+ }, opts.timeout)
191
+ return () => {
192
+ timers.clearFastTimeout(fastTimer)
193
+ clearImmediate(s1)
194
+ clearImmediate(s2)
195
+ }
196
+ }
197
+ : (socketWeakRef, opts) => {
198
+ if (!opts.timeout) {
199
+ return noop
175
200
  }
176
- })
177
- }, timeout)
178
- return () => {
179
- clearTimeout(timeoutId)
180
- clearImmediate(s1)
181
- clearImmediate(s2)
182
- }
183
- }
184
201
 
185
- function onConnectTimeout (socket) {
202
+ let s1 = null
203
+ const fastTimer = timers.setFastTimeout(() => {
204
+ // setImmediate is added to make sure that we prioritize socket error events over timeouts
205
+ s1 = setImmediate(() => {
206
+ onConnectTimeout(socketWeakRef.deref(), opts)
207
+ })
208
+ }, opts.timeout)
209
+ return () => {
210
+ timers.clearFastTimeout(fastTimer)
211
+ clearImmediate(s1)
212
+ }
213
+ }
214
+
215
+ /**
216
+ * @param {net.Socket} socket
217
+ * @param {object} opts
218
+ * @param {number} opts.timeout
219
+ * @param {string} opts.hostname
220
+ * @param {number} opts.port
221
+ */
222
+ function onConnectTimeout (socket, opts) {
186
223
  let message = 'Connect Timeout Error'
187
224
  if (Array.isArray(socket.autoSelectFamilyAttemptedAddresses)) {
188
- message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')})`
225
+ message += ` (attempted addresses: ${socket.autoSelectFamilyAttemptedAddresses.join(', ')},`
226
+ } else {
227
+ message += ` (attempted address: ${opts.hostname}:${opts.port},`
189
228
  }
229
+
230
+ message += ` timeout: ${opts.timeout}ms)`
231
+
190
232
  util.destroy(socket, new ConnectTimeoutError(message))
191
233
  }
192
234
 
@@ -195,6 +195,18 @@ class RequestRetryError extends UndiciError {
195
195
  }
196
196
  }
197
197
 
198
+ class ResponseError extends UndiciError {
199
+ constructor (message, code, { headers, data }) {
200
+ super(message)
201
+ this.name = 'ResponseError'
202
+ this.message = message || 'Response error'
203
+ this.code = 'UND_ERR_RESPONSE'
204
+ this.statusCode = code
205
+ this.data = data
206
+ this.headers = headers
207
+ }
208
+ }
209
+
198
210
  class SecureProxyConnectionError extends UndiciError {
199
211
  constructor (cause, message, options) {
200
212
  super(message, { cause, ...(options ?? {}) })
@@ -227,5 +239,6 @@ module.exports = {
227
239
  BalancedPoolMissingUpstreamError,
228
240
  ResponseExceededMaxSizeError,
229
241
  RequestRetryError,
242
+ ResponseError,
230
243
  SecureProxyConnectionError
231
244
  }
package/lib/core/util.js CHANGED
@@ -233,7 +233,7 @@ function getServerName (host) {
233
233
  return null
234
234
  }
235
235
 
236
- assert.strictEqual(typeof host, 'string')
236
+ assert(typeof host === 'string')
237
237
 
238
238
  const servername = getHostname(host)
239
239
  if (net.isIP(servername)) {
@@ -25,9 +25,23 @@ const kWeight = Symbol('kWeight')
25
25
  const kMaxWeightPerServer = Symbol('kMaxWeightPerServer')
26
26
  const kErrorPenalty = Symbol('kErrorPenalty')
27
27
 
28
+ /**
29
+ * Calculate the greatest common divisor of two numbers by
30
+ * using the Euclidean algorithm.
31
+ *
32
+ * @param {number} a
33
+ * @param {number} b
34
+ * @returns {number}
35
+ */
28
36
  function getGreatestCommonDivisor (a, b) {
29
- if (b === 0) return a
30
- return getGreatestCommonDivisor(b, a % b)
37
+ if (a === 0) return b
38
+
39
+ while (b !== 0) {
40
+ const t = b
41
+ b = a % b
42
+ a = t
43
+ }
44
+ return a
31
45
  }
32
46
 
33
47
  function defaultFactory (origin, opts) {
@@ -105,7 +119,12 @@ class BalancedPool extends PoolBase {
105
119
  }
106
120
 
107
121
  _updateBalancedPoolStats () {
108
- this[kGreatestCommonDivisor] = this[kClients].map(p => p[kWeight]).reduce(getGreatestCommonDivisor, 0)
122
+ let result = 0
123
+ for (let i = 0; i < this[kClients].length; i++) {
124
+ result = getGreatestCommonDivisor(this[kClients][i][kWeight], result)
125
+ }
126
+
127
+ this[kGreatestCommonDivisor] = result
109
128
  }
110
129
 
111
130
  removeUpstream (upstream) {