错误处理

In programming, error handling is a complex topic with many pitfalls, and it can be even more daunting when you’re writing code within the constraints of a framework, as the way you handle errors needs to mesh with the way the framework dispatches errors and vice versa.

This article paints the broad strokes of how errors are handled by the JavaScript framework and Owl, and gives some recommendations on how to interface with these systems in a way that avoids common problems.

Errors in JavaScript

Before we dive into how errors are handled in Odoo as well as how and where to customize error handling behavior, it’s a good idea to make sure we’re on the same page when it comes to what we mean exactly by “error”, as well as some of the peculiarities of error handling in JavaScript.

The Error class

The first thing that may come to mind when we talk about error handling is the built-in Error class, or classes that extend it. In the rest of this article, when we refer to an object that is an instance of this class, we will use the term Error object in italics.

Anything can be thrown

In JavaScript, you can throw any value. It is customary to throw Error objects, but it is possible to throw any other object, and even primitives. While we don’t recommend that you ever throw anything that is not an Error object, the Odoo JavaScript framework needs to be able to deal with these scenarios, which will help you understand some design decisions that we’ve had to make.

When instanciating an Error object, the browser collects information about the current state of the “call stack” (either a proper call stack, or a reconstructed call stack for async functions and promise continuations). This information is called a “stack trace” and is very useful for debugging. The Odoo framework displays this stack trace in error dialogs when available.

When throwing a value that is not an Error object, the browser still collects information about the current call stack, but this information is not available in JavaScript: it is only available in the devtools console if the error is not handled.

Throwing Error objects enables us to show more detailed information, which a user will be able to copy/paste if needed for a bug report, but it also makes error handling more robust as it allows us to filter errors based on their class when handling them. Unfortunately, JavaScript does not have syntactic support for filtering by error class in the catch clause, but you can relatively easily do it yourself:

try {
  doStuff();
} catch (e) {
  if (!(e instanceof MyErrorClass)) {
    throw e; // caught an error we can't handle, rethrow
  }
  // handle MyErrorClass
}

Promise rejections are errors

During the early days of Promise adoption, Promises were often treated as a way to store a disjoint union of a result and an “error”, and it was pretty common to use a Promise rejection as a way to signal a soft failure. While it might seem like a good idea at first glance, browsers and JavaScript runtimes have long started to treat rejected Promises the same way as thrown errors in pretty much every way:

  • throwing in an async function has the same effect as returning a Promise that is rejected with the thrown value as its rejection reason.

  • catch blocks in async functions catch rejected Promises that were awaited in the corresponding try block.

  • runtimes collect stack information about rejected promises.

  • a rejected Promise that is not caught synchronously dispatches an event on the global/window object, and if preventDefault is not called on the event, browsers log an error, and standalone runtimes like node kill the process.

  • the debugger feature “pause on exceptions” pauses when Promises are rejected

For these reasons, the Odoo framework treats rejected Promises in the exact same way as thrown errors. Do not create rejected promises in places where you would not throw an error, and always reject Promises with Error objects as their rejection reason.

error events are not errors

With the exception of error events on the window, error events on other objects such as <media>, <audio> <img>, <script> and <link> elements, or XMLHttpRequest objects are not errors. For the purpose of this article, “error” specifically refers only to thrown values and rejected promises. If you need to handle errors on these elements or want them to be treated as errors, you need to explicitly add an event listener for said event:

const scriptEl = document.createElement("script");
scriptEl.src = "https://example.com/third_party_script.js";
return new Promise((resolve, reject) => {
  scriptEl.addEventListener("error", reject);
  scriptEl.addEventListener("load", resolve);
  document.head.append(scriptEl);
});

Lifecycle of errors within the Odoo JS framework

Thrown errors unwind their call stack to find a catch clause that can handle them. The way an error is handled depends on what code is encountered while unwinding the call stack. While there are a virtually infinite number of places errors could be thrown from, there are only a few possible paths into the JS framework’s error handling code.

