Поиск  
Always will be ready notify the world about expectations as easy as possible: job change page
Feb 26, 2023

Error handling with Async/Await in JS

Error handling with Async/Await in JS
Автор:
Ian Segers
Источник:
Просмотров:
3116

Error handling with Async/Await in JS

This will be a small article, based on some issues I picked up during code reviews and discussions with other developers. This article is rather focused on the novice JS developers.

A Simple Try Catch

Let’s start with the simple try...catch example.

function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

try {
    thisThrows();
} catch (e) {
    console.error(e);
} finally {
    console.log('We do cleanup here');
}

// Output:
// Error: Thrown from thisThrows()
//   ...stacktrace
// We do cleanup here

This works as expected, we call the function thisThrows() which throws a regular error, we catch it, log the error and optionally we run some code in the finally block. No rocket science here.

A Try Catch with a Rejecting Promise

Now we modify thisThrows() so it actually rejects with a promise instead of a regular error. For simplicity, I will make the thisThrows() function async. Remember that an async function always returns a promise:

  • When no return statement defined, or a return statement without a value, it returns a resolving promise, equivalent to return Promise.Resolve() .
  • When a return statement is defined with a value, it will return a resolving promise with the given return value, equivalent to return Promise.Resolve("My return String")
  • When an error is thrown, a rejected promised will be returned with the thrown error, equivalent to return Promise.Reject(error) .
async function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

try {
    thisThrows();
} catch (e) {
    console.error(e);
} finally {
    console.log('We do cleanup here');
}

// output:
// We do cleanup here
// UnhandledPromiseRejectionWarning: Error: Thrown from thisThrows()

Now we have the classic problem, thisThrows returns a rejecting promise, so the regular try...catch is not able to catch the error. As thisThrows() is async , so when we call it, it dispatches a promise, the code does not wait, so the finally block is executed first and then the promise executes, which then rejects. So we don’t have any code that handles this rejected promise.

We can handle this in two ways:

  • We call thisThrows() in an async function and await the thisThrows() function.
  • We chain the thisThrows() function call with a .catch() call.

The first solution would look like this:

async function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

async function run() {
    try {
        await thisThrows();
    } catch (e) {
        console.error(e);
    } finally {
        console.log('We do cleanup here');
    }
}

run();

// Output:
// Error: Thrown from thisThrows()
//   ...stacktrace
// We do cleanup here

And the second one:

async function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

thisThrows()
    .catch(console.error)
    .then(() => console.log('We do cleanup here'));

// Output:
// Error: Thrown from thisThrows()
//   ...stacktrace
// We do cleanup here

Both solutions work fine, but the async/await one is easier to reason about (at least in my personal opinion).

Caveats

We had a look at simple error handling with and without errors. Let’s have a look now at some special cases.

Returning from an async function

Let’s start with a brain teaser, what will happen with the following code?

async function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

async function myFunctionThatCatches() {
    try {
        return thisThrows();
    } catch (e) {
        console.error(e);
    } finally {
        console.log('We do cleanup here');
    }
    return "Nothing found";
}

async function run() {
    const myValue = await myFunctionThatCatches();
    console.log(myValue);
}

run();

At first, we might expect the output to be:

We do cleanup here
Nothing Found

But instead, we got a UnhandledPromiseRejection ! But why? Let’s step through the code:

  • thisThrows() is an async method
  • We throw an error in thisThrows()
  • As thisThrows() is async the thrown error is returned as a rejected promises from the function.
  • We return that rejected promised in myFunctionThatCatches() with the return statement.
  • Our rejected promise reaches the await keyword. The await keyword discovers that the Promise is resolved with the status “rejected” and propagates the error as an unhandled promise rejection.

Based on how our code is structured, our error snuck past our try...catch block and propagated further down in the call tree. Not good!

We solve this by using the await keyword in the return statement.

async function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

async function myFunctionThatCatches() {
    try {
        return await thisThrows(); // <-- Notice we added here the "await" keyword.
    } catch (e) {
        console.error(e);
    } finally {
        console.log('We do cleanup here');
    }
    return "Nothing found";
}

async function run() {
    const myValue = await myFunctionThatCatches();
    console.log(myValue);
}

run();

// Outptut:
// Error: Thrown from thisThrows()
//   ...stacktrace
// We do cleanup here
// Nothing found

