Sunday, June 17, 2018

HTTP Timeouts in Node



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();
});

3 comments: