Async/Await と Node.js でのエラーハンドリング

1. 総括

async/await は Node.js 内で非同期コードを記述する際に、同期コードのように見える形でコードを書くことができ、コールバックや .then() チェーンよりもはるかに読みやすいです。しかし、基盤にあるメカニズムをしっかり理解していないと、エラーハンドリングを誤って書くことが簡単にでき、UnhandledPromiseRejection によってアプリケーション全体がクラッシュします。

この記事は async/await がどのように動作するか、適切なエラーハンドリングパターン、Promise.all、ループ、Express ミドルウェアとの組み合わせで起こりやすいエラーについて説明します。読み終わった後、あなたは安全な async 関数の書き方と、非同期エラーのデバッグを素早く行う方法を知っているはずです。

2. Async/Await はどのように動作するか

async で宣言された関数は常に Promise を返します。内部で何を return しても同じです。await は呼び出し箇所で「一時停止」し、イベントループに制御を譲り、スレッドをブロックしません。

async function getUser(id) {
  const response = await fetch(`https://api.example.com/users/${id}`);
  const user = await response.json();
  return user;
}

getUser(1).then(user => console.log(user.name));

行 await fetch(...) は fetch からの Promise が resolve するまで待機してから、次へ進みます。もし fetch が reject(例:ネットワーク接続の喪失)されたら、そのエラーは await のある箇所で throw されます — もしキャッチしなければ、getUser が返す Promise も reject されます。

注意: async function は常に Promise を返します。内部で何も await しなくても同じです。呼び出し時に await や .then() を付け忘れるのが、「コードが期待した順序で実行されない」バグの最も一般的な原因です。

3. try/catch でエラーをキャッチする

async 関数内でエラーを処理する最も標準的な方法は、await の箇所を try/catch で囲むことです。

async function getOrder(orderId) {
  try {
    const order = await orderRepository.findById(orderId);
    if (!order) {
      throw new Error(`Order ${orderId} not found`);
    }
    return order;
  } catch (err) {
    console.error('Failed to get order:', err.message);
    throw err; // re-throw して、呼び出し元も知ることができるように
  }
}
  • try は throw したり reject する可能性のあるすべてのロジックを囲みます。
  • catch (err) は手動の throw と Promise reject の両方を受け取ります。
  • 最後の throw err は、エラーを「呑み込ま」ないようにします — 呼び出し元は依然として reject された Promise を受け取ります。

注意: catch 内でエラーをキャッチして re-throw または有効な値を return しない場合、async 関数は undefined で resolve します。呼び出し元は問題ないと思っていても、データが不足しています — 非常に発見しにくいバグです。

3.1. カスタムエラークラス

エラーをはっきり分類する(ビジネスロジックエラー、システムエラーなど)には、独自のエラークラスを作成すべきです。

class PaymentError extends Error {
  constructor(message, code) {
    super(message);
    this.name = 'PaymentError';
    this.code = code;
  }
}

async function chargeCard(order) {
  const result = await paymentGateway.charge(order.total);
  if (!result.success) {
    throw new PaymentError('Card declined', 'CARD_DECLINED');
  }
  return result;
}

エラーをキャッチするとき、err instanceof PaymentError をチェックしてエラータイプに応じた処理ができます。メッセージの文字列比較よりも確実です。

4. Promise.all と Promise.allSettled でのエラー

Promise.all は複数の Promise を並列実行しますが、1 つでも reject すると全体が即座に reject されます。残りの Promise は「背後で」実行され続けていますが、結果は無視されます。

async function notifyCustomers(customers) {
  const results = await Promise.all(
    customers.map(c => emailService.send(c.email))
  );
  return results;
}

2 番目の顧客の emailService.send が失敗すると、全体は即座に reject されます — 顧客 1、3、4 のメール送信が成功したかどうかは分かりません。

すべての Promise の結果を知りたいが、いくつかが失敗した場合でも、Promise.allSettled を使用してください:

async function notifyCustomersSafely(customers) {
  const results = await Promise.allSettled(
    customers.map(c => emailService.send(c.email))
  );

  const failed = results.filter(r => r.status === 'rejected');
  if (failed.length > 0) {
    console.warn(`${failed.length} email(s) failed to send`);
  }
  return results;
}

Promise.allSettled は決して reject しません。results の各要素は { status: 'fulfilled', value } または { status: 'rejected', reason } の形式です。その後の処理方法は自分で決めます。

[
  { status: 'fulfilled', value: true },
  { status: 'rejected', reason: Error: SMTP timeout },
  { status: 'fulfilled', value: true }
]

5. ループ内で await を使用する場合の一般的なエラー

forEach 内で await を使用するのは非常に一般的なミスです — forEach のコールバックは async を待機しません。

// 間違い: forEach は async を await できない
async function processOrders(orders) {
  orders.forEach(async order => {
    await orderService.process(order);
  });
  console.log('Done'); // 即座に実行される、すべてのオーダー処理が完了するのを待たない
}

forEach は async コールバックを呼び出し、返される Promise を無視します。待機しません。console.log('Done') はいずれのオーダーが処理される前に実行されます — process がエラーをスローしても、そのエラーはどこでもキャッチされず、UnhandledPromiseRejection が発生します。

順序を考慮する必要がある場合は、for...of を使用して修正します:

async function processOrdersSequentially(orders) {
  for (const order of orders) {
    await orderService.process(order);
  }
  console.log('Done');
}

または、並列処理が必要な場合は map を Promise.all で使用します:

async function processOrdersParallel(orders) {
  await Promise.all(orders.map(order => orderService.process(order)));
  console.log('Done');
}

6. Express ミドルウェアでのエラーハンドリング

Express はデフォルトでは、ルートハンドラー内の async 関数からのエラーをキャッチしません — 内部で throw すると、リクエストがハングするか、古いバージョンの Node でプロセスがクラッシュします(Express 4 は自動的に Promise rejection をキャッチしません)。

// リスク: async エラーが Express によって自動的にキャッチされない
app.get('/orders/:id', async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  res.json(order);
});

安全な方法は、エラーを next() に変換するラッパーを書くことです:

function asyncHandler(fn) {
  return (req, res, next) => {
    fn(req, res, next).catch(next);
  };
}

app.get('/orders/:id', asyncHandler(async (req, res) => {
  const order = await orderService.getOrder(req.params.id);
  if (!order) {
    const err = new Error('Order not found');
    err.statusCode = 404;
    throw err;
  }
  res.json(order);
}));

app.use((err, req, res, next) => {
  res.status(err.statusCode || 500).json({ message: err.message });
});

asyncHandler はルートハンドラーをラップし、返される Promise が reject されると、.catch(next) がエラーを Express のエラーハンドリングミドルウェアに渡します。エラーが単に落とされることはありません。

注意: Express 5(このテキスト執筆時点でベータ版)では async ハンドラーから rejection を自動的にキャッチしていますが、現在広く使われている Express 4 では、このラッパーが必要です。

7. タイムアウトと AbortController

見落とされやすいエラー:Promise が決して resolve しない状態で await すると、関数は無限にハングします。AbortController を使用して待機時間を制限します。

async function fetchWithTimeout(url, timeoutMs = 3000) {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), timeoutMs);

  try {
    const response = await fetch(url, { signal: controller.signal });
    return await response.json();
  } catch (err) {
    if (err.name === 'AbortError') {
      throw new Error(`Request to ${url} timed out after ${timeoutMs}ms`);
    }
    throw err;
  } finally {
    clearTimeout(timer);
  }
}

setTimeout は timeoutMs 後に controller.abort() を呼び出し、fetch を AbortError という名前のエラーで reject させます。finally は成功しても失敗してもタイマーをクリアし、メモリリークを防ぎます。

8. Async 関数のテストを書く

Jest を使用して成功とエラーの両方のケースをテストします。

<!-- package.json (依存関係セクション、Maven ではありません) -->
npm install --save-dev jest
// orderService.js
async function getOrder(orderId, repository) {
  const order = await repository.findById(orderId);
  if (!order) {
    throw new Error(`Order ${orderId} not found`);
  }
  return order;
}

module.exports = { getOrder };
// orderService.test.js
const { getOrder } = require('./orderService');

describe('getOrder', () => {
  it('オーダーが見つかった場合、オーダーを返す', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue({ id: 1, total: 100 }) };

    const order = await getOrder(1, mockRepo);

    expect(order).toEqual({ id: 1, total: 100 });
  });

  it('オーダーが見つからない場合、エラーをスロー', async () => {
    const mockRepo = { findById: jest.fn().mockResolvedValue(null) };

    await expect(getOrder(99, mockRepo)).rejects.toThrow('Order 99 not found');
  });
});

mockResolvedValue は与えられた値で resolve する Promise をシミュレートします。rejects.toThrow は Promise が期待されたメッセージで reject されたかどうかをチェックします — これは非同期エラーをテストする標準的な方法で、テスト内で手動で try/catch を書く代わりになります。

PASS  ./orderService.test.js
  getOrder
    ✓ オーダーが見つかった場合、オーダーを返す (3 ms)
    ✓ オーダーが見つからない場合、エラーをスロー (2 ms)

9. 避けるべき一般的なエラー

  • await を忘れる: async 関数を呼び出すが await を忘れるとコードが予期した順序で実行されず、エラーが暗黙的に「呑み込ま」れます。
  • .then() と await を混在させる: 同じ関数内では 1 つのスタイルに統一します。
  • try/catch が広すぎる: 非同期と関係のないロジックまで囲むとエラーの出所が不明になります。
  • catch 内で re-throw しない: 呼び出し元が undefined をエラーの代わりに受け取り、上流でバグを引き起こします。
  • async コールバックで forEach を使用する: 待機されないため、for...of または Promise.all を使用します。

注意: ロジックに I/O や非同期タスクがない場合、関数を async と標記する必要はありません — 不要な Promise 作成のオーバーヘッドが増えるだけです。

10. まとめ

async/await は Node.js 内の非同期コードを読みやすくしてくれますが、エラーハンドリングを正しく行うことが、アプリケーションを安定して実行させるための重要な部分です。await の箇所は常に try/catch で囲み、必要に応じてエラーを re-throw し、並列タスクの結果をすべて知りたいときは Promise.allSettled を使用し、Express ルートハンドラーのラッパーを忘れずに。成功とエラーの両方のケースのテストを書くことで、後で非同期ロジックをリファクタリングするときに自信を持てます。

nodejsasync-awaitjavascripterror-handlingpromise