Pattern for throwing errors recovered from standard-format HTTP payload
Your redux action does work over HTTP. Sometimes the server responds with bad news, and it seems like there's a standardized format the server uses to report that news. Also, sometimes your own code throws. You want to handle both kinds of problem with control structures related to Errors.
Basic pattern for an async Redux action
Before we start: your action is marked async, but you're still chaining .then and .catch. Let's switch to async/await, converting this:
export const addEmployee = (/*...*/) = async ( dispatch, getState ) => { fetch(/* ... */) .then(response => { return response.text() .then(text => { // happy-path logic throw Error(text) }) }) .catch(error => { // sad-path logic dispatch(/* ... */) }) }
...into this:
export const addEmployee = (/*...*/) = async ( dispatch, getState ) => { try { let response = await fetch(/* ... */) let responseText = await response.text() // happy-path logic dispatch(/* ... */) return // a redux action should return something meaningful } catch ( error ) { // sad-path logic dispatch(/* ... */) return // a failed redux action should also return something meaningful } }
Now let's talk about errors.
Error basics
Meet throw:
try { throw 'mud' } catch( exception ) { /* exception === 'mud' */ } try { throw 5 } catch( exception ) { /* exception === 5 */ } try { throw new Date() } catch( exception ) { /* exception is a Date */ }
You can throw just about anything. When you do, execution halts and immediately jumps to the closest catch, searching all the way through the stack until it finds one or runs out of stack. Wherever it lands, the value you provided to throw becomes the argument received by catch (known as an "exception"). If nothing catches it, your JS console logs it as an "uncaught exception."
You can throw anything, but what should you throw? I think you should only throw instances of Error, or one of its subclasses. The two main reasons are that the Error class does some helpful things (like capturing a stacktrace), and because one of your two sources of failure is already going to be throwing Error instances, so you must do something similar if you wish to handle both with a single codepath.
Meet Error:
try { throw new Error('bad news') } catch ( error ) { console.log(error.message) //> 'bad news' }
We already know that an Error will be thrown if code within your action blows up, e.g. JSON.parse fails on the response body, So we don't have to do anything special to direct execution onto the catch path in those scenarios.
The only thing we have to be responsible for is to check whether the HTTP response contains something that looks like your server's "standard error payload" (more on that later), which your sample suggests is this:
{ "errors": [ { "msg": "ERROR CONTENT HERE" } ] }
Here's the core issue
This handling has to be special because no javascript engine considers it an error simply to receive an HTTP payload that can be parsed as JSON and which contains a key named "errors". (Nor should they.) This payload pattern is merely a custom convention used by some or all of the HTTP endpoints that you talk to.
That's not to say it's a bad idea. (I think it's great!) But that explains why it must be done custom: because this pattern is just your private little thing, and not actually special in a way that would make browsers treat it the special way you want.
So here's our plan:
- make the request, relying on try/catch to capture things thrown by our tools
- if we get a response that seems bad:
- examine the payload for an error encoded in the "standard format"; I call anything like this an "API error"
- if we find an API error, we will create and throw our own
Error, using the API error content as its message - if we don't find an API error, we'll treat the raw body text of the response as the error message
- if we get a response that seems good:
- dispatch the good news (and useful data) to the store
Here's what that looks like in code:
export const addEmployee = ({ firstName, surname, contactNumber, email }) => async ( dispatch, getState ) => { const payloadBody = { firstName, surname, contactNumber, email } try { // step 1 let response = await fetch('/api/users', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payloadBody) }) let responseText = await response.text() if (!response.ok) { // step 2 let errorString = getErrorMessageFromResponseBody(responseText) throw new Error(errorString) // API errors get thrown here } // step 3 let responseJson = JSON.parse(responseText) dispatch(setAlert('New Employee added', responseJson.user.name)) /* A redux action should always returns something useful. addEmployee might return the `user` object that was created. */ return responseJson.user } catch ( error ) { // all errors land here dispatch({ type: REGISTER_FAIL, message: error.message }) /* A failed redux action should always return something useful (unless you prefer to throw). For now, we'll return the reason for the failure. */ return error.message } } function getErrorMessageFromResponseBody( string ) { let errorString = string try { let json = JSON.parse(string) if(json.errors) { errorString = json.errors[0].msg } } catch ( parseOrAccessError ) {} return errorString }
Here's what can be thrown to that catch block:
- anything thrown by
JSON.parse when applied to the arguments - anything thrown by
fetch - if
!response.ok, the whole response payload (or just an error message if the payload contains an API error)
Exception handling
How can you tell those different kinds of failure apart? Two ways:
- Some failures throw specific subclasses of
Error, which you can test for with error instanceof SomeErrorClass: JSON.stringify throws a TypeError if it can't serialize its argument (if you have custom .toJSON anywhere, it can also throw anything that throws) fetch throws a TypeError if it can't reach the internet JSON.parse throws a SyntaxError if the string can't be parsed (if you use a custom reviver, those errors get thrown too)
- Any instance of
Error or its subclasses will have a .message; you can test that string for specific cases
How should you handle them?
- If
JSON.stringify blows up, it's because you wired your data wrong. In that case, you probably want to do something that will alert the developer that something is broken and help diagnose the issue: console.error(error) - dispatch some failure action that includes the
error.message - show a generic error message on-screen
- If
fetch throws, you could dispatch a failure that presents a "fix your wifi" warning to the user. - If
JSON.parse throws, the server is melting down, and you should show a generic error message.
A little sophistication
Those are the basic mechanics, but now you confront a messy situation. Let's list some challenges:
- You may have already noticed one problem: "no internet" will present the same way as "circular data": a thrown
TypeError. - It turns out that the precise text of
JSON.stringify errors depends on the actual value supplied to that function, so you can't do something like error.message === CONSTANT_STRINGIFY_ERROR_MESSAGE. - You may not have an exhaustive list of every
msg value the server can send in an API error.
So how are you supposed to tell the difference between a problem reported by a sane server vs a client-side bug vs a broken server vs unusable user data?
First, I recommend creating a special class for API errors. This lets us detect server-reported problems in a reliable way. And it provides a decent place for the logic inside getErrorMessageFromResponseBody.
class APIError extends Error {} APIError.fromResponseText = function ( responseText ) { // TODO: paste entire impl of getErrorMessageFromResponseBody let message = getErrorMessageFromResponseBody(responseText) return new APIError(message) }
Then, we can do:
// throwing if (!response.ok) { // step 2 throw APIError.fromResponseText(responseText) } // detecting catch ( exception ) { if(exception instanceof APIError) { switch(APIError.message) { case 'User already exist with email': // special logic break case 'etc': // ... } } }
Second, when throwing your own errors, never provide a dynamic string as the message.
Error messages for sane people
Consider:
function add( x, y ) { if(typeof x !== 'number') throw new Error(x + ' is not a number') if(typeof y !== 'number') throw new Error(y + ' is not a number') return x + y }
Every time add is called with a different non-numeric x, the error.message will be different:
add('a', 1) //> 'a is not a number' add({ species: 'dog', name: 'Fido' }, 1) //> '[object Object] is not a number'
The problem in both cases is that I've provided an unacceptable value for x, but the messages are different. That makes it unnecessarily hard to group those cases together at runtime. My example even makes it impossible to tell whether it's x or y that offends!
These troubles apply pretty generally to the errors you'll receive from native and library code. My advice is to not repeat them in your own code if you can avoid it.
The simplest remedy I've found is just to always use static strings for error messages, and put some thought into establishing conventions for yourself. Here's what I do.
There are generally two kinds of errors:
- some value I wish to use is objectionable
- some operation I attempted has failed
In the first case, the relevant info is:
- which datapoint is bad; I call this the "topic"
- why it is bad, in one word; I call this the "objection"
All error messages related to objectionable values ought to include both datapoints, and in a manner that is consistent enough to facilitate flow-control while remaining understandable by a human. And ideally you should be able to grep the codebase for the literal message to find every place that can throw the error (this helps enormously with maintenance).
Here is how I construct the messages:
[objection] [topic]
There is usually a discrete set of objections:
- missing: value was not supplied
- unknown: could not find value in DB & other "bad key" issues
- unavailable: value is already taken (e.g. username)
- forbidden: sometimes specific values are off-limits despite being otherwise fine (e.g. no user may have username "root")
- invalid: heavily overused by dev community; treat as option of last resort; reserved exclusively for values that are of the wrong datatype or syntactically unacceptable (e.g.
zipCode = '__!!@')
I supplement individual apps with more specialized objections as needed, but this set comes up in just about everything.
The topic is almost always the literal variable name as it appears within the code block that threw. To assist with debugging, I think it is very important not to transform the variable name in any way.
This system yields error messages like these:
'missing lastName' 'unknown userId' 'unavailable player_color' 'forbidden emailAddress' 'invalid x'
In the second case, for failed operations, there's usually just one datapoint: the name of the operation (plus the fact that it failed). I use this format:
[operation] failed
As a rule, operation is the routine exactly as invoked:
try { await API.updateUserProfile(newData) } catch( error ) { // can fail if service is down if(error instanceof TypeError) throw new Error('API.updateUserProfile failed') }
This isn't the only way to keep your errors straight, but this set of conventions does make it easy to write new error code without having to think very hard, react intelligently to exceptions, and locate the sources of most errors that can be thrown.
Handling server inconsistencies
A final topic: it's pretty common for a server to be inconsistent about how it structures its payloads, particularly with errors but also with successes.
Very often, two endpoints will encode their errors using slightly different envelopes. Sometimes a single endpoint will use different envelopes for different failure cases. This is not usually deliberate, but it is often a reality.
You should coerce all the different flavors of server complaint into a single interface before any of this madness can leak into the rest of your application, and the shore of the client/server boundary is the best place to immediatley jettison server weirdness. If you let that stuff escape into the rest of your app, not only will it drive you insane, but it will make you brittle by allowing the server to surface errors deep inside your app, far away from the real source: a violated API contract.
A way to support a variety of envelopes is by adding extra code to getErrorMessageFromResponseBody for each of the different envelopes:
function getErrorMessageFromResponseBody( string ) { let errorString = string /* "Format A" { errors: [{ msg: 'MESSAGE' }] } used by most endpoints */ try { /*... */ } catch ( parseOrAccessError ) {} /* "Format B" { error: { message: 'MESSAGE' } } used by legacy TPS endpoint */ try { /*... */ } catch ( parseOrAccessError ) {} /* "Format C" { e: CODE } used by bandwidth-limited vendor X use lookup table to convert CODE to a readable string */ try { /*... */ } catch ( parseOrAccessError ) {} return errorString }
One of the values of having a dedicated APIError class to wrap these things is that the class constructor provides a natural way to gather all this up.
JSON.parse(error.name)so you can get the errors keyJSON.parse(error.name)["errors"]error.namefetched just the name, however,error.messagedid the trick. This line extracts the error array from response bodyJSON.parse(error.message).errorsMany thanks again :-)