背景
先週、本番環境のバックグラウンドワーカーが予期せず停止しました。ログにはあまり詳細が残っておらず、ただ一行、Error [ERR_UNHANDLED_ERROR]: Unhandled error. という厄介なメッセージがあるだけでした。
Node.js において、'error' イベントは特殊なケースです。オブジェクトが EventEmitter を継承している場合(net sockets、streams、あるいはカスタムクラスなど)、誰かがそのイベントをリッスンしていることが期待されます。エラーを emit した際にリスナーが登録されていないと、Node.js はデフォルトの動作を実行します。例外をスローし、スタックトレースを出力して、終了コード 1 でプロセスを終了させます。
このフェイルファスト(fail-fast)の仕組みにより、エラーが黙って無視されるのを防ぐことができます。しかし、たった一つの未ハンドルのエッジケースによって API やワーカーサービス全体がダウンしてしまうのは、悪夢のような状況です。
デバッグプロセス
これを再現するのは簡単です。.on('error') ハンドラーのない emitter でエラーを発生させるだけです。以下は、ターミナルを即座にクラッシュさせる5行のスクリプトです:
const EventEmitter = require('events');
const myEmitter = new EventEmitter();
// リスナーが存在しないため、クラッシュを引き起こします
myEmitter.emit('error', new Error('Database connection failed!'));
大規模なコードベースでこの問題を探す際、私は特に以下の3つの領域に注目します:
- カスタム Emitters: 非同期タスク中にエラーを emit する
EventEmitterを継承したすべてのクラス。 - File Streams: ソース側で失敗するパイプチェーン(例:
fs.createReadStreamが存在しない.envファイルを開こうとする場合)。 - Socket Connections: タイムアウトやリセットが発生したが、キャッチオールなエラーハンドラーがないネットワークリクエスト。
スタックトレースが浅すぎて役に立たない場合は、--trace-uncaught フラグを付けてアプリを実行してみてください。emitter が最初にインスタンス化された場所について、より詳細な情報を得ることができます。
ERR_UNHANDLED_ERROR を修正するための解決策
1. 明示的な 'error' リスナーを追加する
最初の防御策は、すべての emitter にリスナーがあることを確認することです。単純なロガーであっても、プロセスが終了するのを防ぐことができます。これにより、根本原因を調査している間もアプリケーションを稼働させ続けることができます。
const EventEmitter = require('events');
const myEmitter = new EventEmitter();
// クラッシュを防ぐために error イベントを処理する
myEmitter.on('error', (err) => {
console.error('Caught the error properly:', err.message);
});
myEmitter.emit('error', new Error('Something went wrong'));
console.log('The process is still running!');
2. ストリームでのエラー処理
ストリームはこのエラーが発生しやすいことで有名です。多くの開発者は .pipe() がエラーを下流に転送すると考えていますが、実際には転送されません。ソースが失敗すると、プロセス全体が停止します。Node.js 10.0.0 以降、最適な解決策は stream.pipeline を使用することです。
const fs = require('fs');
const { pipeline } = require('stream');
// pipeline はクリーンアップを処理し、エラーハンドリングを一元化します
pipeline(
fs.createReadStream('missing-file.txt'),
fs.createWriteStream('output.txt'),
(err) => {
if (err) {
console.error('Pipeline failed gracefully:', err.message);
}
}
);
3. 'captureRejections' オプションの使用
Node.js 12.6.0 では、Promise とイベントの溝を埋める方法が導入されました。captureRejections: true を設定することで、未ハンドルの rejection による emitter のクラッシュを心配することなく、async 関数をリスナーとして使用できます。
const EventEmitter = require('events');
const myEmitter = new EventEmitter({ captureRejections: true });
myEmitter.on('event', async (value) => {
throw new Error('Async failure');
});
// rejection は自動的に 'error' イベントに変換されます
myEmitter.on('error', (err) => {
console.log('Captured async error:', err.message);
});
myEmitter.emit('event');
4. try-catch と events.once の併用
コールバックよりも async/await 構文を好む場合は、events.once を使用します。このユーティリティはイベントを Promise でラップします。ただし、待機中に emitter が 'error' を emit した場合、Promise は reject されることに注意してください。
const { once, EventEmitter } = require('events');
async function run() {
const ee = new EventEmitter();
try {
const promise = once(ee, 'finish');
ee.emit('error', new Error('Instant fail'));
await promise;
} catch (err) {
console.error('Caught via try-catch:', err.message);
}
}
run();
検証手順
修正を確認するために、私は以下の3ステップの検証プロセスを行っています:
- 失敗をシミュレートする: 不正なファイルパスを指定したり、リッスンしていない 127.0.0.1 のアドレスを指定したりして、手動でエラーを発生させます。
- 終了コードを確認する: ターミナルでスクリプトを実行し、直後に
echo $?を実行します。0であれば成功、1であれば依然としてクラッシュしていることを意味します。 - ログを検査する: ロガー(Pino や Winston など)が、単なる一般的なエラーメッセージではなく、完全なスタックトレースをキャプチャしていることを確認します。
学んだ教訓
- リスナーの規律:
EventEmitterを使用してクラスを構築する場合は、ユーザーが'error'イベントを処理する必要があることをドキュメントに明記してください。 - pipeline を優先する: 本番環境のほぼすべてのストリーム操作において、
.pipe()ではなくstream.pipelineを使用してください。その方が安全でクリーンです。 - ログの一元化: 常に完全なエラーオブジェクトをログに記録してください。本番環境でスタックトレースが失われると、デバッグの難易度が10倍上がります。
- グローバルハンドラーを避ける:
process.on('uncaughtException')はその場しのぎの対策に過ぎません。アプリが破損した状態のままになる可能性があります。代わりに、特定の emitter を修正してください。

