Skip to content

Commit 9adfc05

Browse files
committed
fix(router): bring behavior of abort() inline with native Node
Nocks behavior around events and errors relating to aborting and ending requests has not lined up with Node for while. I dug into an investigation of how it differs, if there were git commits/pull-requests explaining why they differ, and trying to answer the question of should Nock change to line up better. The single largest deviation were the errors emitted around aborting or already aborted requests. Emitting an error on abort was added to fix issue nock#439. The conversation talks about how Node emits an error post abort. > ... in practice the `http` module does emit an `ECONNRESET` error after aborting. ... - Jan 7, 2016 However, this is only true between the 'socket' and 'response' events. Otherwise calling abort does not emit an error of any kind. Determining exactly what Nock should do is a bit of moving target. As Node's internals of HTTP, Net, Stream, and micro task timing have changed a lot since iojs/v4, when Nock added these errors, and v13, current at this time. My conclusion is that none of Nock's deviations from Node's _documented_ behavior are user features that should be maintained. If anything, bringing Nock closer to Node better fulfills original requests. nock#282 The entire `test_abort.js` file was scraped and rewritten by writing assertions against Node behavior, without Nock first. Then adding Nock into each test to ensure events and errors were correct. BREAKING CHANGE: Calling `request.abort()`: - Use to always emit a 'socket hang up' error. Now only emits the error if `abort` is called between the 'socket' and 'response' events. - The emitted 'abort' event now happens on `nextTick`. - The socket is now only destroyed if the 'socket' event has fired, and now emits a 'close' event on `nextTick` that propagates though the request object. - `request.aborted` attribute is set to `true` instead of a timestamp. Changed in Node v11.0 nodejs/node#20230 Calling `write`, `end`, or `flushHeaders` on an aborted request no longer emits an error. However, writing to a request that is already finished (ended) will emit a 'write after end' error. Playback of a mocked responses will now never happen until the 'socket' event is emitted. The 'socket' event is still artificially set to emit on `nextTick` when a ClientRequest is created. This means in the following code the Scope will never be done because at least one tick needs to happen before any matched Interceptor is considered consumed. ```js const scope = nock(...).get('/').reply() const req = http.get(...) scope.done() ```
1 parent d81c260 commit 9adfc05

11 files changed

+372
-314
lines changed

lib/intercepted_request_router.js

+80-52
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,14 @@ class InterceptedRequestRouter {
4040

4141
this.response = new IncomingMessage(this.socket)
4242
this.playbackStarted = false
43+
this.playbackOnSocket = false
4344
this.requestBodyBuffers = []
4445

4546
this.attachToReq()
4647
}
4748

