How do I allow consumers of promise catch the errors?


#1

I have a situation where I want to catch error occurs in the promise first and also return that error to the consumers of the promise. The type of error is Js.Promise.error but reject requires an exn. I know I can use the shady %identity convertion here. But I wonder is there a better solution ?

Js.Promise.(
      resolve(
        InternalConfig.apolloClient##mutate({
          "mutation": [@bs] gql(mutation##query),
          "variables": mutation##variables
        })
      )
      |> then_(value => {
           reduce(() => Result(value), ());
           resolve();
         })
      |> catch(error => {
           reduce(() => Error(error), ());
           reject(error);
         })
    );

Alright, here is the solution I came up with.

Js.Promise.(
      resolve(
        InternalConfig.apolloClient##mutate({
          "mutation": [@bs] gql(mutation##query),
          "variables": mutation##variables
        })
      )
      |> then_(value => {
           reduce(() => Ok(value), ());
           resolve(Ok(value));
         })
      |> catch(error => {
           reduce(() => Error(error), ());
           resolve(Error(error));
         })
    );

The consumer will be able to do this:

Js.Promise.(
                           mutate(mutation)
                           |> then_(result => {
                                let _ =
                                  Js.Result.(
                                    switch result {
                                    | Ok(_) => setSubmitting(false)
                                    | Error(err) => Js.log(err)
                                    }
                                  );
                                resolve();
                              })
                         );

Pretty nice :wink:


#2

@ncthbrt replied to me on Discord chat about his solution, I think it’s pretty neat way to solve this.

