DEV Community

Lukasz Ostrowski for Saleor Commerce

Posted on

Error modelling

Intro

Let’s have a conceptual look at error modeling. I will use Node.js ecosystem and TypeScript in these examples (and some pseudo-code). I find built-in error management in the JS ecosystem rather poor compared to some other languages, which makes it even more important to treat this topic seriously in this tech stack

I focus on the error modeling, but I don’t focus on the data flow. In another article, I will write more about managing errors Rust-way (which has the built-in distinction between recoverable and non-recoverable errors)

Role of errors

Let’s think about all of these:

  • SyntaxError: JSON.parse: unexpected character
  • TypeError: Cannot read property 'value' of null
  • ValidationError: Email already exists
  • Internal Server Error

Each of them is an error with a different origin and reason.

First, JSON.parse will happen when we try to parse a string that is not valid JSON. It can occur when we directly catch the external API response body and without checking the response type, we parse e.g. error page which is HTML

Another TypeError can be a pure static code issue - we can try to access a property of nullish value, for example, accessing an object property before it has been created.

Common things for these two is that they are mainly useful for the developer. Best if they are caught during compilation or static analysis if possible, then we can protect ourselves by writing proper tests. Once we reach the runtime, we must ensure we will be able to recognize them when the application crashes - in logs or error-tracking platforms like Sentry. These errors are also often non-recoverable - the app probably can’t find another way to work if the code can’t execute anymore.

Once we reach ValidationError we change the abstraction level. First of all, it’s not language that will throw such errors, but either our database or our internal data layer that is trying e.g. to insert a user into the database. Validation is a graceful way to recover from the issue, giving clear feedback without crashing the app. Such an error is also different from the previous two: it’s rather not interesting to track it in the error tracker (it’s not something we can fix) and this error should be returned in the response, so the user/frontend can handle it.

Internal Server Error on the other hand is an error on the HTTP layer. It’s represented by the semantic code (500) but also provides information that it’s a crash on the server side. Something we definitely should catch and fix. We definitely need as many details as possible in our internal tracking systems, but also do not expose any detail to the front end to avoid leaking any implementation details.

You can see now, that errors are not equal to errors - depending on the context they differ. And for that reason, it requires us to model errors carefully.

Abstraction is the problem

Conceptually errors can propagate through the stack trace:

FunctionA()
  FunctionB()
    FunctionC()
        throw new Error()
Enter fullscreen mode Exit fullscreen mode

The stack trace follows the function execution. Then, the opposite when we are catching it:

try {
  FunctionA()
    try {
      FunctionB()
        try{
          FunctionC()
            throw new Error()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

The inner error will be traveling through the execution until some catch block intercepts it (or the program finishes with the unhandled exception).

Now when we think about it in scale - a program running hundreds of functions to process the request, we travel through the abstraction layers.

Errors that arise in the controllers can be often caused by validation logic, partially represented business rules (minimal password length), partially expected data format (JSON), etc.

Errors that are caught in the model are likely related strictly to the domain, for example, we can’t add to a cart product that doesn’t exist anymore.

Sometimes, errors happen in external services. Can be our database downtime or external API not responding.

And in every other place, we can face dozens of programming errors, caused by wrong implementation on the language level.

Depending on the abstraction we will need a different handling

Errors chaining

In Python, there is a concept of raise ErrorA from ErrorB. Its purpose is to indicate one error is caused by another, especially useful when we transform errors.

In the JS stack, we can use Error.prototype.cause for that.

// not real API
try {
  stripe.pay()
}catch(stripeError) {
  if(stripeError instanceOf Stripe.InvalidCardDetails) {
    throw new PaymentFailedError("Payment failed to due invalid payment details", {
      cause: stripeError
    })
  }
}
Enter fullscreen mode Exit fullscreen mode

This is a powerful pattern.

  • First, we have locally intercepted errors from the 3rd party API. It gives us control, at this point, we can log, track, set metrics, emit events, or anything else.
  • Second, we can match the error type. External APIs will provide us a unified errors layer (likely HTTP serialized errors if a list of enum codes) - some of them can be recoverable, some not.

An error like “invalid card details” is expected and recoverable - the user must try again.

An error like “invalid Stripe secret key” is not an action for the customer, but definitely should reach the payment operator to fix it, otherwise, the business critical path may be down.

  • Third, we still chain the reasons, allowing us to track not only the stack tree (representing function calls) but also the human-readable messages we wrote in our code.

When we move level up from the payment example above, we will be able to see simplified reasoning of how powerful error matching is:

// controller / app service / use case

...

try {
  paymentProvider.pay(request.body)
}catch(error){
  switch(true) {
    case error instanceOf PaymentFailedError: {
      tracker.trackEvent("invalid_payment")

      // In real life respond with a json-like structure with a payment refusal reason
      return new Response("Invalid payment details", {status: 400})
    }
    case error instanceOf GatewayAuthError: {
      captureException(new Error("Payment Gateway auth rejected", {cause: error}), {level: "FATAL"})

      return new Response("Error processing payment, please try a different payment method",
      {status: 500})
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

In real life, it will be much broader, due to all handled error cases - but conceptually, we can achieve the same thing: a strong and clear distinction between what class of error we are dealing with, who should receive it, what data we provide and how do we monitor these.

Passing cause gives us additional context we can log (e.g. into Sentry), but we don’t have to return it to the storefront. Or maybe we want to, but only for test environments (we do that in our Stripe App in Saleor)

Error matching (that can be implemented with extending Errors or by enum-like reasons) allows us to route error handling and react properly depending on the issue.

Summary

  • Try to think about what types of errors your application can produce
  • Model what data you need to attach to your errors (both internal and external systems)
  • Leverage the Error.prototype.cause field to chain errors
  • Transform errors when they travel through the abstraction layers

In the upcoming article, I will write more about error implementation and error flow in the application.

Top comments (5)

Collapse
 
abrar_ahmed profile image
Abrar ahmed • Edited

Fantastic post!
Loved the insights. Just to build on it a bit — structured error modeling can also make things smoother for both dev teams and users.
or example, using a consistent error format like this has really helped me:

{
  "error": {
    "type": "ValidationError",
    "message": "Email already exists",
    "code": "EMAIL_EXISTS",
    "details": {
      "field": "email"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

It makes it easier for frontend teams to handle errors cleanly. And adding things like error type, request ID, and source layer in logs can really speed up debugging in production.

Collapse
 
lkostrowski profile image
Lukasz Ostrowski Saleor Commerce

Yea, this part for me is a part of the DTO returned to the frontend.

So I would do something like this:

  1. DB layer responds with it's own error ERROR: duplicate key violates unique constraint
  2. We re-throw it throw new UserExistsError("User exists", {cause: dbError})
  3. In controller we can (depending if we are using some framework or not) - throw new ValidationError("Email already exists", {cause: userExistsError}) or construct response directly (return new Response({error: {...}}))

Important part here is to separate frontend part from the backend part. We don't want to return database response by mistake

Also, I would model frontend error to be errors: [...] instead a single object, because it allows us to return multiple errors (imagine submitting form) - with single frontend parsing (always expect array)

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

pretty cool seeing error handling get treated seriously, i always end up buried in unhelpful stack traces lol - you think better error design actually keeps teams shipping faster or folks just deal with messy errors out of habit

Collapse
 
lkostrowski profile image
Lukasz Ostrowski Saleor Commerce

I think real gain is visible in time:

  1. At the beginning things work, but then there is a traffic. We need to be able to debug quickly
  2. We may need to alter some behavior, having errors explictly handled allows us to inject new logic easily, otherwise things are happening in the background (we don't know where error will be caught)

I think people don't care about it (covering only happy path), then it's a nightmare to maintain, trying to patch random places. The worst is matching errors by their messages, which are often unstable

Collapse
 
nevodavid profile image
Nevo David

Pretty cool walkthrough - I’ve lost hours to mystery errors in JS, so seeing this much structure makes life a lot easier.