Contents
async/await の基本と Promise、try/catch 基礎
この章では async 関数 の書き方、Promise との関係性、そして例外を捕捉するための try / catch パターンを解説します。非同期処理を「同期的に」見せることでコードの可読性が向上し、エラーハンドリングも一貫した形で記述できるようになります。
async 関数の構文
async キーワードは関数宣言・式・クラスメソッドのいずれにも付与できます。
async が付いた関数は必ず Promise を返し、内部で await が出現した瞬間にその Promise の完了(解決または拒否)を待ちます。
|
1 2 3 4 5 6 7 |
// ESM 形式 (module) export async function fetchUser(id) { const response = await fetch(`https://api.example.com/users/${id}`); // `response.json()` が返す Promise が自動的に返却される return response.json(); } |
参考: MDN の async 関数の仕様
Promise との関係
await expr は内部で Promise.resolve(expr) を呼び出し、thenable が解決されるまで処理を中断します。したがって、以下のように書くと同等になりますが、冗長にならない程度に留めておきます。
|
1 2 3 4 5 6 7 8 9 10 |
// async/await 版(簡潔な形) async function getData(url) { return await fetch(url); } // Promise 版(直接返すだけ) function getData(url) { return fetch(url); // fetch が既に Promise を返すためそのままで OK } |
この等価性を理解しておくと、既存の Promise ベースライブラリでも await を自然に組み込めます。
try/catch による例外捕捉
await が失敗すると例外がスローされるため、通常の同期コードと同様に try / catch でハンドリングできます。エラー情報をログに残すだけでなく、必要なら再スローや代替処理へ流すことも可能です。
|
1 2 3 4 5 6 7 8 9 10 11 12 |
async function loadConfig(path) { try { const res = await fetch(path); if (!res.ok) throw new Error(`HTTP ${res.status}`); return await res.json(); } catch (err) { console.error('設定読み込みに失敗:', err); // ログ以外の処理(リトライやデフォルト設定の適用)もここで行える throw err; // 呼び出し側へエラーを伝搬 } } |
参考: Node.js の公式ドキュメント – Promise と async/await
トップレベル await と未捕捉例外ハンドリング(Node.js v20 LTS 対応)
Node.js 14 系からトップレベル await がサポートされ、v20 LTS では ESモジュールのデフォルト挙動として安定化しました。モジュール読み込み時に例外が発生するとプロセス全体が停止するリスクがあるため、未処理例外を安全に捕捉し、必要なら外部モニタリングへ送信する手順を示します。
基本的な使用例
トップレベル await はモジュールの評価段階で即座に実行されます。設定ファイルやデータベース接続など、起動時に必須となる非同期処理に適しています。
|
1 2 3 4 5 6 |
// file: init.mjs import { connectDB } from './db.js'; const db = await connectDB(); // モジュールがインポートされた瞬間に DB 接続が開始 export default db; |
参考: Node.js Docs – Top‑level await
未処理例外の安全なハンドリングと Sentry 初期化例
import('./init.mjs') が失敗した場合は Promise.reject になるため、process.on('unhandledRejection') と併せて Sentry 等のエラートラッキングサービスを初期化しておくと、本番環境での可視性が大幅に向上します。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
// server.mjs (ESM) import * as Sentry from '@sentry/node'; import { LogLevel, createLogger } from 'winston'; // ------------------------------------------------- // 1. ロガーと Sentry の初期化(本番環境だけ有効) // ------------------------------------------------- if (process.env.NODE_ENV === 'production') { Sentry.init({ dsn: process.env.SENTRY_DSN, tracesSampleRate: 0.2, // 必要に応じて調整 }); } const logger = createLogger({ level: 'info', format: require('winston').format.json(), transports: [new (require('winston')).transports.Console()], }); // ------------------------------------------------- // 2. 未処理例外・未処理 Promise 拒否の捕捉 // ------------------------------------------------- process.on('unhandledRejection', (reason, promise) => { logger.error({ message: '未処理の Promise 拒否', reason }); Sentry.captureException(reason); }); process.on('uncaughtException', err => { logger.error({ message: '捕捉されなかった例外', err }); Sentry.captureException(err); // 必要に応じてクリーンアップ後にプロセス終了 process.exit(1); }); // ------------------------------------------------- // 3. トップレベル await を含むモジュールの読み込み // ------------------------------------------------- import('./init.mjs') .then(db => startServer({ db, logger })) .catch(err => { // import 自体が失敗したケース(例: DB 接続エラー) logger.error('初期化フェーズで致命的なエラー', err); Sentry.captureException(err); process.exit(1); }); |
この構成により、トップレベル await が原因でサーバがクラッシュするリスクを低減しつつ、例外情報を即座に監視基盤へ送信できます。
fetch のタイムアウトと AbortController(実務向け実装)
fetch はデフォルトではネットワークエラーやサーバ停止時に無限待ちになる可能性があります。AbortController と setTimeout を組み合わせたラッパーを用意すれば、タイムアウト処理とキャンセルロジックを一元管理できます。
timeout ラッパー関数
以下の実装は ESM 向けに書かれており、タイムアウト時には独自の TimeoutError をスローします。エラーハンドリング側で型判定ができるため、ロジックをシンプルに保てます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// utils.mjs (ESM) export class TimeoutError extends Error { constructor(message) { super(message); this.name = 'TimeoutError'; } } /** * fetch にタイムアウトと自動クリーンアップを付与したラッパー * * @param {string} url - リクエスト先 URL * @param {RequestInit} [options={}] - fetch のオプション * @param {number} [ms=5000] - タイムアウトミリ秒 * @returns {Promise<any>} JSON パース済みレスポンス * @throws {TimeoutError|Error} */ export async function fetchWithTimeout(url, options = {}, ms = 5000) { const controller = new AbortController(); const timerId = setTimeout(() => controller.abort(), ms); try { const response = await fetch(url, { ...options, signal: controller.signal }); clearTimeout(timerId); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.json(); } catch (err) { // AbortError はタイムアウトと同等に扱う if (err.name === 'AbortError') { throw new TimeoutError(`リクエストが ${ms}ms でタイムアウトしました`); } throw err; // その他の例外はそのまま再スロー } finally { // 最終的にコントローラを abort してリソース解放(冪等) controller.abort(); } } |
AbortController のベストプラクティス
- クリーンアップは必ず
finally: タイムアウト以外でも途中でキャンセルされた場合にリソースが残らないようにする。 - 複数リクエストの一括中止: 同じコントローラを共有すれば、たとえばユーザー操作で全タスクを停止できる。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
async function parallelFetch(urls, timeoutMs = 3000) { const controller = new AbortController(); try { const promises = urls.map(u => fetchWithTimeout(u, { signal: controller.signal }, timeoutMs) ); return await Promise.all(promises); } catch (e) { // いずれかが失敗したら全体をキャンセル controller.abort(); throw e; } } |
参考: MDN – AbortController
並列処理のエラーパターン:Promise.all と Promise.allSettled
複数の非同期リクエストを同時に実行する際は、全体成功が必須か、一部成功でも続行できるか に応じて Promise.all もしくは Promise.allSettled を選択します。
全体失敗を前提にした Promise.all
すべてのタスクが成功しなければ次の処理へ進めないケース(例: トランザクション的バッチ処理)で使用します。最初に reject が起きた時点で全体が即座に失敗するため、リソース解放やロールバック処理を早期に行えます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
async function fetchAll(urls) { try { const results = await Promise.all( urls.map(u => fetchWithTimeout(u, {}, 4000)) ); return results; // 全成功の配列が返る } catch (err) { console.error('いずれかのリクエストが失敗:', err); // 必要に応じてリトライや代替フローへ遷移 throw err; } } |
部分成功を許容する Promise.allSettled
全タスクの完了状態を把握したうえで、成功分だけ次工程へ進める 必要があるケース(例: 複数画像アップロード)に適しています。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
async function fetchPartial(urls) { const outcomes = await Promise.allSettled( urls.map(u => fetchWithTimeout(u, {}, 4000)) ); const successes = outcomes .filter(o => o.status === 'fulfilled') .map(o => o.value); const failures = outcomes .filter(o => o.status === 'rejected') .map(o => o.reason); console.info(`成功: ${successes.length}, 失敗: ${failures.length}`); // 成功分だけ次のロジックへ、失敗はレポートや再試行対象に回す return { successes, failures }; } |
参考: Node.js Docs – Promise.allSettled()
カスタム Error、構造化ロギング・モニタリング(Winston + Sentry)
エラー情報を 構造化 して出力すれば、検索性や外部モニタリングツールへの連携が格段に楽になります。以下ではカスタム Error クラスの設計指針と、実際に Winston と Sentry を組み合わせたロギング例を示します。
カスタム Error クラス設計
nameは固定で判別しやすくする- ビジネスロジック固有の
codeと自由に拡張できるmetaフィールドを持つ - スタックトレースは
Error.captureStackTraceで正確に取得
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
// errors.mjs export class AppError extends Error { /** * @param {string} message - エラーメッセージ * @param {string} [code='UNKNOWN'] - ビジネスロジック固有コード * @param {object} [meta={}] - 任意の追加情報 (例: requestId, userId) */ constructor(message, code = 'UNKNOWN', meta = {}) { super(message); this.name = 'AppError'; this.code = code; this.meta = meta; if (Error.captureStackTrace) { Error.captureStackTrace(this, AppError); } } } // 必要に応じてサブクラス例 export class TimeoutError extends AppError { constructor(message, meta) { super(message, 'TIMEOUT', meta); this.name = 'TimeoutError'; } } |
ログと外部モニタリングの実装例
- Winston:JSON フォーマットでコンソール/ファイル双方に出力。
- Sentry:本番環境だけ有効化し、スタックトレースや
metaを自動添付。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
// logger.mjs import winston from 'winston'; import * as Sentry from '@sentry/node'; import { AppError } from './errors.mjs'; const logger = winston.createLogger({ level: process.env.NODE_ENV === 'development' ? 'debug' : 'info', format: winston.format.combine( winston.format.timestamp(), winston.format.json() ), transports: [new winston.transports.Console()], }); /** * エラー情報を Winston と Sentry の両方へ送信 * @param {Error} err */ export function logError(err) { const base = { message: err.message, name: err.name, stack: err.stack, }; // AppError 系なら追加フィールドも出力 if (err instanceof AppError) { Object.assign(base, { code: err.code, meta: err.meta }); } logger.error(base); // Sentry は本番環境でのみ送信(環境変数で制御) if (process.env.NODE_ENV === 'production') { Sentry.captureException(err); } } |
参考: Winston ドキュメント – https://github.com/winstonjs/winston#readme
参考: Sentry Node SDK – https://docs.sentry.io/platforms/node/
ESM と CommonJS の相互運用、テスト戦略(Jest)
プロジェクトがレガシーコードとモダンコードを混在させるケースでは、同一ロジックを両形式でエクスポートできるように設計すると移行コストが下がります。また、非同期エラーハンドリングは Jest のフェイクタイマーで確実にテストできます。
両形式でのエクスポート例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
// getUser.mjs (ESM) import { fetchWithTimeout } from './utils.mjs'; import { AppError } from './errors.mjs'; import { logError } from './logger.mjs'; export async function getUserProfile(id) { try { return await fetchWithTimeout(`https://api.example.com/users/${id}`); } catch (err) { const appErr = new AppError('ユーザ取得失敗', 'USER_FETCH_ERR', { id }); logError(appErr); throw appErr; } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// getUser.cjs (CommonJS) const { fetchWithTimeout } = require('./utils.cjs'); const { AppError } = require('./errors.cjs'); const { logError } = require('./logger.cjs'); async function getUserProfile(id) { try { return await fetchWithTimeout(`https://api.example.com/users/${id}`); } catch (err) { const appErr = new AppError('ユーザ取得失敗', 'USER_FETCH_ERR', { id }); logError(appErr); throw appErr; } } module.exports = { getUserProfile }; |
Jest を使った非同期エラーハンドリングテスト
jest.useFakeTimers()によりsetTimeoutの挙動を制御し、タイムアウトロジックが期待どおりに動くか検証。- 成功・失敗・タイムアウトの 3 パターン を網羅すればカバレッジ要件は満たせます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
// __tests__/fetchWithTimeout.test.mjs import { fetchWithTimeout, TimeoutError } from '../utils.mjs'; global.fetch = jest.fn(); describe('fetchWithTimeout', () => { beforeEach(() => { jest.useFakeTimers(); fetch.mockReset(); }); test('正常に JSON が取得できるケース', async () => { const mockRes = { ok: true, json: async () => ({ success: true }) }; fetch.mockResolvedValue(mockRes); const promise = fetchWithTimeout('https://example.com'); jest.runAllTimers(); // タイマーが即発火 await expect(promise).resolves.toEqual({ success: true }); }); test('HTTP エラーは普通の Error としてスローされる', async () => { const mockRes = { ok: false, status: 404 }; fetch.mockResolvedValue(mockRes); const promise = fetchWithTimeout('https://example.com'); jest.runAllTimers(); await expect(promise).rejects.toThrow(Error); }); test('タイムアウト時に TimeoutError がスローされる', async () => { // fetch が永遠に解決しないケースをシミュレート fetch.mockImplementation(() => new Promise(() => {})); const promise = fetchWithTimeout('https://example.com', {}, 1000); jest.advanceTimersByTime(1000); // タイムアウトまで時間経過 await expect(promise).rejects.toThrow(TimeoutError); }); }); |
参考: Jest ドキュメント – https://jestjs.io/docs/timer-mocks
まとめ
- async/await と try/catch は Promise をシンプルにラップし、例外処理を同期コードと同様の形で書ける点が最大の利点です。
- トップレベル await は便利ですが、Node.js v20 LTS では
process.on('unhandledRejection')/uncaughtException'と併せて Sentry 等のモニタリングを組み込むことが実務上必須です。 - fetch のタイムアウト は
AbortControllerとsetTimeoutを組み合わせ、独自のTimeoutErrorで一元管理すると保守性が高まります。 - 並列処理 では失敗時の要件に応じて
Promise.all(全体失敗)かPromise.allSettled(部分成功)を選択してください。 - カスタム Error クラス と 構造化ロギング (Winston + Sentry) により、障害情報が検索しやすくなり、迅速な復旧が可能です。
- ESM / CommonJS 両対応 と Jest テスト を整備すれば、レガシーコードとの共存と品質保証の両立が実現します。
これらのベストプラクティスをプロジェクトに導入することで、非同期処理全般の信頼性・可観測性が大幅に向上し、運用コストの削減につながります。
参考リンク
| 項目 | URL |
|---|---|
| async 関数(MDN) | https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function |
| Promise と async/await(Node.js Docs) | https://nodejs.org/api/util.html#utilpromisifyoriginal |
| Top‑level await(Node.js v20 LTS) | https://nodejs.org/api/esm.html#top-level-await |
| AbortController(MDN) | https://developer.mozilla.org/ja/docs/Web/API/AbortController |
| Promise.allSettled(Node.js Docs) | https://nodejs.org/api/promise.html#promiseallsettlediterable |
| Winston ロガー | https://github.com/winstonjs/winston#readme |
| Sentry Node SDK | https://docs.sentry.io/platforms/node/ |
| Jest タイマーモック | https://jestjs.io/docs/timer-mocks |
これらの公式資料を併せて読むことで、この記事で扱ったテクニックの背後にある仕様やベストプラクティスをさらに深く理解できます。