Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Backport v6.x): move throwOnError to interceptor #3595

Merged
merged 2 commits into from
Sep 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -984,6 +984,203 @@ client.dispatch(
);
```

##### `Response Error Interceptor`

**Introduction**

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.

**ResponseError Class**

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.

**Definition**

```js
class ResponseError extends UndiciError {
constructor (message, code, { headers, data }) {
super(message);
this.name = 'ResponseError';
this.message = message || 'Response error';
this.code = 'UND_ERR_RESPONSE';
this.statusCode = code;
this.data = data;
this.headers = headers;
}
}
```

**Interceptor Handler**

The interceptor's handler class extends `DecoratorHandler` and overrides methods to capture response details and handle errors based on the response status code.

**Methods**

- **onConnect**: Initializes response properties.
- **onHeaders**: Captures headers and status code. Decodes body if content type is `application/json` or `text/plain`.
- **onData**: Appends chunks to the body if status code indicates an error.
- **onComplete**: Finalizes error handling, constructs a `ResponseError`, and invokes the `onError` method.
- **onError**: Propagates errors to the handler.

**Definition**

```js
class Handler extends DecoratorHandler {
// Private properties
#handler;
#statusCode;
#contentType;
#decoder;
#headers;
#body;

constructor (opts, { handler }) {
super(handler);
this.#handler = handler;
}

onConnect (abort) {
this.#statusCode = 0;
this.#contentType = null;
this.#decoder = null;
this.#headers = null;
this.#body = '';
return this.#handler.onConnect(abort);
}

onHeaders (statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
this.#statusCode = statusCode;
this.#headers = headers;
this.#contentType = headers['content-type'];

if (this.#statusCode < 400) {
return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers);
}

if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
this.#decoder = new TextDecoder('utf-8');
}
}

onData (chunk) {
if (this.#statusCode < 400) {
return this.#handler.onData(chunk);
}
this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? '';
}

onComplete (rawTrailers) {
if (this.#statusCode >= 400) {
this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? '';
if (this.#contentType === 'application/json') {
try {
this.#body = JSON.parse(this.#body);
} catch {
// Do nothing...
}
}

let err;
const stackTraceLimit = Error.stackTraceLimit;
Error.stackTraceLimit = 0;
try {
err = new ResponseError('Response Error', this.#statusCode, this.#headers, this.#body);
} finally {
Error.stackTraceLimit = stackTraceLimit;
}

this.#handler.onError(err);
} else {
this.#handler.onComplete(rawTrailers);
}
}

onError (err) {
this.#handler.onError(err);
}
}