Throwing an error at the top-level of a module

When a JS module is loaded, the code at the top level of that module is executed and may throw. While the framework might report these errors with a dialog, module loading is a critical moment for the JavaScript framework and some modules throwing errors might prevent the framework code from booting entirely, so any error reporting at this stage is “best effort”. Errors thrown during module loading should however always at the very least log an error message in the browser console. Because this type of error is critical, there is no way for the application to recover and you should write your code in such a way that it’s impossible for the module to throw during definition. Any error handling and reporting that does happen at this stage is purely with the objective of helping you, the developer, fix the code that threw the error, and we provide no mechanism to customize how these errors are handled.

The error service

When an error is thrown but never caught, the runtime dispatches an event on the global object (window). The type of the event depends on whether the error was thrown synchronously or asynchronously: synchronously thrown errors dispatch an error event, and errors thrown from within an asynchronous context as well as rejected Promises dispatch an unhandledrejection event.

The JS framework contains a service that is dedicated to handling these events: the error service. When receiving one of these events, the error service starts by creating a new Error object that is used to wrap the error that was thrown; this is because any value can be thrown, and Promises can be rejected with any value, including undefined or null, meaning that it’s not guaranteed that it contains any information, or that we can store any information on that value. The wrapping Error object is used to collect some information about the thrown value so that it can be used uniformly in the framework code that needs to display information about errors of any type.

The error service stores a complete stack trace of the thrown error on this wrapper Error object and, when the debug mode is assets, uses the source maps to add information in this stack trace about the source file that contains the function of each stack frame. The position of the function in the bundled assets is kept, as it can be useful is some scenarios. When errors have a cause, this process also unwinds the cause chain to build a complete composite stack trace. While the cause field on Error objects is standard, some major browsers still do not display the full stack trace of error chains. Because of this, we add this information manually. This is particularly useful when errors are thrown within Owl hooks, more on that later.

Once the wrapper error contains all the required information, we start the process of actually handling the error. To do this, the error service successively calls all functions registered in the error_handlers registry, until one of these functions returns a truthy value, which signals that the error has been handled. After this, if preventDefault was not called on the error event, and if the error service was able to add a stack trace on the wrapper error object, the error service calls preventDefault on the error event, and logs the stack trace in the console. This is because, as previously mentioned, some browsers do not display error chains correctly, and the default behaviour of the event is the browser logging the error, so we simply override that behaviour to log a more complete stack trace. If the error service was not able to collect stack trace information about the thrown error, we do not call preventDefault. This can happen when throwing non-error values: strings, undefined or other random objects. In those cases, the browser logs the stack trace itself, as it has that information but does not expose it to the JS code.

The error_handlers registry

The error_handlers registry is the main way to extend the way that the JS framework handles “generic” errors. Generic errors, in this context, means errors that can happen in many places, but that should be handled uniformly. Some examples:

  • UserError: when the user attempts to perform an operation that the python code deems invalid for business reasons, the python code raises a UserError, and the rpc function throws a corresponding error in JavaScript. This has the potential to happen on any rpc anywhere, and we do not want developers to have to handle this kind of error explicitly in all those places, and we want the same behavior to happen everywhere: stop the currently executing code (which is achieved by the throw), and display a dialog that explains to the user what went wrong.

  • AccessError: same reasoning as for user errors: it can happen at any point and should be displayed the same way regardless of where it happens

  • LostConnection: same reasoning again.

Throwing an error in an Owl component

Registering or modifying Owl components is the main way in which you can extend the functionality of the web client. As such, most errors that are thrown are in one way or another thrown from an Owl component. There are a few possible scenarios:

  • Throwing in the component’s setup or during rendering

  • Throwing from within a lifecycle hook

  • Throwing from an event handler

Throwing an error from an event handler or a function or method called directly or indirectly from an event handler means that neither Owl’s code nor the JS framework’s code is in the call stack. If you don’t catch the error, it lands directly in the error service.

When throwing an error in a component’s setup or during rendering, Owl catches the error and goes up the component hierarchy, allowing components that have registered error handlers with the onError hook to attempt to handle the error. If the error is not handled by any of them, Owl destroys the application as it is likely in a corrupted state.

Inside Odoo, there are some places where we do not want the entire application to crash in case of error, and so the framework has a few places where it uses the onError hook. The action service wraps actions and views in a component that handles errors. If a client action or view throws an error during rendering, it attempts to go back to the previous action. The error is dispatched to the error service so that an error dialog can be shown regardless. A similar strategy is used in most places where the framework calls into “user” code: we generally stop displaying the faulty component an show an error dialog.

When throwing an error inside of a hook’s callback function, Owl creates a new Error object that contains stack information about where the hook was registered, and sets its cause as the originally thrown value. This is because the stack trace of the original error contains no information about which component registered this hook and where, it only contains information about what called the hook. Because hooks are called by Owl code, most of this information is generally not very useful for developers, but knowing where the hook was registered and by which component is very useful.

When reading errors that mention “OwlError: the following error occurred in <hookName>”, make sure to read both parts of the composite stack trace:

Error: The following error occurred in onMounted: "My error"
  at wrapError
  at onMounted
  at MyComponent.setup
  at new ComponentNode
  at Root.template
  at MountFiber._render
  at MountFiber.render
  at ComponentNode.initiateRender

Caused by: Error: My error
  at ParentComponent.someMethod
  at MountFiber.complete
  at Scheduler.processFiber
  at Scheduler.processTasks

The first highlighted line tells you which component registered the onMounted hook, while the second highlighted line tells you which function threw the error. In this case, a child component is calling a function it received as prop from its parent, and that function is a method of the parent component. Both pieces of information can be useful, as the method could have been called by mistake by the child (or at a point in the lifecycle where it shouldn’t), but it could also be that the parent’s method contains a bug.

Marking errors as handled

In the previous sections, we talked about two ways to register error handlers: one is adding them to the error_handlers registry, the other is using the onError hook in owl. In both cases, the handler has to decide whether to mark the error as handled.

onError

In the case of a handler registered in Owl with onError, the error is considered by Owl as handled unless you rethrow it. Whatever you do in onError, the user interface is likely not synchronized with the state of the application, as the error prevented owl from completing some work. If you are unable to handle the error, you should rethrow it, and let the rest of the code handle it.

If you don’t rethrow the error, you need to change some state so that the application can render again in a non-erroring way. At this point, if you don’t rethrow the error it will not be reported. In some cases this is desirable, but in most cases, what you should do instead is dispatch this error in a separate call stack outside of Owl. The easiest way to do this is to simply create a rejected Promise with the error as its rejection reason:

import { Component, onError } from "@odoo/owl";
class MyComponent extends Component {
  setup() {
    onError((error) => {
      // implementation of this method is left as an exercise for the reader
      this.removeErroringSubcomponent();
      Promise.reject(error); // create a rejected Promise without passing it anywhere
    });
  }
}

This causes the browser to dispatch an unhandledrejection event on the window, which causes the JS framework’s error handling to kick in and deal with the error, in most cases by opening a dialog with information about the error. This is the strategy that is used internally by the action service and dialog service to stop rendering broken actions or dialogs while still reporting the error.

Handler in the error_handlers registry

Handlers that are added to the error_handlers registry can mark an error as being handled in two ways, with different meanings.

The first way is that the handler can return a truthy value, this means that the handler has processed the error and made something happen because the error it received matched the type of error it is able to handle. This generally means it has opened a dialog or notification to warn the user about the error. This prevents the error service from calling the following handlers with higher sequence number.

