Node.jsアプリケーションの信頼性は、エラーハンドリング戦略に大きく左右されます。「動作はするが、エラー時に何が起きるかわからない」コードは、本番環境で予期せぬダウンタイムを引き起こす原因となります。
本記事では、同期・非同期コードにおけるエラーハンドリングの基本から、uncaughtException/unhandledRejectionによるプロセスレベルの例外捕捉、保守性を高めるカスタムエラークラスの設計、構造化されたエラーログ出力、そしてgraceful shutdownの実装まで、アプリケーションの安定性を高める包括的なエラーハンドリング戦略を解説します。
実行環境#
| 項目 |
バージョン |
| Node.js |
20.x LTS以上 |
| npm |
10.x以上 |
| OS |
Windows/macOS/Linux |
前提条件#
- JavaScriptの基礎知識(関数、オブジェクト、async/await)
- Node.jsの基本API理解(HTTPサーバー、ファイル操作)
- Promiseの基本的な仕組みの理解
バージョン確認コマンドは以下のとおりです。
1
2
3
4
5
|
node -v
# v20.18.0
npm -v
# 10.8.2
|
エラーハンドリングの全体像#
Node.jsにおけるエラーハンドリングは、以下の4つのレイヤーで構成されます。
flowchart TD
subgraph Layer4["Layer 4: プロセスレベル"]
P1[uncaughtException]
P2[unhandledRejection]
end
subgraph Layer3["Layer 3: アプリケーションレベル"]
A1[エラーミドルウェア]
A2[グローバルエラーハンドラー]
end
subgraph Layer2["Layer 2: 関数レベル"]
F1[try-catch]
F2[Promise.catch]
end
subgraph Layer1["Layer 1: 入力検証"]
V1[引数チェック]
V2[型チェック]
end
Layer1 --> Layer2
Layer2 --> Layer3
Layer3 --> Layer4
| レイヤー |
責務 |
処理内容 |
| 入力検証 |
事前防止 |
不正な入力を早期検出し、処理を中止 |
| 関数レベル |
局所的処理 |
try-catchやPromise.catchで個別にハンドリング |
| アプリケーションレベル |
集約処理 |
エラーミドルウェアで統一的に処理 |
| プロセスレベル |
最終防衛線 |
捕捉されなかったエラーをログ出力し安全に終了 |
同期コードのエラーハンドリング#
try-catchの基本#
同期処理のエラーはtry-catch文で捕捉します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import { readFileSync } from 'node:fs';
function loadConfigSync(filePath) {
try {
const content = readFileSync(filePath, 'utf8');
return JSON.parse(content);
} catch (error) {
// エラーの種類に応じた処理
if (error.code === 'ENOENT') {
console.error(`設定ファイルが見つかりません: ${filePath}`);
return getDefaultConfig();
}
if (error instanceof SyntaxError) {
console.error(`設定ファイルのJSON形式が不正です: ${error.message}`);
throw new ConfigurationError('Invalid JSON format', { cause: error });
}
// 予期しないエラーは再スロー
throw error;
}
}
function getDefaultConfig() {
return { port: 3000, env: 'development' };
}
|
エラーの種類を判別する#
Node.jsのエラーは複数の方法で判別できます。
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
|
function handleError(error) {
// 1. インスタンスチェック
if (error instanceof TypeError) {
console.error('型エラー:', error.message);
return;
}
if (error instanceof RangeError) {
console.error('範囲エラー:', error.message);
return;
}
// 2. エラーコードによる判別(Node.js固有)
if (error.code === 'ENOENT') {
console.error('ファイルが存在しません');
return;
}
if (error.code === 'EACCES') {
console.error('アクセス権限がありません');
return;
}
if (error.code === 'ECONNREFUSED') {
console.error('接続が拒否されました');
return;
}
// 3. nameプロパティによる判別
if (error.name === 'ValidationError') {
console.error('バリデーションエラー:', error.message);
return;
}
// 4. その他のエラー
console.error('予期しないエラー:', error);
}
|
finallyブロックの活用#
リソースの解放処理はfinallyブロックで確実に実行します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import { openSync, closeSync, readSync } from 'node:fs';
function readBinaryFile(filePath, bufferSize = 1024) {
let fd = null;
try {
fd = openSync(filePath, 'r');
const buffer = Buffer.alloc(bufferSize);
const bytesRead = readSync(fd, buffer, 0, bufferSize, 0);
return buffer.subarray(0, bytesRead);
} catch (error) {
console.error(`ファイル読み込みエラー: ${error.message}`);
throw error;
} finally {
// エラーの有無に関わらずファイルディスクリプタを閉じる
if (fd !== null) {
try {
closeSync(fd);
} catch (closeError) {
console.error(`ファイルクローズエラー: ${closeError.message}`);
}
}
}
}
|
非同期コードのエラーハンドリング#
async/awaitとtry-catch#
非同期関数ではasync/awaitとtry-catchを組み合わせます。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
import { readFile, writeFile } from 'node:fs/promises';
async function processConfigFile(inputPath, outputPath) {
try {
const content = await readFile(inputPath, 'utf8');
const config = JSON.parse(content);
// 設定を加工
config.processedAt = new Date().toISOString();
await writeFile(outputPath, JSON.stringify(config, null, 2));
console.log('設定ファイルの処理が完了しました');
} catch (error) {
if (error.code === 'ENOENT') {
console.error(`ファイルが見つかりません: ${inputPath}`);
} else if (error instanceof SyntaxError) {
console.error('JSON形式が不正です');
} else {
console.error('処理中にエラーが発生しました:', error.message);
}
throw error;
}
}
|
Promise.catchによるエラーハンドリング#
Promiseチェーンでは.catch()メソッドを使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
import { readFile } from 'node:fs/promises';
function loadMultipleConfigs(paths) {
return Promise.all(
paths.map(path =>
readFile(path, 'utf8')
.then(content => ({ path, config: JSON.parse(content) }))
.catch(error => ({ path, error: error.message }))
)
);
}
// 使用例
const results = await loadMultipleConfigs([
'./config/app.json',
'./config/db.json',
'./config/cache.json'
]);
// 成功と失敗を分離
const successful = results.filter(r => r.config);
const failed = results.filter(r => r.error);
console.log(`成功: ${successful.length}, 失敗: ${failed.length}`);
|
Promise.allSettledの活用#
複数の非同期処理で一部が失敗しても全体を継続したい場合はPromise.allSettled()を使用します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
async function fetchMultipleAPIs(urls) {
const results = await Promise.allSettled(
urls.map(async url => {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
);
return results.map((result, index) => {
if (result.status === 'fulfilled') {
return { url: urls[index], data: result.value };
}
return { url: urls[index], error: result.reason.message };
});
}
|
エラーの再スローと変換#
低レベルのエラーを上位レイヤーで意味のあるエラーに変換します。
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
|
import { readFile } from 'node:fs/promises';
class ConfigurationError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = 'ConfigurationError';
}
}
async function loadDatabaseConfig() {
try {
const content = await readFile('./config/database.json', 'utf8');
return JSON.parse(content);
} catch (error) {
// 低レベルエラーを意味のあるエラーに変換
throw new ConfigurationError(
'データベース設定の読み込みに失敗しました',
{ cause: error }
);
}
}
// 呼び出し側
try {
const dbConfig = await loadDatabaseConfig();
} catch (error) {
console.error(error.message);
// 元のエラーも確認可能
if (error.cause) {
console.error('原因:', error.cause.message);
}
}
|
カスタムエラークラスの設計#
基本的なカスタムエラー#
アプリケーション固有のエラークラスを定義することで、エラー処理の一貫性と可読性が向上します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// errors/base-error.js
export class ApplicationError extends Error {
constructor(message, options = {}) {
super(message, options);
this.name = this.constructor.name;
this.timestamp = new Date().toISOString();
// V8スタックトレースの最適化
if (Error.captureStackTrace) {
Error.captureStackTrace(this, this.constructor);
}
}
toJSON() {
return {
name: this.name,
message: this.message,
timestamp: this.timestamp,
stack: this.stack
};
}
}
|
ドメイン固有のエラークラス#
ビジネスロジックに応じたエラークラスを定義します。
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
48
49
50
51
|
// errors/domain-errors.js
import { ApplicationError } from './base-error.js';
export class ValidationError extends ApplicationError {
constructor(message, field, options = {}) {
super(message, options);
this.field = field;
this.statusCode = 400;
}
toJSON() {
return {
...super.toJSON(),
field: this.field,
statusCode: this.statusCode
};
}
}
export class NotFoundError extends ApplicationError {
constructor(resource, id, options = {}) {
super(`${resource}が見つかりません: ${id}`, options);
this.resource = resource;
this.resourceId = id;
this.statusCode = 404;
}
}
export class ConflictError extends ApplicationError {
constructor(message, options = {}) {
super(message, options);
this.statusCode = 409;
}
}
export class DatabaseError extends ApplicationError {
constructor(message, operation, options = {}) {
super(message, options);
this.operation = operation;
this.statusCode = 500;
}
}
export class ExternalServiceError extends ApplicationError {
constructor(serviceName, message, options = {}) {
super(`外部サービスエラー [${serviceName}]: ${message}`, options);
this.serviceName = serviceName;
this.statusCode = 502;
this.retryable = options.retryable ?? true;
}
}
|
エラークラスの活用例#
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
|
import {
ValidationError,
NotFoundError,
DatabaseError
} from './errors/domain-errors.js';
class UserService {
constructor(userRepository) {
this.userRepository = userRepository;
}
async createUser(userData) {
// バリデーション
if (!userData.email) {
throw new ValidationError('メールアドレスは必須です', 'email');
}
if (!this.isValidEmail(userData.email)) {
throw new ValidationError('メールアドレスの形式が不正です', 'email');
}
try {
return await this.userRepository.create(userData);
} catch (error) {
throw new DatabaseError(
'ユーザーの作成に失敗しました',
'INSERT',
{ cause: error }
);
}
}
async getUser(userId) {
const user = await this.userRepository.findById(userId);
if (!user) {
throw new NotFoundError('User', userId);
}
return user;
}
isValidEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
}
|
プロセスレベルのエラーハンドリング#
uncaughtExceptionイベント#
try-catchで捕捉されなかった同期エラーはuncaughtExceptionイベントで検知できます。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
// 注意: このイベントハンドラはログ出力と安全な終了のみを目的とすべき
process.on('uncaughtException', (error, origin) => {
console.error('============================================');
console.error('未捕捉の例外が発生しました');
console.error(`発生源: ${origin}`);
console.error(`エラー: ${error.message}`);
console.error('スタックトレース:', error.stack);
console.error('============================================');
// ログを確実に出力してからプロセスを終了
// 非同期ログの場合は適切に待機する
process.exit(1);
});
|
uncaughtException発生後にプロセスを継続することはNode.js公式ドキュメントで非推奨とされています。このイベントはログ出力とリソースの後始末のみに使用し、その後は必ずプロセスを終了させるべきです。
unhandledRejectionイベント#
.catch()で処理されなかったPromise拒否はunhandledRejectionイベントで検知できます。
1
2
3
4
5
6
7
8
9
10
|
process.on('unhandledRejection', (reason, promise) => {
console.error('============================================');
console.error('未処理のPromise拒否が発生しました');
console.error('理由:', reason);
console.error('============================================');
// Node.js 15以降はデフォルトでプロセスが終了する
// それ以前のバージョンでは明示的に終了させる
process.exit(1);
});
|
warningイベント#
Node.jsは非推奨APIの使用やメモリリークの兆候をwarningイベントで通知します。
1
2
3
4
5
6
7
8
9
10
|
process.on('warning', (warning) => {
console.warn('警告:', warning.name);
console.warn('メッセージ:', warning.message);
// MaxListenersExceededWarningはメモリリークの兆候
if (warning.name === 'MaxListenersExceededWarning') {
console.warn('EventEmitterのリスナー数が上限を超えました');
console.warn('メモリリークの可能性があります');
}
});
|
Node.js 20以降の–unhandled-rejectionsオプション#
Node.jsの起動オプションで未処理のPromise拒否の挙動を制御できます。
1
2
3
4
5
6
7
8
9
10
11
|
# デフォルト: 警告を出力し、プロセスを終了(Node.js 15以降)
node --unhandled-rejections=throw app.js
# 警告のみ出力(プロセスは継続)
node --unhandled-rejections=warn app.js
# 何もしない(非推奨)
node --unhandled-rejections=none app.js
# 厳密モード: プロセスを即座に終了
node --unhandled-rejections=strict app.js
|
構造化エラーログの実装#
基本的なログフォーマッタ#
本番環境では構造化ログ(JSON形式)が推奨されます。
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
48
49
50
51
52
53
54
55
56
|
// logger.js
const LOG_LEVELS = {
error: 0,
warn: 1,
info: 2,
debug: 3
};
const currentLevel = LOG_LEVELS[process.env.LOG_LEVEL || 'info'];
function formatLog(level, message, meta = {}) {
const logEntry = {
timestamp: new Date().toISOString(),
level,
message,
pid: process.pid,
...meta
};
// 本番環境ではJSON形式
if (process.env.NODE_ENV === 'production') {
return JSON.stringify(logEntry);
}
// 開発環境では可読性を優先
const metaStr = Object.keys(meta).length > 0
? ` ${JSON.stringify(meta)}`
: '';
return `[${logEntry.timestamp}] ${level.toUpperCase()}: ${message}${metaStr}`;
}
export const logger = {
error(message, meta = {}) {
if (LOG_LEVELS.error <= currentLevel) {
console.error(formatLog('error', message, meta));
}
},
warn(message, meta = {}) {
if (LOG_LEVELS.warn <= currentLevel) {
console.warn(formatLog('warn', message, meta));
}
},
info(message, meta = {}) {
if (LOG_LEVELS.info <= currentLevel) {
console.info(formatLog('info', message, meta));
}
},
debug(message, meta = {}) {
if (LOG_LEVELS.debug <= currentLevel) {
console.debug(formatLog('debug', message, 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
|
// logger.js に追加
export function logError(error, context = {}) {
const errorInfo = {
name: error.name,
message: error.message,
stack: error.stack,
...context
};
// カスタムエラーの追加プロパティ
if (error.statusCode) {
errorInfo.statusCode = error.statusCode;
}
if (error.code) {
errorInfo.code = error.code;
}
// cause チェーンを辿る
if (error.cause) {
errorInfo.cause = {
name: error.cause.name,
message: error.cause.message
};
}
logger.error(error.message, errorInfo);
}
|
リクエストコンテキスト付きログ#
HTTPリクエストを処理する際は、リクエスト情報をエラーログに含めます。
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
|
import { randomUUID } from 'node:crypto';
function createRequestLogger(req) {
const requestId = req.headers['x-request-id'] || randomUUID();
return {
error(message, meta = {}) {
logger.error(message, {
requestId,
method: req.method,
url: req.url,
userAgent: req.headers['user-agent'],
...meta
});
},
info(message, meta = {}) {
logger.info(message, {
requestId,
method: req.method,
url: req.url,
...meta
});
}
};
}
// 使用例
function handleRequest(req, res) {
const log = createRequestLogger(req);
try {
// リクエスト処理
log.info('リクエストを受信しました');
} catch (error) {
log.error('リクエスト処理中にエラーが発生しました', {
error: error.message
});
}
}
|
HTTPサーバーのエラーハンドリング#
基本的なエラーハンドラー#
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
|
import { createServer } from 'node:http';
import { logger, logError } from './logger.js';
import { ApplicationError } from './errors/base-error.js';
function errorHandler(error, req, res) {
// エラーログ出力
logError(error, {
method: req.method,
url: req.url
});
// レスポンス設定
const statusCode = error.statusCode || 500;
const responseBody = {
error: {
message: error.message,
...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
}
};
// 本番環境では内部エラーの詳細を隠す
if (statusCode === 500 && process.env.NODE_ENV === 'production') {
responseBody.error.message = 'Internal Server Error';
}
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(responseBody));
}
function requestHandler(req, res) {
// 非同期処理のエラーハンドリング
handleRequestAsync(req, res).catch(error => {
errorHandler(error, req, res);
});
}
async function handleRequestAsync(req, res) {
if (req.url === '/api/users' && req.method === 'GET') {
// ビジネスロジック
const users = await fetchUsers();
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(users));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Not Found' } }));
}
const server = createServer(requestHandler);
// サーバーレベルのエラーハンドリング
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
logger.error('ポートが既に使用されています', { port: 3000 });
process.exit(1);
}
logger.error('サーバーエラー', { error: error.message });
});
server.listen(3000, () => {
logger.info('サーバーが起動しました', { port: 3000 });
});
|
タイムアウト処理#
長時間実行されるリクエストに対するタイムアウト処理を実装します。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
|
import { createServer } from 'node:http';
const REQUEST_TIMEOUT = 30000; // 30秒
const server = createServer((req, res) => {
// リクエストごとのタイムアウト設定
req.setTimeout(REQUEST_TIMEOUT, () => {
if (!res.headersSent) {
res.writeHead(408, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: { message: 'Request Timeout' } }));
}
});
handleRequest(req, res);
});
// サーバー全体のタイムアウト設定
server.timeout = REQUEST_TIMEOUT;
server.keepAliveTimeout = 65000; // ロードバランサーより長く設定
server.headersTimeout = 66000;
|
Graceful Shutdownの実装#
基本的な実装パターン#
graceful shutdownは、新しい接続の受付を停止し、既存の処理を完了させてからプロセスを終了する仕組みです。
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
48
49
50
51
52
|
import { createServer } from 'node:http';
import { logger } from './logger.js';
const server = createServer(requestHandler);
const connections = new Set();
// 接続の追跡
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => {
connections.delete(socket);
});
});
function gracefulShutdown(signal) {
logger.info(`${signal}を受信しました。graceful shutdownを開始します`);
// 新しい接続の受付を停止
server.close((error) => {
if (error) {
logger.error('サーバークローズ中にエラーが発生しました', {
error: error.message
});
process.exit(1);
}
logger.info('すべての接続が終了しました。プロセスを終了します');
process.exit(0);
});
// 既存の接続を適切に終了
for (const socket of connections) {
socket.end();
}
// タイムアウト後は強制終了
setTimeout(() => {
logger.warn('タイムアウトにより強制終了します');
for (const socket of connections) {
socket.destroy();
}
process.exit(1);
}, 30000);
}
// シグナルハンドラーの登録
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
server.listen(3000, () => {
logger.info('サーバーが起動しました', { port: 3000 });
});
|
完全なGraceful Shutdown実装#
データベース接続やキャッシュなど、複数のリソースを適切に解放する実装例です。
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
|
// graceful-shutdown.js
import { logger } from './logger.js';
class GracefulShutdown {
constructor() {
this.isShuttingDown = false;
this.cleanupHandlers = [];
this.timeout = 30000;
}
register(name, handler) {
this.cleanupHandlers.push({ name, handler });
return this;
}
setTimeout(ms) {
this.timeout = ms;
return this;
}
async shutdown(signal) {
if (this.isShuttingDown) {
logger.warn('シャットダウンは既に進行中です');
return;
}
this.isShuttingDown = true;
logger.info(`${signal}を受信しました。graceful shutdownを開始します`);
// タイムアウトタイマー
const forceExitTimer = setTimeout(() => {
logger.error('タイムアウトにより強制終了します');
process.exit(1);
}, this.timeout);
// クリーンアップ処理を順次実行
for (const { name, handler } of this.cleanupHandlers) {
try {
logger.info(`${name}のクリーンアップを実行中...`);
await handler();
logger.info(`${name}のクリーンアップが完了しました`);
} catch (error) {
logger.error(`${name}のクリーンアップに失敗しました`, {
error: error.message
});
}
}
clearTimeout(forceExitTimer);
logger.info('すべてのクリーンアップが完了しました');
process.exit(0);
}
listen() {
process.on('SIGTERM', () => this.shutdown('SIGTERM'));
process.on('SIGINT', () => this.shutdown('SIGINT'));
// Windows対応
if (process.platform === 'win32') {
const readline = await import('node:readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
rl.on('SIGINT', () => this.shutdown('SIGINT'));
}
}
}
export const gracefulShutdown = new GracefulShutdown();
|
アプリケーションへの統合#
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
|
// app.js
import { createServer } from 'node:http';
import { gracefulShutdown } from './graceful-shutdown.js';
import { logger } from './logger.js';
// データベース接続(例)
const db = {
close: async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
logger.info('データベース接続を終了しました');
}
};
// Redis接続(例)
const redis = {
quit: async () => {
await new Promise(resolve => setTimeout(resolve, 500));
logger.info('Redis接続を終了しました');
}
};
// HTTPサーバー
const server = createServer(requestHandler);
const connections = new Set();
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
// クリーンアップハンドラーを登録
gracefulShutdown
.setTimeout(30000)
.register('HTTPサーバー', async () => {
return new Promise((resolve, reject) => {
// Keep-Aliveを無効化
for (const socket of connections) {
socket.setKeepAlive(false);
}
server.close((error) => {
if (error) reject(error);
else resolve();
});
// アイドル接続を終了
for (const socket of connections) {
if (!socket.destroyed) {
socket.end();
}
}
});
})
.register('Redis', async () => {
await redis.quit();
})
.register('データベース', async () => {
await db.close();
});
// シグナルリスナーを登録
gracefulShutdown.listen();
server.listen(3000, () => {
logger.info('サーバーが起動しました', { port: 3000 });
});
|
エラーハンドリングのベストプラクティス#
エラー処理の原則#
1. fail-fastの原則
エラーを早期に検出し、問題のある状態で処理を継続しないようにします。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
function processOrder(order) {
// 早期リターンでバリデーション
if (!order) {
throw new ValidationError('注文データが必要です', 'order');
}
if (!order.items || order.items.length === 0) {
throw new ValidationError('注文には1つ以上の商品が必要です', 'items');
}
if (order.items.some(item => item.quantity <= 0)) {
throw new ValidationError('商品の数量は1以上である必要があります', 'quantity');
}
// バリデーション通過後の処理
return processValidOrder(order);
}
|
2. エラーの伝播と変換
各レイヤーで適切なエラーに変換します。
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
|
// リポジトリ層
async function findUserById(id) {
try {
return await db.query('SELECT * FROM users WHERE id = ?', [id]);
} catch (error) {
throw new DatabaseError('ユーザー検索に失敗しました', 'SELECT', { cause: error });
}
}
// サービス層
async function getUserProfile(userId) {
const user = await findUserById(userId);
if (!user) {
throw new NotFoundError('User', userId);
}
return formatUserProfile(user);
}
// コントローラー層
async function handleGetUser(req, res) {
try {
const profile = await getUserProfile(req.params.id);
res.json(profile);
} catch (error) {
// エラーハンドラーに委譲
throw error;
}
}
|
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
|
async function withRetry(fn, options = {}) {
const {
maxRetries = 3,
delay = 1000,
backoff = 2,
retryable = (error) => error.retryable !== false
} = options;
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error;
if (!retryable(error) || attempt === maxRetries) {
throw error;
}
const waitTime = delay * Math.pow(backoff, attempt - 1);
logger.warn(`試行 ${attempt}/${maxRetries} が失敗しました。${waitTime}ms後にリトライします`, {
error: error.message
});
await new Promise(resolve => setTimeout(resolve, waitTime));
}
}
throw lastError;
}
// 使用例
const result = await withRetry(
() => callExternalAPI(),
{ maxRetries: 3, delay: 1000 }
);
|
アンチパターンと対策#
1. エラーの握り潰し
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
// ❌ アンチパターン: エラーを無視する
try {
await riskyOperation();
} catch (error) {
// 何もしない
}
// ✅ 正しい実装: 適切にログを出力するか再スローする
try {
await riskyOperation();
} catch (error) {
logger.error('操作に失敗しました', { error: error.message });
// 必要に応じて再スロー
throw error;
}
|
2. 過度に広範なtry-catch
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
|
// ❌ アンチパターン: 関数全体を囲む
async function processData(data) {
try {
const validated = validateData(data);
const transformed = transformData(validated);
const result = await saveData(transformed);
return result;
} catch (error) {
// どの処理で失敗したか分からない
console.error('処理に失敗しました');
}
}
// ✅ 正しい実装: 適切な粒度でエラーを処理
async function processData(data) {
let validated;
try {
validated = validateData(data);
} catch (error) {
throw new ValidationError('データ検証に失敗しました', 'data', { cause: error });
}
const transformed = transformData(validated);
try {
return await saveData(transformed);
} catch (error) {
throw new DatabaseError('データ保存に失敗しました', 'INSERT', { cause: error });
}
}
|
3. 文字列でのエラースロー
1
2
3
4
5
6
7
8
|
// ❌ アンチパターン: 文字列をスローする
throw 'エラーが発生しました';
// ✅ 正しい実装: Errorオブジェクトをスローする
throw new Error('エラーが発生しました');
// さらに良い実装: カスタムエラーを使用
throw new ValidationError('入力値が不正です', 'email');
|
統合実装例#
これまでの内容を統合した、本番環境で使用可能なHTTPサーバーの実装例です。
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
|
// server.js
import { createServer } from 'node:http';
import { randomUUID } from 'node:crypto';
import { ApplicationError } from './errors/base-error.js';
import { ValidationError, NotFoundError } from './errors/domain-errors.js';
import { logger, logError } from './logger.js';
import { gracefulShutdown } from './graceful-shutdown.js';
const PORT = process.env.PORT || 3000;
// リクエストハンドラー
async function handleRequest(req, res) {
const requestId = req.headers['x-request-id'] || randomUUID();
res.setHeader('X-Request-ID', requestId);
const startTime = Date.now();
try {
// ルーティング
if (req.url === '/health' && req.method === 'GET') {
return sendJSON(res, 200, { status: 'ok' });
}
if (req.url === '/api/users' && req.method === 'GET') {
const users = [{ id: 1, name: 'User 1' }];
return sendJSON(res, 200, { users });
}
throw new NotFoundError('Route', req.url);
} catch (error) {
handleError(error, req, res, requestId);
} finally {
const duration = Date.now() - startTime;
logger.info('リクエスト完了', {
requestId,
method: req.method,
url: req.url,
statusCode: res.statusCode,
duration: `${duration}ms`
});
}
}
function sendJSON(res, statusCode, data) {
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
function handleError(error, req, res, requestId) {
logError(error, { requestId, method: req.method, url: req.url });
const statusCode = error.statusCode || 500;
const message = statusCode === 500 && process.env.NODE_ENV === 'production'
? 'Internal Server Error'
: error.message;
sendJSON(res, statusCode, {
error: {
message,
requestId,
...(process.env.NODE_ENV !== 'production' && { stack: error.stack })
}
});
}
// サーバー作成
const server = createServer((req, res) => {
handleRequest(req, res).catch(error => {
handleError(error, req, res, 'unknown');
});
});
const connections = new Set();
server.on('connection', (socket) => {
connections.add(socket);
socket.on('close', () => connections.delete(socket));
});
server.on('error', (error) => {
if (error.code === 'EADDRINUSE') {
logger.error('ポートが既に使用されています', { port: PORT });
process.exit(1);
}
logger.error('サーバーエラー', { error: error.message });
});
// プロセスレベルのエラーハンドリング
process.on('uncaughtException', (error, origin) => {
logger.error('未捕捉の例外', {
error: error.message,
origin,
stack: error.stack
});
gracefulShutdown.shutdown('uncaughtException');
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('未処理のPromise拒否', { reason: String(reason) });
gracefulShutdown.shutdown('unhandledRejection');
});
// Graceful shutdown設定
gracefulShutdown
.setTimeout(30000)
.register('HTTPサーバー', () => {
return new Promise((resolve, reject) => {
for (const socket of connections) {
socket.setKeepAlive(false);
}
server.close((error) => {
if (error) reject(error);
else resolve();
});
for (const socket of connections) {
if (!socket.destroyed) socket.end();
}
});
});
gracefulShutdown.listen();
// サーバー起動
server.listen(PORT, () => {
logger.info('サーバーが起動しました', {
port: PORT,
env: process.env.NODE_ENV || 'development',
pid: process.pid
});
});
|
まとめ#
本記事では、Node.jsアプリケーションの安定性を高めるエラーハンドリング戦略を解説しました。
| 項目 |
ポイント |
| 同期エラー |
try-catch-finallyで適切に処理し、リソースを確実に解放 |
| 非同期エラー |
async/awaitとtry-catch、またはPromise.catchで捕捉 |
| カスタムエラー |
ドメイン固有のエラークラスで可読性と保守性を向上 |
| プロセスレベル |
uncaughtException/unhandledRejectionで最終防衛線を構築 |
| ログ出力 |
構造化ログでエラー追跡を容易に |
| Graceful Shutdown |
シグナルハンドリングで安全なプロセス終了を実現 |
エラーハンドリングは「動くコード」と「信頼できるコード」を分ける重要な要素です。本記事で紹介したパターンを参考に、障害に強いNode.jsアプリケーションを構築してください。
参考リンク#