4849
attachToReq() {
49-
const { req, response, socket, options } = this
50-
51-
response.req = req
50+
const { req, socket, options } = this
5251

5352
for (const [name, val] of Object.entries(options.headers)) {
5453
req.setHeader(name.toLowerCase(), val)
@@ -71,7 +70,7 @@ class InterceptedRequestRouter {
7170
// The same Socket is shared between the request and response to mimic native behavior.
7271
req.socket = req.connection = socket
7372

74-
propagate(['error', 'timeout'], req.socket, req)
73+
propagate(['close', 'error', 'timeout'], req.socket, req)
7574

7675
req.write = (...args) => this.handleWrite(...args)
7776
req.end = (...args) => this.handleEnd(...args)
@@ -90,6 +89,11 @@ class InterceptedRequestRouter {
9089
// Some clients listen for a 'socket' event to be emitted before calling end(),
9190
// which causes nock to hang.
9291
process.nextTick(() => {
92+
if (req.aborted) {
93+
return
94+
}
95+
96+
socket.connecting = false
9397
req.emit('socket', socket)
9498

9599
// https://nodejs.org/api/net.html#net_event_connect
@@ -99,6 +103,10 @@ class InterceptedRequestRouter {
99103
if (socket.authorized) {
100104
socket.emit('secureConnect')
101105
}
106+
107+
if (this.playbackOnSocket) {
108+
this.maybeStartPlayback()
109+
}
102110
})
103111
}
104112

@@ -109,26 +117,38 @@ class InterceptedRequestRouter {
109117
})
110118
}
111119

120+
// from docs: When write function is called with empty string or buffer, it does nothing and waits for more input.
121+
// However, actually implementation checks the state of finished and aborted before checking if the first arg is empty.
112122
handleWrite(buffer, encoding, callback) {
113123
debug('write', arguments)
114124
const { req } = this
115125

116-
if (!req.aborted) {
117-
if (buffer) {
118-
if (!Buffer.isBuffer(buffer)) {
119-
buffer = Buffer.from(buffer, encoding)
120-
}
121-
this.requestBodyBuffers.push(buffer)
122-
}
123-
// can't use instanceof Function because some test runners
124-
// run tests in vm.runInNewContext where Function is not same
125-
// as that in the current context
126-
// https://github.com/nock/nock/pull/1754#issuecomment-571531407
127-
if (typeof callback === 'function') {
128-
callback()
129-
}
130-
} else {
131-
this.emitError(new Error('Request aborted'))
126+
if (req.finished) {
127+
const err = new Error('write after end')
128+
err.code = 'ERR_STREAM_WRITE_AFTER_END'
129+
this.emitError(err)
130+
return true
131+
}
132+
133+
if (req.aborted) {
134+
return false
135+
}
136+
137+
if (!buffer || buffer.length === 0) {
138+
return true
139+
}
140+
141+
if (!Buffer.isBuffer(buffer)) {
142+
buffer = Buffer.from(buffer, encoding)
143+
}
144+
this.requestBodyBuffers.push(buffer)
145+
146+
// can't use instanceof Function because some test runners
147+
// run tests in vm.runInNewContext where Function is not same
148+
// as that in the current context
149+
// https://github.com/nock/nock/pull/1754#issuecomment-571531407
150+
if (typeof callback === 'function') {
151+
callback()
132152
}
133153

134154
common.setImmediate(function() {
@@ -142,7 +162,7 @@ class InterceptedRequestRouter {
142162
debug('req.end')
143163
const { req } = this
144164

145-
// handle the different overloaded param signatures
165+
// handle the different overloaded arg signatures
146166
if (typeof chunk === 'function') {
147167
callback = chunk
148168
chunk = null
@@ -155,51 +175,45 @@ class InterceptedRequestRouter {
155175
req.once('finish', callback)
156176
}
157177

158-
if (!req.aborted && !this.playbackStarted) {
159-
req.write(chunk, encoding)
160-
this.startPlayback()
161-
}
162-
if (req.aborted) {
163-
this.emitError(new Error('Request aborted'))
164-
}
178+
req.write(chunk, encoding)
179+
req.finished = true
180+
this.maybeStartPlayback()
165181

166182
return req
167183
}
168184

169185
handleFlushHeaders() {
170186
debug('req.flushHeaders')
171-
const { req } = this
172-
173-
if (!req.aborted && !this.playbackStarted) {
174-
this.startPlayback()
175-
}
176-
if (req.aborted) {
177-
this.emitError(new Error('Request aborted'))
178-
}
187+
this.maybeStartPlayback()
179188
}
180189

181190
handleAbort() {
182191
debug('req.abort')
183-
const { req, response, socket } = this
192+
const { req, socket } = this
184193

185194
if (req.aborted) {
186195
return
187196
}
188-
req.aborted = Date.now()
189-
if (!this.playbackStarted) {
190-
this.startPlayback()
197+
req.aborted = true // as of v11 this is a bool instead of a timestamp
198+
req.destroyed = true
199+
200+
// the order of these next three steps is important to match order of events Node would emit.
201+
process.nextTick(() => req.emit('abort'))
202+
203+
if (!socket.connecting) {
204+
if (!req.res) {
205+
// If we don't have a response then we know that the socket
206+
// ended prematurely and we need to emit an error on the request.
207+
// Node doesn't actually emit this during the `abort` method.
208+
// Instead it listens to the socket's `end` and `close` events, however,
209+
// Nock's socket doesn't have all the complexities and events to
210+
// replicated that directly. This is an easy way to achieve the same behavior.
211+
const connResetError = new Error('socket hang up')
212+
connResetError.code = 'ECONNRESET'
213+
this.emitError(connResetError)
214+
}
215+
socket.destroy()
191216
}
192-
const err = new Error()
193-
err.code = 'aborted'
194-
response.emit('close', err)
195-
196-
socket.destroy()
197-
198-
req.emit('abort')
199-
200-
const connResetError = new Error('socket hang up')
201-
connResetError.code = 'ECONNRESET'
202-
this.emitError(connResetError)
203217
}
204218

205219
/**
@@ -233,6 +247,21 @@ class InterceptedRequestRouter {
233247
}
234248
}
235249

250+
maybeStartPlayback() {
251+
const { req, socket, playbackStarted } = this
252+
253+
// In order to get the events in the right order we need to delay playback
254+
// if we get here before the `socket` event is emitted.
255+
if (socket.connecting) {
256+
this.playbackOnSocket = true
257+
return
258+
}
259+
260+
if (!req.aborted && !playbackStarted) {
261+
this.startPlayback()
262+
}
263+
}
264+
236265
startPlayback() {
237266
debug('ending')
238267
this.playbackStarted = true
@@ -274,7 +303,6 @@ class InterceptedRequestRouter {
274303

275304
// wait to emit the finish event until we know for sure an Interceptor is going to playback.
276305
// otherwise an unmocked request might emit finish twice.
277-
req.finished = true
278306
req.emit('finish')
279307

280308
playbackInterceptor({

lib/playback_interceptor.js

+19-15
Original file line numberDiff line numberDiff line change
@@ -257,10 +257,6 @@ function playbackInterceptor({
257257

258258
interceptor.markConsumed()
259259

260-
if (req.aborted) {
261-
return
262-
}
263-
264260
response.rawHeaders.push(
265261
...selectDefaultHeaders(
266262
response.rawHeaders,
@@ -277,24 +273,24 @@ function playbackInterceptor({
277273

278274
response.headers = common.headersArrayToObject(response.rawHeaders)
279275

280-
process.nextTick(() =>
281-
respondUsingInterceptor({
282-
responseBody,
283-
responseBuffers,
284-
})
285-
)
276+
respondUsingInterceptor({
277+
responseBody,
278+
responseBuffers,
279+
})
286280
}
287281

288282
function respondUsingInterceptor({ responseBody, responseBuffers }) {
289-
if (req.aborted) {
290-
return
291-
}
292-
293283
function respond() {
294284
if (req.aborted) {
295285
return
296286
}
297287

288+
// Even though we've had the response object for awhile at this point,
289+
// we only attach it to the request immediately before the `response`
290+
// event because, as in Node, it alters the error handling around aborts.
291+
req.res = response
292+
response.req = req
293+
298294
debug('emitting response')
299295
req.emit('response', response)
300296

@@ -342,7 +338,15 @@ function playbackInterceptor({
342338
}
343339
}
344340

345-
start()
341+
// stall the playback to ensure the correct events are emitted first and
342+
// any aborts in the queue can be called.
343+
common.setImmediate(() => {
344+
if (req.aborted) {
345+
return
346+
}
347+
348+
start()
349+
})
346350
}
347351

348352
module.exports = { playbackInterceptor }

lib/socket.js

+13-4
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ module.exports = class Socket extends EventEmitter {
1818
this.readable = true
1919
this.pending = false
2020
this.destroyed = false
21-
this.connecting = false
21+
this.connecting = true
2222

2323
// totalDelay that has already been applied to the current
2424
// request/connection, timeout error will be generated if
@@ -70,11 +70,20 @@ module.exports = class Socket extends EventEmitter {
7070
}
7171

7272
destroy(err) {
73+
if (this.destroyed) {
74+
return this
75+
}
76+
7377
this.destroyed = true
7478
this.readable = this.writable = false
75-
if (err) {
76-
this.emit('error', err)
77-
}
79+
80+
process.nextTick(() => {
81+
if (err) {
82+
this.emit('error', err)
83+
}
84+
this.emit('close')
85+
})
86+
7887
return this
7988
}
8089
}

0 commit comments

Comments
 (0)