The other way is to call preventDefault on the error event: this has a different meaning. After deciding that it is able to handle the error, the handler needs to decide if the error it received is something that is allowed to happen during normal operation and if it is, it should call preventDefault. This is generally applicable to business errors such as an access errors or validation errors: users can share links with other users to ressources to which they do not have acces, and users can attempt to save a record that’s in an invalid state.

When not calling preventDefault, the error is treated as unexpected, any such occurrence during a test causes the test to fail, as it’s generally indicative of defective code.

Avoid throwing errors as much as possible

Errors introduce complexity in many ways, here are some reasons why you should avoid throwing them.

Errors are expensive

Because errors need to unwind the callstack and collect information as they do so, throwing errors is slow. Additionally, JavaScript runtimes are generally optimized with the assumption that exceptions are rare, and as such generally compiles the code with the assumption that it doesn’t throw, and fall back to a slower code path if it ever does.

Throwing errors makes debugging harder

JavaScript debuggers, like the one included in the Chrome and Firefox devtools for example, have a feature that allows you to pause the execution when an exception is thrown. You can also choose whether to pause only on caught exceptions, or on both caught and uncaught exceptions.

When you throw an error inside of code that is called by Owl or by the JavaScript framework (e.g. in a field, view, action, component, …), because they manage resources, they need to catch errors and inspect them to decide whether the error is critical and the application should crash, or if the error is expected and should be handled in a particular manner.

Because of this, almost all errors that are thrown within JavaScript code are caught at some point, and although they may be rethrown if they cannot be handled, this means that using the “pause on uncaught exceptions” feature is effectively useless while working within Odoo, as it always pauses within the JavaScript framework code, instead of near the code that threw the error originally.

However, the “pause on caught exceptions” feature is still very useful, as it pauses execution on every throw statement and rejected promise. This allows the developer to stop and inspect the execution context whenever an exceptional situation occurs.

However, this is only true assuming that exceptions are rarely thrown. If exceptions are thrown routinely, any action within the page can cause the debugger to stop the execution, and the developer might need to step through many “routine” exceptions before they can get to the actual exceptional scenario they are interested in. In some situations, because clicking the play button in the debugger removes focus from the page, it may even make the interesting throw scenario inaccessible without using the keyboard shortcut for resuming execution which results in poor developer experience.

Throwing breaks the normal flow of the code

When throwing an error, code that looks like it should always execute may be skipped, this can cause many subtle bugs and memory leaks. Here is a simple example:

eventTarget.addEventListener("event", handler);
someFunction();
eventTarget.removeEventListener("event", handler);

In this block of code, we add an event listener to an event target, then call a function which may dispatch events on that target. After the function call, we remove the event listener.

If someFunction throws, the event listener will never be removed. This means that the memory associated with this event listener is effectively leaked and will never be freed unless the eventTarget itself gets deallocated.

On top of the memory being leaked, the handler still being attached means that it may be called for events being dispatched for reasons other than the call to someFunction. This is a bug.

To account for this, one would need to wrap the call in a try block, and the cleanup in a finally block:

eventTarget.addEventListener("event", handler);
try {
  someFunction();
} finally {
  eventTarget.removeEventListener("event", handler);
}

While this now avoids the problems mentioned above, not only does this require more code, it also requires knowledge that the function may throw. It would be unmanageable to wrap all code that may throw in a try/finally block.

Catching errors

Sometimes, you need to call into code that is known to throw errors and you want to handle some of these errors. There are two important things to keep in mind:

  • Rethrow errors that are not the type of error you expect. This should generally be done with and instanceof check

  • Keep the try block as small as possible. This avoid catching errors that are not the one you’re trying to catch. Generally, the try block should contain exactly one statement.

let someVal;
try {
  someVal = someFunction();
  // do not start working with someVal here.
} catch (e) {
  if (!(e instanceof MyError)) {
    throw e;
  }
  someVal = null;
}
// start working with someVal here

While this is straightforward with try/catch, it’s much easier to accidentally wrap a much larger portion of code in a catch clause when working with Promise.catch:

