Skip to content

Commit 1f2cca6

Browse files
kibertoadronag
andauthored
feat: Option to throw on error status codes (nodejs#1453)
* Improve coverage * Update TS types * Fix linting * Improve naming, add type tests * Address code review comments * make check explicit Co-authored-by: Robert Nagy <[email protected]> * make condition more explicit Co-authored-by: Robert Nagy <[email protected]> Co-authored-by: Robert Nagy <[email protected]>
1 parent 6b1e2ee commit 1f2cca6

File tree

7 files changed

+103
-5
lines changed

7 files changed

+103
-5
lines changed

docs/api/Dispatcher.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,13 +200,14 @@ Returns: `Boolean` - `false` if dispatcher is busy and further dispatch calls wo
200200
* **upgrade** `string | null` (optional) - Default: `null` - Upgrade the request. Should be used to specify the kind of upgrade i.e. `'Websocket'`.
201201
* **bodyTimeout** `number | null` (optional) - The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use `0` to disable it entirely. Defaults to 30 seconds.
202202
* **headersTimeout** `number | null` (optional) - The amount of time the parser will wait to receive the complete HTTP headers. Defaults to 30 seconds.
203+
* **throwOnError** `boolean` (optional) - Default: `false` - Whether Undici should throw an error upon receiving a 4xx or 5xx response from the server.
203204

204205
#### Parameter: `DispatchHandler`
205206

206207
* **onConnect** `(abort: () => void, context: object) => void` - Invoked before request is dispatched on socket. May be invoked multiple times when a request is retried when the request at the head of the pipeline fails.
207208
* **onError** `(error: Error) => void` - Invoked when an error has occurred. May not throw.
208209
* **onUpgrade** `(statusCode: number, headers: Buffer[], socket: Duplex) => void` (optional) - Invoked when request is upgraded. Required if `DispatchOptions.upgrade` is defined or `DispatchOptions.method === 'CONNECT'`.
209-
* **onHeaders** `(statusCode: number, headers: Buffer[], resume: () => void) => boolean` - Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. Not required for `upgrade` requests.
210+
* **onHeaders** `(statusCode: number, headers: Buffer[], resume: () => void, statusText: string) => boolean` - Invoked when statusCode and headers have been received. May be invoked multiple times due to 1xx informational headers. Not required for `upgrade` requests.
210211
* **onData** `(chunk: Buffer) => boolean` - Invoked when response payload data is received. Not required for `upgrade` requests.
211212
* **onComplete** `(trailers: Buffer[]) => void` - Invoked when response payload and trailers have been received and the request has completed. Not required for `upgrade` requests.
212213
* **onBodySent** `(chunk: string | Buffer | Uint8Array) => void` - Invoked when a body chunk is sent to the server. Not required. For a stream or iterable body this will be invoked for every chunk. For other body types, it will be invoked once after the body is sent.

lib/api/api-request.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
const Readable = require('./readable')
44
const {
55
InvalidArgumentError,
6-
RequestAbortedError
6+
RequestAbortedError,
7+
ResponseStatusCodeError
78
} = require('../core/errors')
89
const util = require('../core/util')
910
const { AsyncResource } = require('async_hooks')
@@ -15,7 +16,7 @@ class RequestHandler extends AsyncResource {
1516
throw new InvalidArgumentError('invalid opts')
1617
}
1718

18-
const { signal, method, opaque, body, onInfo, responseHeaders } = opts
19+
const { signal, method, opaque, body, onInfo, responseHeaders, throwOnError } = opts
1920

2021
try {
2122
if (typeof callback !== 'function') {
@@ -51,6 +52,7 @@ class RequestHandler extends AsyncResource {
5152
this.trailers = {}
5253
this.context = null
5354
this.onInfo = onInfo || null
55+
this.throwOnError = throwOnError
5456

5557
if (util.isStream(body)) {
5658
body.on('error', (err) => {
@@ -70,7 +72,7 @@ class RequestHandler extends AsyncResource {
7072
this.context = context
7173
}
7274

73-
onHeaders (statusCode, rawHeaders, resume) {
75+
onHeaders (statusCode, rawHeaders, resume, statusMessage) {
7476
const { callback, opaque, abort, context } = this
7577

7678
if (statusCode < 200) {
@@ -89,6 +91,13 @@ class RequestHandler extends AsyncResource {
8991
const headers = this.responseHeaders === 'raw' ? util.parseRawHeaders(rawHeaders) : util.parseHeaders(rawHeaders)
9092

9193
if (callback !== null) {
94+
if (this.throwOnError && statusCode >= 400) {
95+
this.runInAsyncScope(callback, null,
96+
new ResponseStatusCodeError(`Response status code ${statusCode}${statusMessage ? `: ${statusMessage}` : ''}`, statusCode, headers)
97+
)
98+
return
99+
}
100+
92101
this.runInAsyncScope(callback, null, null, {
93102
statusCode,
94103
headers,

lib/core/errors.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ class BodyTimeoutError extends UndiciError {
5656
}
5757
}
5858

59+
class ResponseStatusCodeError extends UndiciError {
60+
constructor (message, statusCode, headers) {
61+
super(message)
62+
Error.captureStackTrace(this, ResponseStatusCodeError)
63+
this.name = 'ResponseStatusCodeError'
64+
this.message = message || 'Response Status Code Error'
65+
this.code = 'UND_ERR_RESPONSE_STATUS_CODE'
66+
this.status = statusCode
67+
this.statusCode = statusCode
68+
this.headers = headers
69+
}
70+
}
71+
5972
class InvalidArgumentError extends UndiciError {
6073
constructor (message) {
6174
super(message)
@@ -186,6 +199,7 @@ module.exports = {
186199
BodyTimeoutError,
187200
RequestContentLengthMismatchError,
188201
ConnectTimeoutError,
202+
ResponseStatusCodeError,
189203
InvalidArgumentError,
190204
InvalidReturnValueError,
191205
RequestAbortedError,

lib/core/request.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ class Request {
4343
blocking,
4444
upgrade,
4545
headersTimeout,
46-
bodyTimeout
46+
bodyTimeout,
47+
throwOnError
4748
}, handler) {
4849
if (typeof path !== 'string') {
4950
throw new InvalidArgumentError('path must be a string')
@@ -71,6 +72,8 @@ class Request {
7172

7273
this.bodyTimeout = bodyTimeout
7374

75+
this.throwOnError = throwOnError === true
76+
7477
this.method = method
7578

7679
if (body == null) {

test/client.js

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,72 @@ test('basic get with query params partially in path', (t) => {
315315
})
316316
})
317317

318+
test('basic get returns 400 when configured to throw on errors (callback)', (t) => {
319+
t.plan(6)
320+
321+
const server = createServer((req, res) => {
322+
res.statusCode = 400
323+
res.end('hello')
324+
})
325+
t.teardown(server.close.bind(server))
326+
327+
server.listen(0, () => {
328+
const client = new Client(`http://localhost:${server.address().port}`, {
329+
keepAliveTimeout: 300e3
330+
})
331+
t.teardown(client.close.bind(client))
332+
333+
const signal = new EE()
334+
client.request({
335+
signal,
336+
path: '/',
337+
method: 'GET',
338+
throwOnError: true
339+
}, (err) => {
340+
t.equal(err.message, 'Response status code 400: Bad Request')
341+
t.equal(err.status, 400)
342+
t.equal(err.statusCode, 400)
343+
t.equal(err.headers.connection, 'keep-alive')
344+
t.equal(err.headers['content-length'], '5')
345+
})
346+
t.equal(signal.listenerCount('abort'), 1)
347+
})
348+
})
349+
350+
test('basic get returns 400 when configured to throw on errors (promise)', (t) => {
351+
t.plan(5)
352+
353+
const server = createServer((req, res) => {
354+
res.writeHead(400, 'Invalid params', { 'content-type': 'text/plain' })
355+
res.end('Invalid params')
356+
})
357+
t.teardown(server.close.bind(server))
358+
359+
server.listen(0, async () => {
360+
const client = new Client(`http://localhost:${server.address().port}`, {
361+
keepAliveTimeout: 300e3
362+
})
363+
t.teardown(client.close.bind(client))
364+
365+
const signal = new EE()
366+
try {
367+
await client.request({
368+
signal,
369+
path: '/',
370+
method: 'GET',
371+
throwOnError: true
372+
})
373+
t.fail('Should throw an error')
374+
} catch (err) {
375+
t.equal(err.message, 'Response status code 400: Invalid params')
376+
t.equal(err.status, 400)
377+
t.equal(err.statusCode, 400)
378+
t.equal(err.headers.connection, 'keep-alive')
379+
t.equal(err.headers['content-type'], 'text/plain')
380+
}
381+
})
382+
})
383+
318384
test('basic head', (t) => {
319385
t.plan(14)
320386

test/types/dispatcher.test-d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ expectAssignable<Dispatcher>(new Dispatcher())
2626

2727
// request
2828
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0 }))
29+
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, query: {} }))
30+
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, query: { pageNum: 1, id: 'abc' } }))
31+
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: '', path: '', method: 'GET', maxRedirections: 0, throwOnError: true }))
2932
expectAssignable<Promise<Dispatcher.ResponseData>>(dispatcher.request({ origin: new URL('http://localhost'), path: '', method: 'GET' }))
3033
expectAssignable<void>(dispatcher.request({ origin: '', path: '', method: 'GET' }, (err, data) => {
3134
expectAssignable<Error | null>(err)

types/dispatcher.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ declare namespace Dispatcher {
5757
headersTimeout?: number | null;
5858
/** The timeout after which a request will time out, in milliseconds. Monitors time between receiving body data. Use 0 to disable it entirely. Defaults to 30 seconds. */
5959
bodyTimeout?: number | null;
60+
/** Whether Undici should throw an error upon receiving a 4xx or 5xx response from the server. Defaults to false */
61+
throwOnError?: boolean;
6062
}
6163
export interface ConnectOptions {
6264
path: string;

0 commit comments

Comments
 (0)