はじめに
こんにちは!最近、後輩から「API呼んでも500エラーが返ってきて、何が悪いのか全然わからない...」と相談されることが増えてきました。APIのトラブルって、原因が多岐にわたるので結構大変ですよね。
そこで今回は、TypeScript + Express環境でのAPI トラブルシューティングについて、実際に遭遇した問題を踏まえて整理してみました。きっと同じような悩みを抱えている方の参考になるはずです。
よくある「API呼べない」パターン
まず、実際によく遭遇するパターンを整理してみましょう。
1. まずは基本から:接続できているか確認
サーバーが起動しているか
意外とあるあるなのが、「サーバー起動し忘れ」です。特に複数のプロジェクトを並行して作業している時によくやらかします。
# プロセス確認
ps aux | grep node
# または
lsof -i :3000 # ポート3000で起動しているプロセスを確認
Expressサーバーの起動確認も念のため:
// server.ts
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`🚀 Server is running on http://localhost:${PORT}`);
});
// ヘルスチェックエンドポイントがあると便利
app.get('/health', (req, res) => {
res.json({ status: 'OK', timestamp: new Date().toISOString() });
});
CORSの罠
フロントエンドから直接API呼び出しをしている場合、CORSエラーは本当によく遭遇します。ブラウザのDevToolsで「CORS policy」って出てきたら、これですね。
// Express側での対応
import cors from 'cors';
// 開発環境では緩め
if (process.env.NODE_ENV === 'development') {
app.use(cors({
origin: 'http://localhost:3000', // フロントエンドのURL
credentials: true
}));
} else {
// 本番環境では厳格に
app.use(cors({
origin: process.env.FRONTEND_URL,
credentials: true
}));
}
フロントエンド側でのAPIコール例:
// api.ts
const API_BASE_URL = process.env.NODE_ENV === 'development'
? 'http://localhost:8000'
: 'https://api.yourapp.com';
export const apiClient = {
async get<T>(endpoint: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
method: 'GET',
credentials: 'include', // Cookieを送る場合
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`API Error: ${response.status} ${response.statusText}`);
}
return response.json();
}
};
2. 認証周りのトラブル(401/403エラー)
認証まわりは本当にハマりやすいところです。JWTを使っている場合の典型的なパターンを見てみましょう。
JWT トークンの問題
// JWT検証ミドルウェア(Express側)
import jwt from 'jsonwebtoken';
interface JwtPayload {
userId: string;
email: string;
}
export const authenticateToken = (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer TOKEN"
if (!token) {
return res.status(401).json({ error: 'Access token required' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!) as JwtPayload;
(req as any).user = decoded; // 型安全性のために本来はRequestを拡張すべき
next();
} catch (error) {
console.error('JWT verification failed:', error);
return res.status(403).json({ error: 'Invalid or expired token' });
}
};
フロントエンド側でのトークン管理:
// authStore.ts(Zustand使用例)
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthState {
token: string | null;
setToken: (token: string) => void;
clearToken: () => void;
isTokenExpired: () => boolean;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
token: null,
setToken: (token: string) => set({ token }),
clearToken: () => set({ token: null }),
isTokenExpired: () => {
const { token } = get();
if (!token) return true;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
return payload.exp * 1000 < Date.now();
} catch {
return true;
}
},
}),
{ name: 'auth-storage' }
)
);
// APIクライアントでの使用
export const authenticatedFetch = async (endpoint: string, options: RequestInit = {}) => {
const { token, isTokenExpired, clearToken } = useAuthStore.getState();
if (!token || isTokenExpired()) {
clearToken();
throw new Error('Authentication required');
}
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
clearToken();
throw new Error('Token expired');
}
return response;
};
3. リクエスト形式の問題(400/422エラー)
バリデーションエラーのハンドリング
Expressでzodを使ったバリデーション例:
import { z } from 'zod';
// スキーマ定義
const CreateUserSchema = z.object({
email: z.string().email('Invalid email format'),
name: z.string().min(1, 'Name is required').max(100, 'Name too long'),
age: z.number().int().min(0).max(150).optional(),
});
type CreateUserRequest = z.infer<typeof CreateUserSchema>;
// バリデーションミドルウェア
export const validateBody = (schema: z.ZodSchema) => {
return (req: Request, res: Response, next: NextFunction) => {
try {
schema.parse(req.body);
next();
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({
error: 'Validation failed',
details: error.errors.map(err => ({
field: err.path.join('.'),
message: err.message
}))
});
}
next(error);
}
};
};
// 使用例
app.post('/users',
validateBody(CreateUserSchema),
async (req: Request, res: Response) => {
const userData = req.body as CreateUserRequest;
// ここではバリデーション済みのデータを安全に使える
// ...
}
);
フロントエンド側でも同じスキーマを使いまわせるのがTypeScriptの良いところですね:
// shared/schemas.ts(フロントとバックエンドで共有)
export const CreateUserSchema = z.object({
email: z.string().email('メールアドレスの形式が正しくありません'),
name: z.string().min(1, '名前は必須です').max(100, '名前が長すぎます'),
age: z.number().int().min(0).max(150).optional(),
});
// フロントエンド側での使用
const handleSubmit = async (formData: unknown) => {
try {
const validatedData = CreateUserSchema.parse(formData);
await apiClient.post('/users', validatedData);
} catch (error) {
if (error instanceof z.ZodError) {
// バリデーションエラーをUIに表示
setErrors(error.errors);
}
}
};
4. サーバーエラー(500番台)のデバッグ
500エラーが一番厄介ですよね。ログをしっかり仕込んでおくことが重要です。
// logger.ts
import winston from 'winston';
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/error.log', level: 'error' }),
new winston.transports.File({ filename: 'logs/combined.log' }),
],
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
// エラーハンドリングミドルウェア
export const errorHandler = (
error: Error,
req: Request,
res: Response,
next: NextFunction
) => {
logger.error({
message: error.message,
stack: error.stack,
url: req.url,
method: req.method,
body: req.body,
headers: req.headers,
});
if (process.env.NODE_ENV === 'production') {
res.status(500).json({ error: 'Internal server error' });
} else {
res.status(500).json({
error: error.message,
stack: error.stack
});
}
};
データベース接続の問題
Prismaを使っている場合のよくあるパターン:
// database.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// 接続テスト
export const testDatabaseConnection = async () => {
try {
await prisma.$connect();
logger.info('Database connected successfully');
} catch (error) {
logger.error('Database connection failed:', error);
process.exit(1);
}
};
// Graceful shutdown
process.on('beforeExit', async () => {
await prisma.$disconnect();
});
5. 実践的なデバッグツール
カスタムAPIクライアントでのデバッグ機能
実際に使っているAPIクライアントの例です:
// apiClient.ts
interface ApiClientOptions {
baseURL: string;
timeout?: number;
retries?: number;
debug?: boolean;
}
class ApiClient {
private baseURL: string;
private timeout: number;
private retries: number;
private debug: boolean;
constructor(options: ApiClientOptions) {
this.baseURL = options.baseURL;
this.timeout = options.timeout || 10000;
this.retries = options.retries || 3;
this.debug = options.debug || false;
}
private log(message: string, data?: any) {
if (this.debug) {
console.log(`[API Client] ${message}`, data || '');
}
}
private async delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const { token } = useAuthStore.getState();
const config: RequestInit = {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { 'Authorization': `Bearer ${token}` }),
...options.headers,
},
};
this.log(`Making request to ${url}`, { method: config.method, headers: config.headers });
for (let attempt = 1; attempt <= this.retries; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(url, {
...config,
signal: controller.signal,
});
clearTimeout(timeoutId);
this.log(`Response received`, {
status: response.status,
statusText: response.statusText
});
if (!response.ok) {
const errorBody = await response.text();
this.log(`Error response body`, errorBody);
throw new ApiError(
`HTTP ${response.status}: ${response.statusText}`,
response.status,
errorBody
);
}
const data = await response.json();
this.log(`Success response`, data);
return data;
} catch (error) {
this.log(`Attempt ${attempt} failed`, error);
if (attempt === this.retries) {
throw error;
}
// リトライ可能なエラーかチェック
if (this.shouldRetry(error)) {
await this.delay(1000 * attempt); // exponential backoff
continue;
}
throw error;
}
}
throw new Error('Max retries exceeded');
}
private shouldRetry(error: any): boolean {
if (error.name === 'AbortError') return false; // タイムアウトはリトライしない
if (error instanceof ApiError) {
const retryableStatus = [408, 429, 500, 502, 503, 504];
return retryableStatus.includes(error.status);
}
return true; // ネットワークエラーなどはリトライ
}
}
class ApiError extends Error {
constructor(
message: string,
public status: number,
public responseBody: string
) {
super(message);
this.name = 'ApiError';
}
}
// 使用例
export const apiClient = new ApiClient({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000',
debug: process.env.NODE_ENV === 'development',
retries: 2,
timeout: 5000,
});
簡単なログ解析スクリプト
Express のログを解析する簡単なスクリプトも作りました:
// analyzeApiLogs.ts
import fs from 'fs';
import path from 'path';
interface LogEntry {
timestamp: string;
level: string;
message: string;
url?: string;
method?: string;
statusCode?: number;
responseTime?: number;
}
function analyzeApiLogs(logFilePath: string) {
const logContent = fs.readFileSync(logFilePath, 'utf-8');
const lines = logContent.split('\n').filter(line => line.trim());
const entries: LogEntry[] = [];
const errorCounts: Record<string, number> = {};
const endpointStats: Record<string, { count: number; avgTime: number; times: number[] }> = {};
for (const line of lines) {
try {
const entry: LogEntry = JSON.parse(line);
entries.push(entry);
// エラー集計
if (entry.level === 'error') {
const key = entry.message.split('\n')[0]; // スタックトレース除く
errorCounts[key] = (errorCounts[key] || 0) + 1;
}
// エンドポイント統計
if (entry.url && entry.responseTime) {
const endpoint = `${entry.method} ${entry.url}`;
if (!endpointStats[endpoint]) {
endpointStats[endpoint] = { count: 0, avgTime: 0, times: [] };
}
endpointStats[endpoint].count++;
endpointStats[endpoint].times.push(entry.responseTime);
}
} catch (error) {
console.warn('Failed to parse log line:', line);
}
}
// 統計計算
Object.keys(endpointStats).forEach(endpoint => {
const stats = endpointStats[endpoint];
stats.avgTime = stats.times.reduce((a, b) => a + b, 0) / stats.times.length;
});
console.log('📊 API Log Analysis');
console.log('==================');
console.log(`Total log entries: ${entries.length}`);
console.log();
console.log('🚨 Top Errors:');
Object.entries(errorCounts)
.sort(([,a], [,b]) => b - a)
.slice(0, 5)
.forEach(([error, count]) => {
console.log(` ${count}x: ${error}`);
});
console.log();
console.log('🐌 Slowest Endpoints:');
Object.entries(endpointStats)
.sort(([,a], [,b]) => b.avgTime - a.avgTime)
.slice(0, 5)
.forEach(([endpoint, stats]) => {
console.log(` ${endpoint}: ${stats.avgTime.toFixed(2)}ms avg (${stats.count} requests)`);
});
}
// 使用例
if (require.main === module) {
const logPath = process.argv[2] || './logs/combined.log';
analyzeApiLogs(logPath);
}
6. 開発時に便利な設定
環境別設定の管理
// config.ts
const config = {
development: {
port: 8000,
dbUrl: 'postgresql://user:pass@localhost:5432/myapp_dev',
jwtSecret: 'dev-secret-key',
corsOrigin: 'http://localhost:3000',
logLevel: 'debug',
},
production: {
port: process.env.PORT || 8000,
dbUrl: process.env.DATABASE_URL!,
jwtSecret: process.env.JWT_SECRET!,
corsOrigin: process.env.FRONTEND_URL!,
logLevel: 'info',
},
test: {
port: 8001,
dbUrl: 'postgresql://user:pass@localhost:5432/myapp_test',
jwtSecret: 'test-secret-key',
corsOrigin: 'http://localhost:3000',
logLevel: 'error',
},
};
const env = (process.env.NODE_ENV as keyof typeof config) || 'development';
export default config[env];
ホットリロード & nodemon設定
// nodemon.json
{
"watch": ["src"],
"ext": "ts,json",
"ignore": ["src/**/*.test.ts"],
"exec": "tsx src/server.ts",
"env": {
"NODE_ENV": "development"
}
}
まとめ
APIのトラブルシューティングって、慣れないうちは本当に大変ですが、パターンを覚えてしまえば意外とスムーズに解決できるようになります。
個人的に重要だと思うポイント:
- ログをちゃんと仕込む - 後で絶対助かります
- 段階的に切り分ける - いきなり複雑なところを見ずに、基本から確認
- エラーハンドリングを丁寧に - ユーザーにも開発者にも優しい設計を
- 開発時の設定を充実させる - デバッグしやすい環境を整える
最初は時間がかかっても、こういった仕組みを整備しておくと、後々の開発効率が全然違ってきます。特にチーム開発では、他のメンバーもデバッグしやすくなるので投資する価値ありです。
皆さんも何かハマった時の対処法があれば、ぜひコメントで教えてください!