module.exports = (dispatch) => (opts, handler) => opts.throwOnError
? dispatch(opts, new Handler(opts, { handler }))
: dispatch(opts, handler);
```
**Tests**
Unit tests ensure the interceptor functions correctly, handling both error and non-error responses appropriately.
**Example Tests**
- **No Error if `throwOnError` is False**:
```js
test('should not error if request is not meant to throw error', async (t) => {
const opts = { throwOnError: false };
const handler = { onError: () => {}, onData: () => {}, onComplete: () => {} };
const interceptor = createResponseErrorInterceptor((opts, handler) => handler.onComplete());
assert.doesNotThrow(() => interceptor(opts, handler));
});
```
- **Error if Status Code is in Specified Error Codes**:
```js
test('should error if request status code is in the specified error codes', async (t) => {
const opts = { throwOnError: true, statusCodes: [500] };
const response = { statusCode: 500 };
let capturedError;
const handler = {
onError: (err) => { capturedError = err; },
onData: () => {},
onComplete: () => {}
};

const interceptor = createResponseErrorInterceptor((opts, handler) => {
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
handler.onError(new Error('Response Error'));
} else {
handler.onComplete();
}
});

interceptor({ ...opts, response }, handler);

await new Promise(resolve => setImmediate(resolve));

assert(capturedError, 'Expected error to be captured but it was not.');
assert.strictEqual(capturedError.message, 'Response Error');
assert.strictEqual(response.statusCode, 500);
});
```
- **No Error if Status Code is Not in Specified Error Codes**:
```js
test('should not error if request status code is not in the specified error codes', async (t) => {
const opts = { throwOnError: true, statusCodes: [500] };
const response = { statusCode: 404 };
const handler = {
onError: () => {},
onData: () => {},
onComplete: () => {}
};

const interceptor = createResponseErrorInterceptor((opts, handler) => {
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
handler.onError(new Error('Response Error'));
} else {
handler.onComplete();
}
});

assert.doesNotThrow(() => interceptor({ ...opts, response }, handler));
});
```
**Conclusion**
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.
## Instance Events
### Event: `'connect'`
Expand Down
13 changes: 13 additions & 0 deletions lib/core/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,18 @@ class RequestRetryError extends UndiciError {
}
}

class ResponseError extends UndiciError {
constructor (message, code, { headers, data }) {
super(message)
this.name = 'ResponseError'
this.message = message || 'Response error'
this.code = 'UND_ERR_RESPONSE'
this.statusCode = code
this.data = data
this.headers = headers
}
}

class SecureProxyConnectionError extends UndiciError {
constructor (cause, message, options) {
super(message, { cause, ...(options ?? {}) })
Expand Down Expand Up @@ -227,5 +239,6 @@ module.exports = {
BalancedPoolMissingUpstreamError,
ResponseExceededMaxSizeError,
RequestRetryError,
ResponseError,
SecureProxyConnectionError
}
86 changes: 86 additions & 0 deletions lib/interceptor/response-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
'use strict'

const { parseHeaders } = require('../core/util')
const DecoratorHandler = require('../handler/decorator-handler')
const { ResponseError } = require('../core/errors')

class Handler extends DecoratorHandler {
#handler
#statusCode
#contentType
#decoder
#headers
#body

constructor (opts, { handler }) {
super(handler)
this.#handler = handler
}

onConnect (abort) {
this.#statusCode = 0
this.#contentType = null
this.#decoder = null
this.#headers = null
this.#body = ''

return this.#handler.onConnect(abort)
}

onHeaders (statusCode, rawHeaders, resume, statusMessage, headers = parseHeaders(rawHeaders)) {
this.#statusCode = statusCode
this.#headers = headers
this.#contentType = headers['content-type']

if (this.#statusCode < 400) {
return this.#handler.onHeaders(statusCode, rawHeaders, resume, statusMessage, headers)
}

if (this.#contentType === 'application/json' || this.#contentType === 'text/plain') {
this.#decoder = new TextDecoder('utf-8')
}
}

onData (chunk) {
if (this.#statusCode < 400) {
return this.#handler.onData(chunk)
}

this.#body += this.#decoder?.decode(chunk, { stream: true }) ?? ''
}

onComplete (rawTrailers) {
if (this.#statusCode >= 400) {
this.#body += this.#decoder?.decode(undefined, { stream: false }) ?? ''

if (this.#contentType === 'application/json') {
try {
this.#body = JSON.parse(this.#body)
} catch {
// Do nothing...
}
}

let err
const stackTraceLimit = Error.stackTraceLimit
Error.stackTraceLimit = 0
try {
err = new ResponseError('Response Error', this.#statusCode, this.#headers, this.#body)
} finally {
Error.stackTraceLimit = stackTraceLimit
}

this.#handler.onError(err)
} else {
this.#handler.onComplete(rawTrailers)
}
}

onError (err) {
this.#handler.onError(err)
}
}

module.exports = (dispatch) => (opts, handler) => opts.throwOnError
? dispatch(opts, new Handler(opts, { handler }))
: dispatch(opts, handler)
67 changes: 67 additions & 0 deletions test/interceptors/response-error.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict'

const assert = require('assert')
const { test } = require('node:test')
const createResponseErrorInterceptor = require('../../lib/interceptor/response-error')

test('should not error if request is not meant to throw error', async (t) => {
const opts = { throwOnError: false }
const handler = {
onError: () => {},
onData: () => {},
onComplete: () => {}
}

const interceptor = createResponseErrorInterceptor((opts, handler) => handler.onComplete())

assert.doesNotThrow(() => interceptor(opts, handler))
})

test('should error if request status code is in the specified error codes', async (t) => {
const opts = { throwOnError: true, statusCodes: [500] }
const response = { statusCode: 500 }
let capturedError
const handler = {
onError: (err) => {
capturedError = err
},
onData: () => {},
onComplete: () => {}
}

const interceptor = createResponseErrorInterceptor((opts, handler) => {
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
handler.onError(new Error('Response Error'))
} else {
handler.onComplete()
}
})

interceptor({ ...opts, response }, handler)

await new Promise(resolve => setImmediate(resolve))

assert(capturedError, 'Expected error to be captured but it was not.')
assert.strictEqual(capturedError.message, 'Response Error')
assert.strictEqual(response.statusCode, 500)
})

test('should not error if request status code is not in the specified error codes', async (t) => {
const opts = { throwOnError: true, statusCodes: [500] }
const response = { statusCode: 404 }
const handler = {
onError: () => {},
onData: () => {},
onComplete: () => {}
}

const interceptor = createResponseErrorInterceptor((opts, handler) => {
if (opts.throwOnError && opts.statusCodes.includes(response.statusCode)) {
handler.onError(new Error('Response Error'))
} else {
handler.onComplete()
}
})

assert.doesNotThrow(() => interceptor({ ...opts, response }, handler))
})
4 changes: 3 additions & 1 deletion types/interceptors.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ declare namespace Interceptors {
export type DumpInterceptorOpts = { maxSize?: number }
export type RetryInterceptorOpts = RetryHandler.RetryOptions
export type RedirectInterceptorOpts = { maxRedirections?: number }

export type ResponseErrorInterceptorOpts = { throwOnError: boolean }

export function createRedirectInterceptor(opts: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
export function dump(opts?: DumpInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
export function retry(opts?: RetryInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
export function redirect(opts?: RedirectInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
export function responseError(opts?: ResponseErrorInterceptorOpts): Dispatcher.DispatcherComposeInterceptor
}
Loading