This note/example shows 3 different ways of handling promise rejections in JavaScript.
Before we start you may want to refresh in your mind that:
.then(...)
takes two arguments: MDN..catch(...)
is a syntactic surgar around.then(...)
with two arguments: MDN.- Exploring JS:
Exceptions that are thrown in the callbacks of then() and catch() are passed on to the next error handler, as rejections
In the contrived example below we want:
- Get JSON response and check if it conforms to our little schema.
- Report errors which must be fixed by a programmer.
- If server responses with a 4xx (client errors) then report it too.
'use strict';
const fetch = require('node-fetch');
// In the real world here may be some function
// that is very complex and error prone!
const isJsonValidDangerous = (json) => json.ok === true;
const is4xx = (res) => 400 <= res.status && res.status < 500;
const reject4xxAsync = async (res) => {
if (is4xx(res)) {
return res.text()
.then(
(text) => {
// Throwing error inside promise callback results in a promise rejection
// with the thrown error as a rejected value.
throw new Error(`${res.status}: ${text}`);
},
);
}
return res;
};
const reportErrorAsync = (err) => {
console.log('Reporting error:', err);
// This function always resolves, even if reporting fails.
// Example of implementation:
// return fetch(...)
// .catch((reportingErr) => {/* Show error to the user in a robust way. */})
// .catch(() => {/* Suppress. */});
return Promise.resolve();
};
/*
==========================================
Way 1: .then(...) with the second argument
==========================================
Con: Doesn't handle all errors, see the comment below.
*/
const doesHaveValidJsonAsync1 = async (url) =>
fetch(url)
.then(reject4xxAsync)
.then((res) => res.json())
.then(
isJsonValidDangerous, // May throw an error, which won't be handled!
(err) => reportErrorAsync(err)
.then(() => false), // After reporting we want to resolve with `false`.
);
/*
==================
Way 2: .catch(...)
==================
Pro: Handles all errors.
*/
const doesHaveValidJsonAsync2 = async (url) =>
fetch(url)
.then(reject4xxAsync)
.then((res) => res.json())
.then(isJsonValidDangerous) // May throw an error, will be handled by the next line.
.catch(
(err) => reportErrorAsync(err)
.then(() => false),
);
/*
==============================================
Way 3.1: try...catch + many awaits - then(...)
==============================================
Con: Imperative code (vs. expressions) may be less composable (practical example needed).
Con: `await` may be not supported on your target browser in contrast to promises, which are older.
Pro: Some people find it easier to read compared to a chain of then/catch.
*/
const doesHaveValidJsonAsync3_1 = async (url) => {
try {
const res = await fetch(url);
const goodRes = await reject4xxAsync(res);
const json = await goodRes.json();
return isJsonValidDangerous(json);
// Or if you like nesting: return isJsonValidDangerous(await goodRes.json());
} catch(err) {
await reportErrorAsync(err);
return false;
}
};
/*
=================================================
Way 3.2: try...catch + mixing await and then(...)
=================================================
Pros & cons: See way 3.1.
Con: Some people advocate against mixing await and then/catch.
*/
const doesHaveValidJsonAsync3_2 = async (url) => {
try {
// At least one `await` is needed for a rejection to be thrown and caught by try...catch.
const ifValid = await fetch(url)
.then(reject4xxAsync)
.then((res) => res.json())
.then(isJsonValidDangerous);
return ifValid;
// A shorter variant: return await fetch(...)...
} catch(err) {
await reportErrorAsync(err);
return false;
}
};
(async () => {
//=======
// Tests
//=======
// For manual testing:
/*
{ // <- namespace opens.
const url = 'https://gist.githubusercontent.com/ilyaigpetrov/96a4491877f4bd14f95cf41134de1990/raw/f00434edd42b7029fec587507ec585e42930c730/zzz-test-ok.json';
const ifValid = await doesHaveValidJsonAsync3_2(url);
console.log(`${url} has`, ifValid ? 'valid json' : 'NO valid json');
}
*/
// Non-manual tests.
const funcs = [
doesHaveValidJsonAsync1,
doesHaveValidJsonAsync2,
doesHaveValidJsonAsync3_1,
doesHaveValidJsonAsync3_2,
];
const runTestAsync = async (testName, url, expected, message) => {
console.log(`Test "${testName}" started.`);
const promises = funcs.map(async (func) => {
const actual = await func(url);
console.assert(
actual === expected,
`${func.name}: ${message}`,
);
});
await Promise.all(promises);
console.log(`Test "${testName}" finished.\n`);
};
console.log(/* Empty line. */);
// Test 1: Valid JSON
const urlOk = 'https://gist.github.com/ilyaigpetrov/96a4491877f4bd14f95cf41134de1990/raw/3bbe333cecd0f17b1189e8ede558b69587d44269/zzz-test-1-ok.json';
await runTestAsync(
'Valid JSON',
urlOk,
true,
'`true` must be returned for a valid JSON structure.',
);
// Test 2: Non Valid JSON
const urlNotOk = 'https://gist.github.com/ilyaigpetrov/96a4491877f4bd14f95cf41134de1990/raw/3bbe333cecd0f17b1189e8ede558b69587d44269/zzz-test-2-not-ok.json';
await runTestAsync(
'Non Valid JSON',
urlNotOk,
false,
'`false` must be returned for a non-valid JSON structure.',
);
// Test 3: 4xx
await runTestAsync(
'4xx',
'https://httpstat.us/403',
false,
'`false` must be returned for 4xx.',
);
})();
To make this example easier some real world issues are not handled:
-
Handle cases when server responses with something that is not JSON, report such cases together with the server response. You can't use
res.json()
and in the case of failureres.text()
on the same response, instead you may:- Use
res.text()
, thentry { JSON.parse(text); } catch(err) { /* Report the text, which wasn't parsed. */ }
. - Use
res.clone()
.
At some point you probably will want to write a
fetch
wrapper for this and other things. - Use
-
I wouldn't embed error reporter into every function, instead I would let functions reject and handle rejections with a reporter on the most external level.
-
Report 5xx (server errors) as well.
-
Network failures result in
fetch
rejections which are reported to the programmer. If this code is executed on a client (not on a server) then network failures should be reported to the user only.
I've asked many people on freenode, gitter and reddit to find out which way they advocate.
I want to thank everyone who participated.
Special thanks for a review to Sergey Zhigalov (@Zhigalov).
I import a reply from @Zhigalov here
Ты молодец что затронул тему обработки исключений. Это очень важно понимать, когда пишешь асинхронный код в продакшне. Давай постараемся рассказать от этом ещё понятнее.
Что мне понравилось
В примере есть два типа ошибок: синхронная, которая получается при парсинге JSON, и асинхронная, которая прилетает с сервера. Это самый правильный подход объяснить проблему. Тут ты очень тонко и верно подобрал пример, круто
Все обработчики ты вынес в функции с говорящими названиями, написанными в одну строчку. Это понятно и читаемо, так мне было легко разобраться с кодом. Это однозначно надо оставить как есть.
Мне очень понравился переход от первого примера ко второму. Он верно выстроен. В этот переход я бы добавил теории. Не все знают, что в
then
можно передать два обработчика. Надо рассказать что это значит и в каком порядке они срабатывают. У Ильи Кантора есть замечательный пример cIdentity
иThrower
, я бы позаимствовал оттуда и теорию и идею для иллюстрации.Что мне НЕ понравилось
Пример 3.1, он режет глаз. Я стараюсь не использовать в одной конструкции промисы и async/await и всячески отговариваю своих коллег это делать. Понимаю, что ты хочешь сделать переход от первому ко второму, но как по мне - не стоит. Я бы убрал.
Библиотека
node-fetch
имеет несколько замудрённый интерфейс. Ты сначала асинхронно ждёшь ответа, а потом вызываешьres.text()
который снова асинхронный. В продакшне использовать - ок, без вопросов. Но в примере надо объяснить максимально просто. Не надо добавлять лишних асинхронностей, чтобы не запутать читателя. Может заменить на got. У неё и звёзд больше, и второй асинхронности нет.Не понял зачем
Может зачейнить на уровень выше или вообще убрать.
Предложения
reject4xxAsync
не оборачивать ошибку вPromise.reject
, а сразу её кинуть при помощиthrow
. Так ты ещё раз на примере покажешь что синхронные ошибки внутри обработчиков промиса становятся асинхронными.My reply to his post
Большое спасибо за подробный отклик на заметку.
Парсинг JSON через
response.json()
работает асинхронно (возвращаетPromise
), а вотisJsonValidDangerous(...)
работает синхронно и может прокинуть ошибку. Верно, что это пример синхронной ошибки в обработчикеPromise
. У Ильи Кантора используется.then(JSON.parse)
, но я не уверен, что ради примера нужно отступать от принятого в языке решения/стиля/идиомы, по крайней мере без предупреждения читателя об этом.Пример хороший, но я сторонник того, что программисту нужно развивать технический английский и читать/писать на нём с самого начала.
Может, MDN и менее подробный источник, но лучшего у меня пока нет. Добавил пару ссылок на MDN во вступительный абзац.
await
применяется как раз таки кPromise
, думаю, ты имеешь ввиду не использоватьthen/catch
иawait
в одной конструкции. Я поменял 3.1 и 3.2 местами, теперь 3.2 — это пример не всеми рекомендуемого стиля с соответствующей пометкой.Я рассчитываю, что заметка может быть полезной не только программистам на Node.js, но и фронтенд-программистам на JavaScript.
Потому я бы использовал API, который поддерживается и браузером и Node.js (cross-fetch, node-fetch).
doesHaveValidJsonAsyncX(...)
возвращаетBoolean
:true
— если по адресу есть валидный JSON,false
— во всех других случаях (даже если возникла неожиданная ошибка и мы о ней доложили или не доложили).В продакшн я бы вынес докладчик на самый внешний уровень и не встраивал бы его таким образом в каждую функцию.
Добавил это как рекомендацию под пунктом 2 (Ways of Making This Code Better):
Исправил на
throw
, для объяснения добавил ссылку на ExploringJS и комментарий.Также
Добавил тесты.
Добавил тебя в раздел благодарностей.