Super Good Error Conversion for Promise.reject

I need to get better at extracting error messages for both returning the UI what actually happened, as well as logging so our monitoring can have a better idea of what failed.

Right now, most of the errors from Jzon are a weird shape, but readable in both CloudWatch and ElasticSearch if you just shove 'em to pino/console.log as is using a generic type. However, when the Lambda is complete and has an error (all my Lambdas are just 1 big Promise that returns a JSON object decoded by Jzon, or the raw error in the catch), the error is unreadable. I guess it’s using Js.String.make under the covers because it’s [object Object] which rhymes with “bloody useless”. I’ve seen some of the exception unpacking in the docs and asked about it in the past, but I never seem to know what I’m supposed to be pattern matching on. Like, all my Lambdas have a custom exception up top:

exception MyCustomError(string)

And pattern matching on that works, but Promises are these mysterious things that can expose other errors like Jzon parsing failure, or Result.Error from some operation that was shoved in a Promise.resolve, or some runtime JavaScript exception from my bindings. I don’t need a “super powerful epic pattern matcher for all errors, evarrrr”, but rather, just a function that can be like “gimme an error, here’s the ?.message property”. Basically, ANYTHING is better than [object Object].

… do I have to know the types and put in them in a switch with _ at the bottom and just deal?

Hmm, yeah for that you need to know what kind of exceptions are being thrown in your system and pattern match on that to allow proper message extraction. If those exceptions would be Js.Exn.t (basically plain JS errors) then you could proceed with a pattern like this:

->catch(e => {
  switch e {
  | JsError(obj) =>
    switch Js.Exn.message(obj) {
    | Some(msg) => Js.log("Some JS error msg: " ++ msg)
    | None => Js.log("Must be some non-error value")
    }
  | _ => Js.log("Some unknown error")
  }
  resolve()
  // Outputs: Some JS error msg: Some JS error
})

Not sure if I understand your problem correctly though.

That’s close. What I’m trying to avoid 99% of the time is:

  • None => Js.log("Must be some non-error value")
  • and _ => Js.log("Some unknown error")

The only way I can do that is if I know exactly what comes out, right?

That is correct. You’d need to know that your code throws certain exceptions… but in your case you should know, right?

No, lol. Coming from JavaScript, you just define 1 catch, and no matter how big your Promise grows, how many thens, how many nested promises, you just have error handling in 1 place, log it, and all is well in the world.

ReScript: “What do you mean ‘error’? Please go in all those places and find the types; the compiler will help you ensure you got 'em all”

… that’s not easy work. I wish like “all functions” in my Promise could use the same exception or agree to only use Result.error, that would make this a lot easier. Ok, will attempt to find then, thank you!

In ReScript you can also have one single catch in an arbitrarily complex Promise chain. At some point you’d still need to find out if you’ve thrown a ReScript exn, or if you are handling a JS exception.

In practise you could also just generate and throw JS exceptions and just catch all of them in your single JS-exception-match-case.

On an unrelated note, I don’t understand why the decoding library is throwing exceptions with result values. What’s up with that?

1 Like

I think Jzon is doing normal exceptions, but I have custom code doing Results on my own parsing, and they get mixed.

I like this “catch and map to a normal exception” idea, I’ll try that, thank you!

1 Like

I’m not sure I understood the question correctly, but Jzon never throws an exception. At least it is designed to never throw, otherwise, it is a bug. Instead, to signal an error it returns Result.Error with Jzon.DecodingError.t payload. Such an error indeed has a deeply nested shape for a case you want to recover properly. If all you need is to report the problem as an exception, use Jzon.DecodingError.toString:

fetchLalala
->Promise.thenResolve(myJson => myJson->Jzon.decodeWith(myCodec))
->Promise.then(res =>
  switch res {
  | Ok(record) => record->Promise.resolve
  | Error(err) => err->Jzon.DecodingError.toString->Promise.reject
  })
->Promise.then(yourRecordAsIs => /* ... */)

The middle layer might be wrapped in a nice utility if you use it often.

I’m taking a more detailed approach; I use a different promise library that lets me specify the rejection type.

I then set up my bindings so that every promise-based API specifies a different rejection type.

This does make the code difficult at times; in a chain the same error type must always be returned so a catch is required at every level to satisfy the type system. But with careful use it works around the “top level catch” problem and any resulting errors can be pattern matched quite easily.

It looks a bit like this (not real code, but the structure is how my app looks):

type errorA
type errorB
type errorC

type collectedErrors = | A(errorA) | B(errorB) | C(errorC)

input
->mayRejectErrorA
->catch(error => A(error))
->flatMap(v1 =>
  v1
  ->mayRejectErrorB
  ->catch(error => B(error))
)
->flatMap(v2 =>
  v2
  ->mayRejectErrorC
  ->catch(error => C(error))
)
1 Like

:: Keanu Reeves voice :: “Whoa…”

Ok, so here’s another example where I’m confused. I have this Promise composing another module. In the catch, it looks something like this:

switch error {
    | GetMerchantProductsAndPricingsError(str) => {
        Error(str) -> resolve
    }
    | JsError(obj) =>
        switch Js.Exn.message(obj) {
        | Some(msg) => {
            Error(msg) -> resolve
        }
        | None => {
            Error("Unknown JavaScript error.") -> resolve
        }
        }
    | _ => {
        Error("Unknown OCAML error.") -> resolve
    }
}

This works good in the main module, but because this Promise doesn’t know about the other module, which has this:

reject(GetMerchantProductsError(DecodingError.toString(reason)))

So the main module basically triggers a Error("Unknown OCAML error.") in my logs which isn’t the worst because I can read up on the log-stream. What’s the strategy here? Should I be exporting that exception from the sub-module somehow? I know how to export types, but never have exported exceptions before.

… I feel like I should be using Results instead of Exceptions here.

Not sure exactly what your code looks like but exporting an exception should be fairly easy, just declare it in the interface file. I suspect this will not solve the specific problem though, because it’s probably getting lost in translation between the ReScript exception which does not look like what a standard JavaScript exception does.

You may have better luck rejecting with more standard JavaScript exceptions, e.g. "XYZ happened"->Js.Exn.raiseError->reject.

I don’t use Promise.reject in my application code at all. It’s much more convenient and typesafe to always resolve result. You can also use ROP with the result type to handle all the possible errors in the end of operation