Friday 15 June 2018

Common `fetch` errors

The new fetch API is a great improvement on XMLHttpRequest but it does have a very surprising behavior that catches people out a lot. Separately, because it uses promises, a lot of people using it fall into a common promise-related error as well. This post is a quick note about both of those.

Let's say we have an endpoint that returns JSON. This is the common code you see for consuming that:

function showStuff() {
    fetch("/get/stuff")
        .then(response => response.json())
        .then(data => {
            // ...show the stuff from `data`
        });
}

There are two significant errors in the above, can you see them?

Right! They are:

  1. fetch only rejects on network errors; HTTP-level errors (like 404s or 500s) are still resolutions, but the above assumes that resolution = all is good.
  2. Rejections aren't handled at all.

So if the server is overloaded or some such, instead of handling it gracefully, the code will just fail as of the result.json() call since what it gets back is probably an HTML page for a 500 error, not JSON. And then it completely fails to handle that error, resulting in an "Unhandled rejection" error in the console.

I'm going to take these one at a time because they are completely distinct issues.

To fix #1, we need to check for response.ok, which is set for any response where the HTTP status code is 200-299 ("success"). The simple version is something like this:

function showStuff() {
    fetch("/get/stuff")
        .then(response => {
            if (!response.ok) {
                throw new Error("Failed with HTTP code " + response.status);
            }
            return response;
        })
        .then(result => result.json())
        .then(data => {
            // ...show the stuff from `data`
        });
}

There I've turned the resolution with a non-ok status into a rejection by throwing in the then handler. You may choose to do something different, but making it a rejection means all my following logic can just work on the basis that things were okay.

If, like me, you always want to do that, you might give yourself a helper function (but you may want something slightly more elaborate, read on):

function myFetch(...args) {
    return fetch(...args).then(response => {
        if (!response.ok) {
            throw new Error("Failed with HTTP code " + response.status);
        }
        return response;
    });
}

Now I can use myFetch and know that resolutions are for completely successful calls. There are lots of ways one might vary that, but that's the basic idea. Now our example is:

function showStuff() {
    myFetch("/get/stuff")
        .then(result => result.json())
        .then(data => {
            // ...show the stuff from `data`
        });
}

That's #1 sorted. But the second problem remains.

For #2, we need to follow the promise commandment:

Handle errors, or return the promise to something that does.
(I don't know if anyone actually said that — well, anyone but me. :-) ) In our example situation, it appears that showStuff is meant to be fairly standalone, so we'd handle errors inline:

function showStuff() {
    myFetch("/get/stuff")
        .then(result => result.json())
        .then(data => {
            // ...show the stuff from `data`
        })
        .catch(err => {
            // ...do something with the error that occurred
        });
}

But it would also be perfectly valid to make showStuff return the promise. In that case, we probably wouldn't call it showStuff, but perhaps getFormattedStuff on the assumption the caller will either handle the error or use the formatted result:

function showStuff() {
    return myFetch("/get/stuff")
        .then(result => result.json())
        .then(data => {
            // ...format the stuff from `data`
            return formattedStuff;
        });
}

Now it's up to the caller to handle things, both resolution (with formatted stuff) or rejection.

If, like me, you want to have a utility wrapper so you don't always have to write that response.ok logic every time. I also usually know in advance what I'm going to do with the response body (response.json(), response.text(), etc.). To make the wrapper maximally useful, we want to throw an error (because rejections should always be Errors) but also include the response, because it's perfectly valid to want to read the body of an error response. To provide that, I have an Error subclass:

class FetchError extends Error {
    constructor(response) {
        super(`HTTP error ${response.status}`);
        this.response = response;
    }
}

Then my wrappers use it:

function fetchJSON(...args) {
    return fetch(...args)
    .then(response => {
        if (!response.ok) {
            throw new FetchError(response);
        }
        return response.json();
    });
}

function fetchText(...args) {
    return fetch(...args)
    .then(response => {
        if (!response.ok) {
            throw new FetchError(response);
        }
        return response.text();
    });
}

// ...

That way, if the code using the wrapper needs to read the body of the error response, it can by using the response property on the FetchError instance.