froglegs
froglegs

Reputation: 108

How do you design around / integrate error handling when using promises / constructing promise chains?

I'm starting to implement promises more and more, and am uncertain as to how exactly they should be implemented.

For example, consider a signup function which takes an email address, username, and password, and performs the following async operations in sequence:

  1. Check database to make sure email address isn't already registered.
  2. Check database to make sure username isn't already taken.
  3. Hash password.
  4. Store new user credentials in database.

If a problem occurs during any step, progress is halted and a JSON object is sent back to the user to notify them of the problem. Otherwise the user is notified that they successfully signed up.

How should this functionality / error handling be implemented using promises?

Take the email check (which returns a promise) for example. If an error occurs querying the database, the promise should obviously be rejected. How should the actual email check be handled though?

For example, should the promise be rejected if the email address is already registered, and the error object specified in the checkEmail function?

e.g.

function checkEmail(email) {
    return new Promise((resolve, reject) => {
        // check email against database
        // if database error occurs, reject()

        if (email already registered) {
            reject({error: 'email already registered'});
        } else {
            resolve(); // move on to check username
        }
    });
}

Using this style then results in promise chains that look something like this:

checkEmail(email)
    .then(() => checkUsername(username))
    .then(() => hashPassword(password))
    .then(passwordHash => addUser(email, username, passwordHash))
    .then(() => {
        // notify user of successful signup
    })
    .catch(error => {
        // all error objects are passed here
        // can then simply send error object directly to client

        /* if different error handling functionality is needed,
        additional property can be included on error object passed to
        resolve(), and used to differentiate between errors */
    });

Or should the promise be resolved within the checkEmail function / the functionality of handling the checkEmail() result deferred to the next function in the chain?

e.g.

function checkEmail(email) {
    return new Promise((resolve, reject) => {
        // check email against database
        // if database error occurs, reject()

        resolve(result); // process result / potential errors in next step of chain
        });
    }

This then results in something similar to the following:

checkEmail(email)
    .then((result) => {
        if (email already registered) {
            // error handling specified here, instead of in checkEmail()
            return Promise.reject({error: 'email already registered'});
        }
    })
    .then()...
    .then()...
    .then()...
    .catch(error => {/* handle errors */});

This results in longer / more messy promise chains, but seems more flexible.

Anyway, I'm not sure exactly how to word this, so hopefully these examples illustrate what I'm trying to get at. I just started implementing promises, and am uncertain regarding much of this. Are there any problems with any of this? Is one of these methods preferable over the other? Is there a better way to implement these promise chains / handle errors?

Any help / suggestions are much appreciated.

Thanks

Upvotes: 0

Views: 66

Answers (3)

jfriend00
jfriend00

Reputation: 707218

It's really totally up to you what you make a rejection and what you make a return value. The way promises are designed with chaining, you usually want a rejection to mean something that you want the chain to abort. If a condition is likely to want the chain to continue without special handling, then it should not reject because obviously, a rejection will abort the promise chain without special handling in a .catch() handler to keep the chain going.

The fetch() example is an interesting case and, I think, illustrative of a common issue where you don't just have a binary outcome of result or error. In the example of fetch(), you have a clear error where you could not contact the server and that is indeed, a rejection. And, you have the clear result where you contacted the server successfully and got the result. Then, you have the middle case where you contacted the server successfully and got some sort of status other than 2xx (perhaps even a 404). In the case of fetch(), they decided that a rejection was going to be defined only as they couldn't reach the server and all other return values were going to be considered a successful call and would resolve.

While I think the design decisions for fetch() make logical sense for a generic design, there are lots and lots of cases where that isn't actually what you want and I often use a small wrapper around fetch that checks the status and resolves only when the status is 2xx. If you're trying to fetch a specific page, success in that case is only really when you got the page - everything else is just a different type of failure. So, the ideal design is really in the eyes of the caller. There often is no perfect solution.

So, here are my general guidelines:

  1. First, think like the caller of your API thinks. What would the user of my API most want to be a rejection and what should be a resolved value?
  2. Imagine a chain of calls using your API in the middle of the chain. Think about what conditions the caller would most like the chain to continue and make those a resolve. If there's no way the chain can continue without intervention, then it's probably a rejection and the caller can either let the chain abort or they can intervene with a .catch() handler to decide whether the chain should continue or not.
  3. If there are multiple ways the API can finish, then just consider resolving with an object that describes how it finished and let the caller decide how they want the chain to continue in a .then() handler.
  4. If your API is binary, receives a value or not, then just reject when there's no value rather than return null or something like that. A chain expecting a value should probably reject anyway. And, callers can always use .catch() to customize if they want a more unusual behavior.
  5. If the API outright fails (such as fetch() failing to even contact the server), then that's clearly a reject in all cases.

In your specific case, you state this:

If a problem occurs during any step, progress is halted and a JSON object is sent back to the user to notify them of the problem. Otherwise the user is notified that they successfully signed up.

That seems pretty straightforward that any time you want progress halted, you should just reject with a descriptive error object. Then you can just run the chain and if it rejects, you have an error object to feed back to the user. If the chain didn't reject, you had success.

Take the email check (which returns a promise) for example. If an error occurs querying the database, the promise should obviously be rejected. How should the actual email check be handled though?

For example, should the promise be rejected if the email address is already registered, and the error object specified in the checkEmail function?

Per our previous discussion, if the email being already registered is something you want to abort your chain for and feed an error object back to the user, then you probably want to reject if it's already registered. If it's a benign operation that their email is already registered and the chain of operations can continue just fine, then you'd resolve when it already exists. I don't follow your logic completely, but if the user is asking to register a new account, then an email address already existing is probably an error and thus a rejection because that's an account conflict and you can't have another account with the same identifying email.

Upvotes: 1

Bergi
Bergi

Reputation: 664297

It depends. You can both model it with an assertNameAvailable method that throws (or rejects a promise) when the name is already taken or with an isNameAvailable method that returns (or fulfills a promise with) a boolean value. You would subsequently handle the result either with a try/catch block (or promise method) or with an if/else statement. Both approaches are viable.

Upvotes: 1

crazymage
crazymage

Reputation: 128

It depends. There are cases where passing an error by resolving the Promise is useful.

Consider the Fetch API, which resolves regardless of the response status. This is, in this context, useful is because even though the response returned with status 404, we may want to fetch a backup file later in the chain. This would not be possible had the Promise been rejected, unless we wrap it in another Promise, unless we add a catch on the Promise to handle the 404 before proceeding in down the chain.

In this specific case rejecting instead of deferring is more efficient. If the second promise in the chain depends on the success of the first one, rejecting is the most efficient way to skip the rest of the chain. Explicitly doing a check in the next promise is avoidable, because the email must be valid for us to even consider executing the rest of the chain.

It basically boils down to this: If the error is of interest to the next function in the chain resolve it, else reject it.

Upvotes: 0

Related Questions