someFunction().then((someVal) => {
  // work with someVal
}).catch((e) => {
  if (!(e instanceof MyError)) {
    throw e;
  }
  return null;
});

In this example, the catch block is actually catching errors in the entire then block, which is not what we want. In this particular example, because we properly filter based on the error type, we’re not swallowing the error, but you can see that it may be much easier to do so if we’re expecting a single error type and decide not to have the instanceof check. Notice however that unlike the previous example, the null isn’t going through the codepath that uses someVal. To avoid this, catch clauses should generally be as close as possible to the promise that may throw, and should always filter on the error type.

Error free control flow

For the reasons outlined above, you should avoid throwing errors for doing routine things, and in particular, for control flow. If a function is expected to be unable to complete its work on a regular basis, it should communicate that failure without throwing an exception. Consider the example code:

let someVal;
try {
  someVal = someFunction();
} catch (e) {
  if (!(e instanceof MyError)) {
    throw e;
  }
  someVal = null;
}

There are many things that are problematic with this code. First, because we want the variable someVal to be accessible after the try/catch block, it needs to be declared before that block, and it cannot be const since it needs to be assigned after initialization. This hurts readability further down the road as you now have to look out for this variable potentially being reassigned later in the code.

Second, when we catch the error, we have to check that the error is actually the type of error we were expecting to catch, and if not, rethrow the error. If we don’t do this, we might end up swallowing errors that were actually unexpected instead of reporting them correctly, e.g. we could be catching and swallowing a TypeError if the underlying code tries to access a property on null or undefined.

Lastly, not only is this very verbose, but it’s easy to do this incorrectly: if you forget to add the try/catch, you are likely to end up with a traceback. If you add the try/catch block but forget to rethrow unexpected errors, you are swallowing unrelated errors. And if you want to avoid having to reassign the variable you may move the entire block that uses the variable inside the try block. The more code you have inside your try block, the more likely you are to catch unrelated errors, and swallow them if you forgot to filter by error type. It also adds an indentation level to the entire block, and you may even end up with nested try/catch blocks. Lastly, it makes it harder to identify which line is actually expected to throw the error.

The following sections outline some alternative approaches you can use instead of using errors.

Return null or undefined

If the function returns a primitive or an object, you can generally use null or undefined to signal that it was unable to do its intended job. This suffices in most cases. The code ends up looking something like this:

const someVal = someFunction();
// further
if (someVal !== null) { /* do something */ }

As you can see, this is much simpler.

Return an object or array

In some cases, a value of null or undefined is part of the expected return values. In those cases, you can instead return a wrapper object or a two-element array that contains either the return value or the error:

const { val: someVal, err } = someFunction();
if (err) {
  return;
}
// do something with someVal as it is known to be valid

Or with an array:

const [err, someVal] = someFunction();
if (err) {
  return;
}
// do something with someVal as it is known to be valid

注解

When using a two-element array, it is advisable to have the error be the first element, so that it is harder to ignore by mistake when destructuring. One would need to explicitly add a placeholder or comma to skip the error, whereas if the error is the second element, it is easy to simply destructure only the first element and mistakenly forget to handle the error.

When to throw errors

The previous sections give many good reasons to avoid throwing errors, so what are some examples of cases where throwing an error is the best course of action?

  • Generic errors that can happen in many places but should be treated the same everywhere; e.g., access errors can happen on basically any RPC, and we always want to display information about why the user doesn’t have access.

  • Some precondition that should always be fulfilled for some operation is not fulfilled; e.g., a view couldn’t be rendered because the domain is invalid. These types of error are generally not intended to be caught anywhere and signal that code is incorrect or data is corrupted. Throwing forces the framework to bail out and prevents operating in a broken state.

  • When traversing some deep data structure recursively, throwing an error can be more ergonomic and less error prone than having to manually test for errors and forward them through many levels of calls. This should be very rare in practice, and needs to be weighed against all the disadvantages mentioned in this article.