@@ -3,13 +3,20 @@ const {
3
3
OperationalError,
4
4
UpstreamServiceError
5
5
} = require ( '@dotcom-reliability-kit/errors' ) ;
6
+ const { Writable } = require ( 'node:stream' ) ;
6
7
7
8
/**
8
9
* @typedef {object } ErrorHandlerOptions
9
10
* @property {string } [upstreamSystemCode]
10
11
* The system code of the upstream system that the `fetch` makes a request to.
11
12
*/
12
13
14
+ /**
15
+ * @typedef {object } FetchResponseBody
16
+ * @property {(stream: Writable) => void } [pipe]
17
+ * A function to pipe a response body stream.
18
+ */
19
+
13
20
/**
14
21
* @typedef {object } FetchResponse
15
22
* @property {boolean } ok
@@ -18,6 +25,8 @@ const {
18
25
* The response HTTP status code.
19
26
* @property {string } url
20
27
* The URL of the response.
28
+ * @property {FetchResponseBody } body
29
+ * A representation of the response body.
21
30
*/
22
31
23
32
/* eslint-disable jsdoc/valid-types */
@@ -48,140 +57,160 @@ function createFetchErrorHandler(options = {}) {
48
57
return async function handleFetchErrors ( input ) {
49
58
let response = input ;
50
59
51
- // If input is a promise, resolve it. We also handle
52
- // more errors this way.
53
- if ( isPromise ( input ) ) {
54
- try {
55
- response = await input ;
56
- } catch ( /** @type {any } */ error ) {
57
- const errorCode = error ?. code || error ?. cause ?. code ;
58
-
59
- // Handle DNS errors
60
- if ( errorCode === 'ENOTFOUND' ) {
61
- const hostname = error ?. hostname || error ?. cause ?. hostname ;
62
- const dnsLookupErrorMessage = `Cound not resolve DNS entry${
63
- hostname ? ` for host ${ hostname } ` : ''
64
- } `;
65
- throw new OperationalError ( {
66
- code : 'FETCH_DNS_LOOKUP_ERROR' ,
67
- message : dnsLookupErrorMessage ,
68
- relatesToSystems,
69
- cause : error
70
- } ) ;
71
- }
60
+ // This outer try/catch is used to make sure that we're able to read
61
+ // the response body in the case of an error. This is important because
62
+ // otherwise node-fetch will leak memory. See the (still not fixed) bug:
63
+ // https://github.com/node-fetch/node-fetch/issues/83
64
+ try {
65
+ // If input is a promise, resolve it. We also handle
66
+ // more errors this way.
67
+ if ( isPromise ( input ) ) {
68
+ try {
69
+ response = await input ;
70
+ } catch ( /** @type {any } */ error ) {
71
+ const errorCode = error ?. code || error ?. cause ?. code ;
72
72
73
- // Handle standardised abort and timeout errors
74
- const abortErrorMessage =
75
- 'The fetch was aborted before the upstream service could respond' ;
76
- if ( error ?. name === 'AbortError' || error ?. name === 'TimeoutError' ) {
77
- throw new OperationalError ( {
78
- code :
79
- error . name === 'AbortError'
80
- ? 'FETCH_ABORT_ERROR'
81
- : 'FETCH_TIMEOUT_ERROR' ,
82
- message : abortErrorMessage ,
83
- relatesToSystems,
84
- cause : error
85
- } ) ;
86
- }
73
+ // Handle DNS errors
74
+ if ( errorCode === 'ENOTFOUND' ) {
75
+ const hostname = error ?. hostname || error ?. cause ?. hostname ;
76
+ const dnsLookupErrorMessage = `Cound not resolve DNS entry${
77
+ hostname ? ` for host ${ hostname } ` : ''
78
+ } `;
79
+ throw new OperationalError ( {
80
+ code : 'FETCH_DNS_LOOKUP_ERROR' ,
81
+ message : dnsLookupErrorMessage ,
82
+ relatesToSystems,
83
+ cause : error
84
+ } ) ;
85
+ }
87
86
88
- // Handle non-standardised timeout errors
89
- if ( error ?. name === 'FetchError' && error ?. type === 'request-timeout' ) {
90
- throw new OperationalError ( {
91
- code : 'FETCH_TIMEOUT_ERROR' ,
92
- message : abortErrorMessage ,
93
- relatesToSystems,
94
- cause : error
95
- } ) ;
96
- }
87
+ // Handle standardised abort and timeout errors
88
+ const abortErrorMessage =
89
+ 'The fetch was aborted before the upstream service could respond' ;
90
+ if ( error ?. name === 'AbortError' || error ?. name === 'TimeoutError' ) {
91
+ throw new OperationalError ( {
92
+ code :
93
+ error . name === 'AbortError'
94
+ ? 'FETCH_ABORT_ERROR'
95
+ : 'FETCH_TIMEOUT_ERROR' ,
96
+ message : abortErrorMessage ,
97
+ relatesToSystems,
98
+ cause : error
99
+ } ) ;
100
+ }
97
101
98
- // Handle socket hangups
99
- if (
100
- errorCode === 'ECONNRESET' ||
101
- error ?. cause ?. name === 'SocketError'
102
- ) {
103
- throw new UpstreamServiceError ( {
104
- code : 'FETCH_SOCKET_HANGUP_ERROR' ,
105
- message : 'The connection to the upstream service was terminated' ,
106
- relatesToSystems,
107
- cause : error
108
- } ) ;
102
+ // Handle non-standardised timeout errors
103
+ if (
104
+ error ?. name === 'FetchError' &&
105
+ error ?. type === 'request-timeout'
106
+ ) {
107
+ throw new OperationalError ( {
108
+ code : 'FETCH_TIMEOUT_ERROR' ,
109
+ message : abortErrorMessage ,
110
+ relatesToSystems,
111
+ cause : error
112
+ } ) ;
113
+ }
114
+
115
+ // Handle socket hangups
116
+ if (
117
+ errorCode === 'ECONNRESET' ||
118
+ error ?. cause ?. name === 'SocketError'
119
+ ) {
120
+ throw new UpstreamServiceError ( {
121
+ code : 'FETCH_SOCKET_HANGUP_ERROR' ,
122
+ message : 'The connection to the upstream service was terminated' ,
123
+ relatesToSystems,
124
+ cause : error
125
+ } ) ;
126
+ }
127
+
128
+ // We don't know what to do with this error so
129
+ // we throw it as-is
130
+ throw error ;
109
131
}
132
+ }
110
133
111
- // We don't know what to do with this error so
112
- // we throw it as-is
113
- throw error ;
134
+ // Check whether the value we were given is a valid response object
135
+ if ( ! isFetchResponse ( response ) ) {
136
+ // This is not an operational error because the invalid
137
+ // input is highly likely to be a programmer error
138
+ throw Object . assign (
139
+ new TypeError (
140
+ 'Fetch handler must be called with a `fetch` response object or a `fetch` promise'
141
+ ) ,
142
+ { code : 'FETCH_ERROR_HANDLER_INVALID_INPUT' }
143
+ ) ;
114
144
}
115
- }
116
145
117
- // Check whether the value we were given is a valid response object
118
- if ( ! isFetchResponse ( response ) ) {
119
- // This is not an operational error because the invalid
120
- // input is highly likely to be a programmer error
121
- throw Object . assign (
122
- new TypeError (
123
- 'Fetch handler must be called with a `fetch` response object or a `fetch` promise'
124
- ) ,
125
- { code : 'FETCH_ERROR_HANDLER_INVALID_INPUT' }
126
- ) ;
127
- }
146
+ // If the response isn't OK, we start throwing errors
147
+ if ( ! response . ok ) {
148
+ // Parse the response URL so we can use the hostname in error messages
149
+ let responseHostName = 'unknown' ;
150
+ if ( typeof response . url === 'string' ) {
151
+ try {
152
+ const url = new URL ( response . url ) ;
153
+ responseHostName = url . hostname ;
154
+ } catch ( _ ) {
155
+ // We ignore this error because having a valid URL isn't essential – it
156
+ // just helps debug if we do have one. If someone's using a weird non-standard
157
+ // `fetch` implementation or mocking then this error could be fired
158
+ }
159
+ }
128
160
129
- // If the response isn't OK, we start throwing errors
130
- if ( ! response . ok ) {
131
- // Parse the response URL so we can use the hostname in error messages
132
- let responseHostName = 'unknown' ;
133
- if ( typeof response . url === 'string' ) {
134
- try {
135
- const url = new URL ( response . url ) ;
136
- responseHostName = url . hostname ;
137
- } catch ( _ ) {
138
- // We ignore this error because having a valid URL isn't essential – it
139
- // just helps debug if we do have one. If someone's using a weird non-standard
140
- // `fetch` implementation or mocking then this error could be fired
161
+ // Some common error options which we'll include in any that are thrown
162
+ const baseErrorOptions = {
163
+ message : `The upstream service at "${ responseHostName } " responded with a ${ response . status } status` ,
164
+ relatesToSystems,
165
+ upstreamUrl : response . url ,
166
+ upstreamStatusCode : response . status
167
+ } ;
168
+
169
+ // If the back end responds with a `4xx` error then it normally indicates
170
+ // that something is wrong with the _current_ system. Maybe we're sending data
171
+ // in an invalid format or our API key is invalid. For this we throw a generic
172
+ // `500` error to indicate an issue with our system.
173
+ if ( response . status >= 400 && response . status < 500 ) {
174
+ throw new HttpError (
175
+ Object . assign (
176
+ { code : 'FETCH_CLIENT_ERROR' , statusCode : 500 } ,
177
+ baseErrorOptions
178
+ )
179
+ ) ;
141
180
}
142
- }
143
181
144
- // Some common error options which we'll include in any that are thrown
145
- const baseErrorOptions = {
146
- message : `The upstream service at "${ responseHostName } " responded with a ${ response . status } status` ,
147
- relatesToSystems,
148
- upstreamUrl : response . url ,
149
- upstreamStatusCode : response . status
150
- } ;
151
-
152
- // If the back end responds with a `4xx` error then it normally indicates
153
- // that something is wrong with the _current_ system. Maybe we're sending data
154
- // in an invalid format or our API key is invalid. For this we throw a generic
155
- // `500` error to indicate an issue with our system.
156
- if ( response . status >= 400 && response . status < 500 ) {
182
+ // If the back end responds with a `5xx` error then it normally indicates
183
+ // that something is wrong with the _upstream_ system. For this we can output
184
+ // an upstream service error and attribute the error to this system.
185
+ if ( response . status >= 500 && response . status < 600 ) {
186
+ throw new UpstreamServiceError (
187
+ Object . assign ( { code : 'FETCH_SERVER_ERROR' } , baseErrorOptions )
188
+ ) ;
189
+ }
190
+
191
+ // If we get here then it's unclear what's wrong – `response.ok` is false but the status
192
+ // isn't in the 400–599 range. We throw a generic 500 error so that we have visibility.
157
193
throw new HttpError (
158
194
Object . assign (
159
- { code : 'FETCH_CLIENT_ERROR ' , statusCode : 500 } ,
195
+ { code : 'FETCH_UNKNOWN_ERROR ' , statusCode : 500 } ,
160
196
baseErrorOptions
161
197
)
162
198
) ;
163
199
}
164
200
165
- // If the back end responds with a `5xx` error then it normally indicates
166
- // that something is wrong with the _upstream_ system. For this we can output
167
- // an upstream service error and attribute the error to this system.
168
- if ( response . status >= 500 && response . status < 600 ) {
169
- throw new UpstreamServiceError (
170
- Object . assign ( { code : 'FETCH_SERVER_ERROR' } , baseErrorOptions )
171
- ) ;
201
+ return response ;
202
+ } catch ( /** @type {any } */ finalError ) {
203
+ // If the response body has a pipe method then we're dealing
204
+ // with a node-fetch body. In this case we need to read the
205
+ // body so we don't introduce a memory leak.
206
+ if (
207
+ isFetchResponse ( response ) &&
208
+ typeof response . body . pipe === 'function'
209
+ ) {
210
+ response . body . pipe ( new BlackHoleStream ( ) ) ;
172
211
}
173
-
174
- // If we get here then it's unclear what's wrong – `response.ok` is false but the status
175
- // isn't in the 400–599 range. We throw a generic 500 error so that we have visibility.
176
- throw new HttpError (
177
- Object . assign (
178
- { code : 'FETCH_UNKNOWN_ERROR' , statusCode : 500 } ,
179
- baseErrorOptions
180
- )
181
- ) ;
212
+ throw finalError ;
182
213
}
183
-
184
- return response ;
185
214
} ;
186
215
}
187
216
@@ -214,4 +243,16 @@ function isFetchResponse(value) {
214
243
return true ;
215
244
}
216
245
246
+ /**
247
+ * Writable stream to pipe data into the void.
248
+ */
249
+ class BlackHoleStream extends Writable {
250
+ /**
251
+ * @override
252
+ */
253
+ _write ( chunk , encoding , done ) {
254
+ done ( ) ;
255
+ }
256
+ }
257
+
217
258
module . exports = createFetchErrorHandler ;
0 commit comments