Here is his code:

  let handleJsPromiseError =
    [@bs.open]
    (
      fun
      | Protocol.Response.OrganizationException(e) => Js.Promise.resolve(Unsuccessful(e))
      | JsPromiseException(e) =>
        Js.Promise.resolve(Unsuccessful(`Exception(JsPromiseException(e))))
    );

Also note that I would use Js.Result.t because it’s the standard way to deal with it in OCaml


#3

I’ve seen this rationale before but so far I remain unconvinced - the promise is a JavaScript construct that is being manipulating with BuckleScript/ReasonML so it makes sense to me to adhere to the conventions of the host construct rather then forcing the sensibilities of the “guest” langauge upon it (“When it Rome, do as Romans do”).

In my view returning an error in a fulfilled promise is at best incongruous, at worst a violation of the semantics of a promise … because the recipient of that promise has to be prepared to handle an error in a then handler, something that really is the responsibility of a catch handler.

Both promises and Js.Result.t allow Railway Oriented Programming but they do so in very different ways. With Js.Result.t the value can simply be extracted with pattern matching because the value is known to exist - due to the asynchronous nature of a promise, the contained value has to be manipulated via a handler. A rule of thumb for the promise/handler chains seems to be:

  • a then handler processes a value in a fulfilled promise - typically assumed to be a successful result
  • a catch handler processes a value in a rejected promise - typically assumed to be a failure result

regardless of whether that promise/handler chain is scaffolded and terminated in one single place or composed in a more distributed fashion throughout the code base. So the then handlers compose the “green” track, while the catch handlers compose the “red” track of the “Railway Oriented” promise/handler pipeline.

It’s an implementation detail that ultimately a promise has to be resolved by the time the promise/handler chain terminates in order to avoid an unhandledrejection event.

If it is convenient/necessary to convert a rejected promise to a Js.Result.t then it makes sense for a catch handler to wrap the error result and forward the Error to where it can be adequately processed.

If that catch handler is known to be the final, terminating catch handler of the chain then it makes sense to resolve to an empty value consistent with the type of the promise in order to prevent an unhandledrejection event.

If however the promise itself is returned or passed on then the catch handler should create another rejected promise adhering to the standard promise protocol of indicating the condition of failure.

As far as I can tell Js.Promise.reject does not adhere to the convention of the host construct as it requires an exn rather than a Js.Exn.Error.

MDN: Promise.reject

For debugging purposes and selective error catching, it is useful to make reason an instanceof Error.

So the only way to consistently reject a JavaScript promise at the end of a (non-final) catch handler is to use Js.Exn.raiseError and the like.

For these reasons I feel that fulfilled promises holding error results are a somewhat dubious practice as they seem to run counter to the whole concept of “fulfilled” and “rejected” promises.


#4

The convention of the host construct is to use rejection for exceptions, not errors in general. AIUI, the only reason promise rejection exists is to provide a way to return an exception that’s been thrown in an asynchronous context.

Consider how fetch is implemented. The server returning a 500 error would in most application contexts be considered an error, but it does not result in a promise rejection. Instead you’ll have to check response.ok or response.statusCode. That’s why Js.Promise.reject only accepts an exn.

But since this is so often misunderstood (by myself too, until fairly recently), promise rejection is often used as a general error handling construct. This is why catch gives you an opaque error type. While it should be an exception, it will often not be, and so you should check to make sure before you use it, or preferably just log it and move on.


#5

Thank You for responding.

The server returning a 500 error would in most application contexts be considered an error, but it does not result in a promise rejection.

That’s an interesting example - which according to the issues leads to lots of surprised reactions. The spec seems to suggest that the “promise” is considered “fulfilled” if a response is delivered - the payload of the response doesn’t seem to concern fetch.

So I guess I would have to soften my view somewhat - but I’m still concerned about universal and unthinking adoption of running result style Error values through then handlers.

But since this is so often misunderstood (by myself too, until fairly recently), promise rejection is often used as a general error handling construct.

Would you happen to remember the source(s) that brought you around to this position?

I can accept that I would have to check the status of a fulfilled response but I have to admit I’d be sorely tempted to return a rejected promise to signal that I’m off the happy path.

For the time being a fulfilled promise seems analogous to Ok x just like a rejected promise seems analogous Error e. If that is a genuine misconception, I’d like to put it to rest.


#6

Oh yeah, it’s definitely a surprise to most people. But I think the reason it’s used for general error handling is that monadic error handling is very natural once you’ve tried it, and promises are how most JS developers are introduced to that style of error handling. It’s also the only built-in error-handling monad, and if not used for general error handling its error-handling capability would barely be used at all, since exceptions are so exceptional. So why not make real use of it! (/s)

Ideally I think it’s better to separate the exceptionality of exceptions from errors you’re expected to handle, and then have the consumer decide whether they should be merged. Being forced to deal with exception handling in normal control flow can in extreme cases become like having to explicitly deal with division by zero errors in arithemetics. It’s not nice.

Exceptions and promise rejection also bridge the async and sync pradigms better, since exceptions are automatically converted to promise rejection (and if promise rejection could be guaranteed to be an exception it could be rethrown easily when going from async to sync). There’s no such bridge between resolving a result monad and promise rejection, so if you’re already dealing with a synchronous flow with monadic error handling, it’s much easier to just keep using the result monad when introducing asynchrony.

I do, but it’s probably not easy to find. It was during a discussion on Discord almost a year ago with Hongbo, the author of BuckleScript, on the design of Js.Promise, in particular on the abstract error type given to the catch handler. We went through the fetch design (which I said made no sense to me :laughing:) and some examples of common usage in the wild, where we found a lot of misuse but figured 80% or so was just log and forget.


#7

Ok, here is some guidance, though it’s far from definitive - Writing Promise-Using Specifications - 4.1.2. Rejection Reasons Should Be Errors:

Good cases for rejections include:

  • When it will be impossible to complete the requested task

but then again

Bad uses of rejections include:

  • When a value is asked for asynchronously and is not found

Which probably explains the diverse “philosophical differences” on when it is appropriate to reject a promise.

Ideally I think it’s better to separate the exceptionality of exceptions from more errors you’re expected to handle

Which is the accepted standard for try..catch - especially in environments where exception handling is considered costly.

It was during a discussion on Discord almost a year ago with Hongbo, the author of BuckleScript, on the design of Js.Promise, in particular on the abstract error type given to the catch handler.

The perspective is clear from the design of Js.Promise - but that perspective seems to predicate that rejected promises are primarily a manifestation of exceptions that occurred during an asynchronous operation.

  • If that were true, Promise.reject would be redundant because exceptions are typically thrown and throwing an exception should be the only necessary means to reject a promise.
  • If one however accepts that Promise.reject is the primary means for rejecting a promise things open up a bit. Now there is the situation where exceptions need to go somewhere and making them also reject the promise is the next logical step - but at this point throwing exceptions is an ancillary means for rejecting promises.

I’m not saying that Hongbo is wrong (really, I’m not) - but there still seems to be plenty of room for interpretation (and confusion).

Thank You again for indulging my curiosity.


#8

Oh, that’s interesting. Good find! I’ve tried to find something like this before, but haven’t been able to. I thought promises were embarassingly underdocumented, but apparently the official guidelines are just largely ignored.

I’m not seeing the gray area you’re seeing though. Even the section titles alone seem to make it pretty clear:

4.1.2. Rejection Reasons Should Be Errors
4.1.3. Rejections Should Be Used for Exceptional Situations

Of course there’s a large gray area concerning what should be considered exceptional, but that’s no different than for synchronous exceptions.

Promise.reject is a convenience helper for creating a rejected promise outside the context where an exception would be caught. So while not strictly necessary, I wouldn’t say it’s redundant. It’s just a shorthand for

new Promise(() => throw new Error("oops"));

I assume instead you mean the reject function passed to the callback given to the promise constructor, i.e. new Promise((resolve, reject) => ...);. If so, I think that’s necessary to actually be able to reject a promise asynchronously, since exceptions won’t be able to be caught in a different execution context. Which of course is why we need promise rejection in the first place.

This isn’t actually asynchronous (well it is, by definition, but not in any useful sense):

new Promise(() => {
  throw new Error("oops");
});

If you instead have something like this:

new Promise((resolve, reject) => {
  setTimeout(() => throw new Error("oops"), 0);
});

the exception thrown inside the setTimeout callback won’t be captured by the promise since it’s run in a different context. It willl instead be caught by the run loop. So we actually do need reject to be able to signal rejection.


#9

In my mind (perhaps unjustifiably so) all things being equal, throwing an Error object, necessitating it being caught, seems a far more exceptional measure than simply passing or returning the Error object. Now granted the catch handler unfortunately homogenizes both cases, so it’s not clear whether thrown Errors are demoted to mere Errors or whether mere Errors are elevated to thrown (exceptional) Errors (the wording of the specification seems to suggest the latter).

I assume instead you mean the reject function passed to the callback given to the promise constructor

I wasn’t actually thinking of the reject callback for the executor. It actually only really occurred to me because in BuckleScript/ReasonML each handler is required to explicitly return a resolved or rejected promise. So:

const promise1 = new Promise((resolve, reject) => {
  // resolve('Success!');
  reject(new Error('Uh-oh!'));
});

// non-fluent - just to be awkward
// ... well actually because the various promises
// may belong to different parts of code

promise2 = promise1.catch((error) => {
  console.log("Oh NOES!");
  return Promise.reject(error); // --- THIS reject ---
});

promise3 = promise2.then((value) => {
  console.log(value);
  // Promise {<resolved>: undefined}
});

promise3.catch((error) => {
  console.error(error);
  // Promise {<resolved>: undefined}
});

I was actually talking about returning a rejected promise from a catch handler.

Ultimately my view is coloured by the use of a promise as replacement for a function(err, result) callback - so whatever invokes function(err, result) gets to decide what is exceptional because an err should translate to a rejected promise. But the error isn’t returned or passed around - the promise is. So each time a new promise is created by attaching another handler, the context of what is exceptional changes.


#10

I find it really difficult to keep track of which kind of exception a promise is going to reject, as the type only describes which kind of value a promise is going to resolve upon fulfilled. And sometimes I would forget to handle promise rejections.

Set aside the difference between exceptions and errors, I think it’s safer not to rely on the reject/catch mechanism at all, and resort to the good old result (when possible).