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"):

function showStuff() {
    fetch("/get/stuff")
        .then(response => {
            if (!response.ok) {
                throw new Error("Error getting the stuff");
            }
            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:

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.