Skip to content

Commit 0ea1074

Browse files
authored
interceptors: move throwOnError to interceptor (#3331)
1 parent e4ebcdc commit 0ea1074

5 files changed

Lines changed: 366 additions & 1 deletion

File tree

docs/docs/api/Dispatcher.md

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -986,6 +986,203 @@ client.dispatch(
986986
);
987987
```
988988

989+
##### `Response Error Interceptor`
990+
991+
**Introduction**
992+
993+
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.
994+
995+
**ResponseError Class**
996+
997+
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.
998+
999+
**Definition**
1000+
1001+
```js
1002+
class ResponseError extends UndiciError {
1003+
constructor (message, code, { headers, data }) {
1004+
super(message);
1005+
this.name = 'ResponseError';
1006+
this.message = message || 'Response error';
1007+
this.code = 'UND_ERR_RESPONSE';
1008+
this.statusCode = code;
1009+
this.data = data;
1010+
this.headers = headers;
1011+
}
1012+
}
1013+
```
1014+
1015+
**Interceptor Handler**
1016+
1017+
The interceptor's handler class extends `DecoratorHandler` and overrides methods to capture response details and handle errors based on the response status code.
1018+
1019+
**Methods**
1020+
1021+
- **onConnect**: Initializes response properties.
1022+
- **onHeaders**: Captures headers and status code. Decodes body if content type is `application/json` or `text/plain`.
1023+
- **onData**: Appends chunks to the body if status code indicates an error.
1024+
- **onComplete**: Finalizes error handling, constructs a `ResponseError`, and invokes the `onError` method.
1025+
- **onError**: Propagates errors to the handler.
1026+
1027+
**Definition**
1028+
1029+
```js
1030+
class Handler extends DecoratorHandler {
1031+
// Private properties
1032+
#handler;
1033+
#statusCode;
1034+
#contentType;
1035+
#decoder;
1036+
#headers;
1037+
#body;
1038+
1039+
constructor (opts, { handler }) {
1040+
super(handler);
1041+
this.#handler = handler;
1042+
}
1043+
1044+
onConnect (abort) {
1045+
this.#statusCode = 0;
1046+
this.#contentType = null;
1047+
this.#decoder = null;
1048+
this.#headers = null;
1049+
this.#body = '';
1050+
return this.#handler.onConnect(abort);
1051+
}
1052+
1053+
onHeaders (statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
1054+
this.#statusCode = statusCode;
1055+
this.#headers = headers;
1056+
this.#contentType = headers['content-type'];
1057+
1058+
if (this.#statusCode < 400) {
1059+
return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers);
1060+
}
1061+
1062+
if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
1063+
this.#decoder = new TextDecoder('utf-8');
1064+
}
1065+
}
1066+
1067+
onData (chunk) {
1068+
if (this.#statusCode < 400) {
1069+
return this.#handler.onData(chunk);
1070+
}
1071+
this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? '';
1072+
}
1073+
1074+
onComplete (rawTrailers) {
1075+
if (this.#statusCode >= 400) {
1076+
this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? '';
1077+
if (this.#contentType === 'application/json') {
1078+
try {
1079+
this.#body = JSON.parse(this.#body);
1080+
} catch {
1081+
// Do nothing...
1082+
}
1083+
}
1084+
1085+
let err;
1086+
const stackTraceLimit = Error.stackTraceLimit;
1087+
Error.stackTraceLimit = 0;
1088+
try {
1089+
err = new ResponseError('Response Error', this.#statusCode, this.#headers, this.#body);
1090+
} finally {
1091+
Error.stackTraceLimit = stackTraceLimit;
1092+
}
1093+
1094+
this.#handler.onError(err);
1095+
} else {
1096+
this.#handler.onComplete(rawTrailers);
1097+
}
1098+
}
1099+
1100+
onError (err) {
1101+
this.#handler.onError(err);
1102+
}
1103+
}
1104+
1105+
module.exports = (dispatch) => (opts, handler) => opts.throwOnError
1106+
? dispatch(opts, new Handler(opts, { handler }))
1107+
: dispatch(opts, handler);
1108+
```
1109+
1110+
**Tests**
1111+
1112+
Unit tests ensure the interceptor functions correctly, handling both error and non-error responses appropriately.
1113+
1114+
**Example Tests**
1115+
1116+
- **No Error if `throwOnError` is False**:
1117+
1118+
```js
1119+
test('should not error if request is not meant to throw error', async (t) => {
1120+
const opts = { throwOnError: false };
1121+
const handler = { onError: () => {}, onData: () => {}, onComplete: () => {} };
1122+
const interceptor = createResponseErrorInterceptor((opts, handler) => handler.onComplete());
1123+
assert.doesNotThrow(() => interceptor(opts, handler));
1124+
});
1125+
```
1126+
1127+
- **Error if Status Code is in Specified Error Codes**:
1128+
1129+
```js
1130+
test('should error if request status code is in the specified error codes', async (t) => {
1131+
const opts = { throwOnError: true, statusCodes: [500] };
1132+
const response = { statusCode: 500 };
1133+
let capturedError;
1134+
const handler = {
1135+
onError: (err) => { capturedError = err; },
1136+
onData: () => {},
1137+
onComplete: () => {}
1138+
};
1139+
1140+
const interceptor = createResponseErrorInterceptor((opts, handler) => {
1141+
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
1142+
handler.onError(new Error('Response Error'));
1143+
} else {
1144+
handler.onComplete();
1145+
}
1146+
});
1147+
1148+
interceptor({ ...opts, response }, handler);
1149+
1150+
await new Promise(resolve => setImmediate(resolve));
1151+
1152+
assert(capturedError, 'Expected error to be captured but it was not.');
1153+
assert.strictEqual(capturedError.message, 'Response Error');
1154+
assert.strictEqual(response.statusCode, 500);
1155+
});
1156+
```
1157+
1158+
- **No Error if Status Code is Not in Specified Error Codes**:
1159+
1160+
```js
1161+
test('should not error if request status code is not in the specified error codes', async (t) => {
1162+
const opts = { throwOnError: true, statusCodes: [500] };
1163+
const response = { statusCode: 404 };
1164+
const handler = {
1165+
onError: () => {},
1166+
onData: () => {},
1167+
onComplete: () => {}
1168+
};
1169+
1170+
const interceptor = createResponseErrorInterceptor((opts, handler) => {
1171+
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
1172+
handler.onError(new Error('Response Error'));
1173+
} else {
1174+
handler.onComplete();
1175+
}
1176+
});
1177+
1178+
assert.doesNotThrow(() => interceptor({ ...opts, response }, handler));
1179+
});
1180+
```
1181+
1182+
**Conclusion**
1183+
1184+
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.
1185+
9891186
## Instance Events
9901187
9911188
### Event: `'connect'`

lib/core/errors.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,18 @@ class RequestRetryError extends UndiciError {
195195
}
196196
}
197197

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+
198210
class SecureProxyConnectionError extends UndiciError {
199211
constructor (cause, message, options) {
200212
super(message, { cause, ...(options ?? {}) })
@@ -227,5 +239,6 @@ module.exports = {
227239
BalancedPoolMissingUpstreamError,
228240
ResponseExceededMaxSizeError,
229241
RequestRetryError,
242+
ResponseError,
230243
SecureProxyConnectionError
231244
}

lib/interceptor/response-error.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
'use strict'
2+
3+
const { parseHeaders } = require('../core/util')
4+
const DecoratorHandler = require('../handler/decorator-handler')
5+
const { ResponseError } = require('../core/errors')
6+
7+
class Handler extends DecoratorHandler {
8+
#handler
9+
#statusCode
10+
#contentType
11+
#decoder
12+
#headers
13+
#body
14+
15+
constructor (opts, { handler }) {
16+
super(handler)
17+
this.#handler = handler
18+
}
19+
20+
onConnect (abort) {
21+
this.#statusCode = 0
22+
this.#contentType = null
23+
this.#decoder = null
24+
this.#headers = null
25+
this.#body = ''
26+
27+
return this.#handler.onConnect(abort)
28+
}
29+
30+
onHeaders (statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
31+
this.#statusCode = statusCode
32+
this.#headers = headers
33+
this.#contentType = headers['content-type']
34+
35+
if (this.#statusCode < 400) {
36+
return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
37+
}
38+
39+
if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
40+
this.#decoder = new TextDecoder('utf-8')
41+
}
42+
}
43+
44+
onData (chunk) {
45+
if (this.#statusCode < 400) {
46+
return this.#handler.onData(chunk)
47+
}
48+
49+
this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? ''
50+
}
51+
52+
onComplete (rawTrailers) {
53+
if (this.#statusCode >= 400) {
54+
this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? ''
55+
56+
if (this.#contentType === 'application/json') {
57+
try {
58+
this.#body = JSON.parse(this.#body)
59+
} catch {
60+
// Do nothing...
61+
}
62+
}
63+
64+
let err
65+
const stackTraceLimit = Error.stackTraceLimit
66+
Error.stackTraceLimit = 0
67+
try {
68+
err = new ResponseError('Response Error', this.#statusCode, this.#headers, this.#body)
69+
} finally {
70+
Error.stackTraceLimit = stackTraceLimit
71+
}
72+
73+
this.#handler.onError(err)
74+
} else {
75+
this.#handler.onComplete(rawTrailers)
76+
}
77+
}
78+
79+
onError (err) {
80+
this.#handler.onError(err)
81+
}
82+
}
83+
84+
module.exports = (dispatch) => (opts, handler) => opts.throwOnError
85+
? dispatch(opts, new Handler(opts, { handler }))
86+
: dispatch(opts, handler)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
'use strict'
2+
3+
const assert = require('assert')
4+
const { test } = require('node:test')
5+
const createResponseErrorInterceptor = require('../../lib/interceptor/response-error')
6+
7+
test('should not error if request is not meant to throw error', async (t) => {
8+
const opts = { throwOnError: false }
9+
const handler = {
10+
onError: () => {},
11+
onData: () => {},
12+
onComplete: () => {}
13+
}
14+
15+
const interceptor = createResponseErrorInterceptor((opts, handler) => handler.onComplete())
16+
17+
assert.doesNotThrow(() => interceptor(opts, handler))
18+
})
19+
20+
test('should error if request status code is in the specified error codes', async (t) => {
21+
const opts = { throwOnError: true, statusCodes: [500] }
22+
const response = { statusCode: 500 }
23+
let capturedError
24+
const handler = {
25+
onError: (err) => {
26+
capturedError = err
27+
},
28+
onData: () => {},
29+
onComplete: () => {}
30+
}
31+
32+
const interceptor = createResponseErrorInterceptor((opts, handler) => {
33+
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
34+
handler.onError(new Error('Response Error'))
35+
} else {
36+
handler.onComplete()
37+
}
38+
})
39+
40+
interceptor({ ...opts, response }, handler)
41+
42+
await new Promise(resolve => setImmediate(resolve))
43+
44+
assert(capturedError, 'Expected error to be captured but it was not.')
45+
assert.strictEqual(capturedError.message, 'Response Error')
46+
assert.strictEqual(response.statusCode, 500)
47+
})
48+
49+
test('should not error if request status code is not in the specified error codes', async (t) => {
50+
const opts = { throwOnError: true, statusCodes: [500] }
51+
const response = { statusCode: 404 }
52+
const handler = {
53+
onError: () => {},
54+
onData: () => {},
55+
onComplete: () => {}
56+
}
57+
58+
const interceptor = createResponseErrorInterceptor((opts, handler) => {
59+
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
60+
handler.onError(new Error('Response Error'))
61+
} else {
62+
handler.onComplete()
63+
}
64+
})
65+
66+
assert.doesNotThrow(() => interceptor({ ...opts, response }, handler))
67+
})

types/interceptors.d.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ declare namespace Interceptors {
77
export type DumpInterceptorOpts = { maxSize?: number }
88
export type RetryInterceptorOpts = RetryHandler.RetryOptions
99
export type RedirectInterceptorOpts = { maxRedirections?: number }
10-
10+
export type ResponseErrorInterceptorOpts = { throwOnError: boolean }
11+
1112
export function createRedirectInterceptor(opts: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
1213
export function dump(opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
1314
export function retry(opts?: RetryInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
1415
export function redirect(opts?: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
16+
export function responseError(opts?: ResponseErrorInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
1517
}

0 commit comments

Comments
 (0)