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

interceptors: move throwOnError to interceptor #3331

Merged
merged 28 commits into from
Jul 11, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
4192b38
interceptors: move throwOnError to interceptor
Jun 16, 2024
e2321ec
delete http-errors
Jun 17, 2024
7855225
Update response-error.js
mertcanaltin Jun 17, 2024
ea068eb
Update response-error.js
mertcanaltin Jun 17, 2024
6782734
Update lib/interceptor/response-error.js
mertcanaltin Jun 17, 2024
eb29a2a
feat: added new undiciError
Jun 19, 2024
75fcf70
feat: enable interceptor by default
Jun 19, 2024
b2110d4
feat: always throw error when interceptor is used
Jun 19, 2024
e7512d3
feat: add option to throw error on specific status codes
Jun 19, 2024
2195af1
feat: export retry interceptor in index.js
Jun 19, 2024
124e0db
Update response-error.js
mertcanaltin Jun 20, 2024
d731d9f
Update response-error.js
mertcanaltin Jun 21, 2024
b0700c3
Update response-error.js
mertcanaltin Jun 21, 2024
821ea1d
Update response-error.js
mertcanaltin Jun 21, 2024
463f9a5
Update response-error.js
mertcanaltin Jun 21, 2024
a8c3149
Update response-error.js
mertcanaltin Jun 21, 2024
f5adfb2
Update lib/api/index.js
mertcanaltin Jul 1, 2024
a1b282f
lint & added more test
Jul 1, 2024
43a1b07
Update response-error.js
mertcanaltin Jul 1, 2024
0d6f450
Update response-error.js
mertcanaltin Jul 1, 2024
8980063
Update response-error.js
mertcanaltin Jul 1, 2024
636dd26
fix: test repair & unused values delete
Jul 1, 2024
72cea91
Merge branch 'main' of /~https://github.com/mertcanaltin/undici into ad…
Jul 8, 2024
ab619ec
fix: test problem solved
Jul 8, 2024
d5b2eb0
added types
Jul 9, 2024
e0dd09d
added Interceptor info in md
Jul 10, 2024
f10fc30
repair doc
Jul 11, 2024
ccd83e6
Update lib/interceptor/response-error.js
mertcanaltin Jul 11, 2024
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 @@ -1097,3 +1097,200 @@ or
}
}
```

# Response Error Interceptor
mertcanaltin marked this conversation as resolved.
Show resolved Hide resolved

## 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 {
mertcanaltin marked this conversation as resolved.
Show resolved Hide resolved
// 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.
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('undici')
mertcanaltin marked this conversation as resolved.
Show resolved Hide resolved
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)) {
mertcanaltin marked this conversation as resolved.
Show resolved Hide resolved
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')
}
mertcanaltin marked this conversation as resolved.
Show resolved Hide resolved
}

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) {
mertcanaltin marked this conversation as resolved.
Show resolved Hide resolved
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
mertcanaltin marked this conversation as resolved.
Show resolved Hide resolved
? 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