Skipping promise chain after handling error

mjomble picture mjomble · Jun 22, 2014 · Viewed 7.2k times · Source

Using the https://github.com/kriskowal/q library, I'm wondering if it's possible to do something like this:

// Module A

function moduleA_exportedFunction() {
  return promiseReturningService().then(function(serviceResults) {
    if (serviceResults.areGood) {
      // We can continue with the rest of the promise chain
    }
    else {
      performVerySpecificErrorHandling();
      // We want to skip the rest of the promise chain
    }
  });
}

// Module B

moduleA_exportedFunction()
  .then(moduleB_function)
  .then(moduleB_anotherFunction)
  .fail(function(reason) {
    // Handle the reason in a general way which is ok for module B functions
  })
  .done()
;

Basically if the service results are bad, I'd like to handle the failure in module A, using logic that is specific to the internals of module A, but still skip the remaining module B functions in the promise chain.

The obvious solution for skipping module B functions is to throw an error/reason from module A. However, then I would need to handle that in module B. And ideally I'd like to do it without needing any extra code in module B for that.

Which may very well be impossible :) Or against some design principles of Q.

In which case, what kind of alternatives would you suggest?

I have two approaches in mind, but both have their disadvantages:

  1. Throw a specific error from module A and add specific handling code to module B:

    .fail(function(reason) {
      if (reason is specificError) {
        performVerySpecificErrorHandling();
      }
      else {
        // Handle the reason in a general way which is ok for module B functions
      }
    })
    
  2. Perform the custom error handling in module A, then after handling the error, throw a fake rejection reason. In module B, add a condition to ignore the fake reason:

    .fail(function(reason) {
      if (reason is fakeReason) {
        // Skip handling
      }
      else {
        // Handle the reason in a general way which is ok for module B functions
      }
    })
    

Solution 1 requires adding module A specific code to module B.

Solution 2 solves this, but the whole fake rejection approach seems very hackish.

Can you recommend other solutions?

Answer

Benjamin Gruenbaum picture Benjamin Gruenbaum · Jun 23, 2014

Let's talk about control constructs.

In JavaScript, code flows in two ways when you call a function.

  • It can return a value to the caller, indicating that it completed successfully.
  • It can throw an error to the caller, indicating that an exceptional operation occurred.

It looks something like:

function doSomething(){ // every function ever
  if(somethingBad) throw new Error("Error operating");
  return value; // successful completion.
}

try{
  doSomething();
  console.log("Success");
} catch (e){
  console.log("Boo");
}

Promises model this exact same behavior.

In Promises, code flows in exactly two ways when you call a function in a .then handler:

  • It can return a promise or a value which indicates that it completed successfully.
  • It can throw an error which indicates that an exceptional state occured.

It looks something like:

var doSomething = Promise.method(function(){
  if(somethingBad) throw new Error("Error operating");
  return someEventualValue(); // a direct value works here too
}); // See note, in Q you'd return Q.reject()

Promise.try(function(){ // in Q that's Q().then
  doSomething();
  console.log("Success");
}).catch(function(e){
  console.log("Boo");
});

Promises model flow of control itself

A promise is an abstraction over the notion sequencing operations itself. It describes how control passes from one statement from another. You can consider .then an abstraction over a semicolon.

Let's talk about synchronous code

Let's see how synchronous code would look in your case.

function moduleA_exportedFunction() {
  var serviceResults = someSynchronousFunction();
    if (serviceResults.areGood) {
      // We can continue with the rest of our code
    }
    else {
      performVerySpecificErrorHandling();
      // We want to skip the rest of the chain
    }
}

So, how continuing with the rest of our code is simply returning. This is the same in synchronous code and in asynchronous code with promises. Performing very specific error handling is also ok.

How would we skip the rest of the code in the synchronous version?

doA();
doB();
doC(); // make doD never execute and not throw an exception
doD();

Well, even if not immediately, there is a rather simple way to make doD never execute by causing doC to enter into an infinite loop:

function doC() {
    if (!results.areGood) {
      while(true){} // an infinite loop is the synchronous analogy of not continuing
                    // a promise chain.
    }
}

So, it is possible to never resolve a promise - like the other answer suggests - return a pending promise. However, that is extremely poor flow control since the intent is poorly conveyed to the consumer and it will be likely very hard to debug. Imagine the following API:

moduleA_exportedFunction - this function makes an API request and returns the service as a ServiceData object if the data is available. Otherwise, it enters the program into an endless loop.

A bit confusing, isn't it :)? However, it actually exists in some places. It's not uncommon to find the following in really old APIs.

some_bad_c_api() - this function foos a bar, on failure it terminates the process.

So, what bothers us about terminating the process in that API anyway?

It's all about responsibility.

  • It's the responsibility of the called API to convey whether the API request was successful.
  • It is the responsibility of the caller to decide what to do in each case.

In your case. ModelA is simply breaching the limit of its responsibility, it should not be entitled to make such decisions about the flow of the program. Whoever consumes it should make these decisions.

Throw

The better solution is to throw an error and let the consumer handle it. I'll use Bluebird promises here since they're not only two orders of magnitude faster and have a much more modern API - they also have much much better debugging facilities - in this case - sugar for conditional catches and better stack traces:

moduleA_exportedFunction().then(function(result){
   // this will only be reached if no error occured
   return someOtherApiCall();
}).then(function(result2){
   // this will be called if the above function returned a value that is not a 
   // rejected promise, you can keep processing here
}).catch(ApiError,function(e){
   // an error that is instanceof ApiError will reach here, you can handler all API
   // errors from the above `then`s in here. Subclass errors
}).catch(NetworkError,function(e){
   // here, let's handle network errors and not `ApiError`s, since we want to handle
   // those differently
}).then(function(){
   // here we recovered, code that went into an ApiError or NetworkError (assuming
   // those catch handlers did not throw) will reach this point.
   // Other errors will _still_ not run, we recovered successfully
}).then(function(){
   throw new Error(); // unless we explicitly add a `.catch` with no type or with 
                      // an `Error` type, no code in this chain will run anyway.
});

So in a line - you would do what you would do in synchronous code, as is usually the case with promises.

Note Promise.method is just a convenience function Bluebird has for wrapping functions, I just hate synchronous throwing in promise returning APIs as it creates major breakage.