[IMP] developer: add an article to explain error handling in JS

closes odoo/documentation#9240

Signed-off-by: Samuel Degueldre (sad) <sad@odoo.com>
This commit is contained in:
Samuel Degueldre 2024-03-08 07:53:40 +01:00
parent 2d0f640223
commit 489fe7d3ea
2 changed files with 589 additions and 0 deletions

View File

@ -16,6 +16,7 @@ Web framework
frontend/services
frontend/hooks
frontend/patching_code
frontend/error_handling
frontend/javascript_reference
frontend/mobile
frontend/qweb

View File

@ -0,0 +1,588 @@
==============
Error handling
==============
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:
.. code-block:: javascript
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:
.. code-block:: javascript
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.
.. seealso::
`Error handling in the Owl documentation <https://github.com/odoo/owl/blob/master/doc/reference/error_handling.md>`_
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:
.. code-block::
:emphasize-lines: 4,12
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:
.. code-block:: javascript
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:
.. code-block:: javascript
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:
.. code-block:: javascript
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.
.. code-block:: javascript
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`:
.. code-block:: javascript
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:
.. code-block:: javascript
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:
.. code-block:: javascript
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:
.. code-block:: javascript
const { val: someVal, err } = someFunction();
if (err) {
return;
}
// do something with someVal as it is known to be valid
Or with an array:
.. code-block:: javascript
const [err, someVal] = someFunction();
if (err) {
return;
}
// do something with someVal as it is known to be valid
.. note::
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.