I needed to update some code at work the other day to add a
timeout to an HTTP GET request. Simple
enough, I thought. How hard could it be?
Looking back, it is pretty straight forward, and the code makes
perfect sense, but definitely wasn’t intuitive when first trying to get it
working. I’m writing this mainly as a
note to myself about the proper way to handle this in the future.
The Starting Point
The code I was updating called a GET endpoint on localhost.
var req = http.get(url, res => {
//handle success
}).on('error', e => {
//handle error
});
Adding a Timeout
I hadn't added a timeout to a request in Node before, using
the built-in HTTP library, so I did what any red-blooded developer would do,
and copy-pasted from elsewhere in the code that was doing something similar.
var req = http.get(url, res => {
//handle success
}).on('error', e => {
//handle error
}).on('socket', sock => {
sock.setTimeout(500);
sock.on('timeout', () => {
req.abort();
});
});
This seemed oddly confusing for something so simple, but
worked fine. Reading the docs, I could
see that the 'socket' event fires when a socket is assigned to the request. We then set a timeout directly on the socket,
and abort the request once that occurs.
I thought the abort wasn't necessary, since the request had already
timed out, so I removed it and it still worked.
The timeout event would fire right away,
instead of waiting the default 2 minutes, like it used to, to error out. I was actually missing something important,
but hadn't noticed it yet.
HTTPS
Next, I had to update http to https, which normally just
means adding a single letter to your require statement. Since this was localhost though, and didn't
have a certificate, I also needed to ignore certificate errors. Again, I used my normal trick of finding
another call that did this, and copying from it.
I found a POST call to a different localhost endpoint, and
saw that it was using https.request (instead of https.get) and that it was
using an options object, instead of passing the URL as a string. The options object had a 'rejectUnauthorized' property, which is what I needed to ignore certificate errors. Why they used the term 'Unauthorized',
instead of 'InvalidCert' or something similar, is beyond me.
I figured that request() just always used an option object,
instead of a URL like get() did, and to get access to the 'rejectUnauthorized' property, I would need to need to switch to it.
So, I changed my http.get to https.request, passed it an option object,
breaking out the host, path, port and method, and passed that in, instead of
the URL string.
let options = {
host: 'localhost',
path: '/resource',
port: '12345',
method: 'GET',
rejectUnauthorized: false
};
var req = http.request(options, res => {
//handle success
}).on('error', e => {
//handle error
}).on('socket', sock => {
sock.setTimeout(500);
sock.on('timeout', () => { });
});
Simple enough, but it didn't work. The call would hang. It took some digging, but it turns out I
missed an important line from the request() example I copied from. I knew that the get() method I was using
before would automatically set the method to GET, but apparently it also calls end()
on the request, which I didn't realize was needed. It makes sense, as the way request are
modified by method chaining means you wouldn't know when you were done updating
the request, but it wasn't obvious at first.
After I got that figured that out, I then realized that both
get() and request() can take either a url or options object, so I actually didn't
need to use request(), and switched back to get() (and removed the call to end()
I had just added). Everything was
working now, until I pushed the code to an environment where the logging code
actually wrote to somewhere.
Aborting
It turns out that whenever my request timed out, I was still
logging an error, but only after 2 minutes.
Again, I dug through the docs and found that when a request times out,
it doesn't actually end. It would keep
going until it was forcefully timed-out and killed, after the default 2-minute
timeout. In the timeout event, you still
need to manually abort the request, which the original code I copied from
did. Yet another example of it never
being a good idea to remove code you don't understand, until you understand it.
That change still didn't get everything working though. The code would still throw an error ("socket hang up"), but it
would do it immediately after the timeout, instead of after 2 minutes. The final piece was realizing that aborting a
request still throws an error, but sets the 'code' property of the error object
to 'ECONNRESET'. So when I checked for
that in the error handler, I could successfully ignore timeouts, and handle
regular errors. Annoyingly though,
error.code is also set to 'ECONNRESET' if the server resets the connection, so to
be truly accurate, you would need to set a variable in the 'timeout' event that
the 'error' event could check. For my
needs though, that wasn't necessary, and I just checked error.code.
Wrapping Up
In the end, I got the code working, and everything makes
sense now. While I understand the logic
behind most design decisions of the API (the name of 'rejectUnauthorized' not
withstanding), I feel like many parts aren't very intuitive, especially for
those coming from a different background, such as .NET.
After finding out about request.setTimeout, which mimicks
the more verbose socket code, the final code is below:
let options = {
host: 'localhost',
path: '/resource',
port: '12345',
method: 'GET',
rejectUnauthorized: false
};
var req = https.get(options, res => {
//handle success
}).on('error', function(err) {
//ignore timeouts
if (err.code === "ECONNRESET") {
return;
}
//handle error
}
).setTimeout(25, () => {
req.abort();
});