Now our try..catch works as expected. Now the await keyword on line 7 will first await the returned promise of thisThrows() and if that promise rejects, the error will be propagated to the catch block. Problem solved!

Resetting your stack trace

How to handle these use cases will be different on a per-case basis, make sure to be aware of this common mistake and then decide if it’s relevant for you or not.

It’s not uncommon to see code where someone catches an error and wraps it in a new error, like in the following snippet:

function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

function myFunctionThatCatches() {
    try {
        return thisThrows();
    } catch (e) {
        throw new TypeError(e.message);
    } finally {
        console.log('We do cleanup here');
    }
}

async function run() {
    try {
        await myFunctionThatCatches();
    } catch (e) {
        console.error(e);
    }
}

run();

// Outputs:
// We do cleanup here
// TypeError: Error: Thrown from thisThrows()
//    at myFunctionThatCatches (/repo/error_stacktrace_1.js:9:15) <-- Error points to our try catch block
//    at run (/repo/error_stacktrace_1.js:17:15)
//    at Object.<anonymous> (/repo/error_stacktrace_1.js:23:1)

Notice that our stack trace only starts where we caught the original exception. When we create an error and catch it, we lose the original stack trace as we now create a new error of type TypeError and only keep the original error message (sometimes we don’t even do that).

Imagine that the thisThrows() function has far more logic in it, and somewhere in that function an error is thrown, we won’t see in our logged stack trace the origin of the problem, as we created a new error which will build a brand new stack trace. If we just re-throw our original error, we won’t have that problem:

function thisThrows() {
    throw new Error("Thrown from thisThrows()");
}

function myFunctionThatCatches() {
    try {
        return thisThrows();
    } catch (e) {
        // Maybe do something else here first.
        throw e;
    } finally {
        console.log('We do cleanup here');
    }
}

async function run() {
    try {
        await myFunctionThatCatches();
    } catch (e) {
        console.error(e);
    }
}

run();

// Outputs:
// We do cleanup here
// Error: Thrown from thisThrows()
//     at thisThrows (/repo/error_stacktrace_2.js:2:11) <-- Notice we now point to the origin of the actual error
//     at myFunctionThatCatches (/repo/error_stacktrace_2.js:7:16)
//     at run (/repo/error_stacktrace_2.js:18:15)
//     at Object.<anonymous> (/repo/error_stacktrace_2.js:24:1)

Notice that the stack trace now points to the origin of the actual error, being on line 2 from our script.

It is important to be aware of this problem when handling errors. Sometimes this might be desirable, but often this obfuscates the origin of your problem, making it hard to debug the root of a problem. If you create your own custom errors for wrapping errors, make sure you keep track of the original stack trace so that debugging doesn’t turn into a nightmare.

Summary

  • We can use try...catch for synchronous code.
  • We can use try...catch (in combination with async functions) and the .catch() approaches to handle errors for asynchronous code.
  • When returning a promise within a try block, make sure to await it if you want the try...catch block to catch the error.
  • Be aware when wrapping errors and rethrowing, that you lose the stack trace with the origin of the error.
Похожее
Mar 23, 2022
Why do we need lifecycle hooks? Modern front-end frameworks move the application from state to state. Data fuels these updates. These technologies interact with the data which in turn transitions the state. With every state change, there are many specific...
24 марта
Автор: Артём Полищук
Когда вам нужно что-то отдебажить на фронтенде, вы юзаете console.log Одной из проблем юзания console.log может быть засорение консоли, что затрудняет чтение консоли. Ниже обсудим 10 способов как улучшить запись в консоль: 1. console.table Вместо того, чтобы просто выводить массив...
Oct 17
Author: walter Torricos
Intro What is the Result pattern? Basically it is a great way to write error-tolerant code that can be composed. Do you feel that phrase sounds familiar? If you like F# you are right, I’ve taken it from the Results...
Feb 26, 2023
Author: Vitalii Shevchuk
Achieve Typescript mastery with a 21-steps guide, that takes you from Padawan to Obi-Wan. Content Intro Best Practice 1: Strict Type Checking Best Practice 2: Type Inference Best Practice 3: Linters Best Practice 4: Interfaces Best Practice 5: Type Aliases...
Написать сообщение
Тип
Почта
Имя
*Сообщение
RSS