Skilore

APIエラー設計

APIのエラーレスポンスは、クライアント開発者の体験を左右する。HTTPステータスの正しい使い方、RFC 7807 Problem Details、エラーレスポンス設計のベストプラクティスを解説。

84 分で読めます41,519 文字

APIエラー設計

APIのエラーレスポンスは、クライアント開発者の体験を左右する。HTTPステータスの正しい使い方、RFC 7807 Problem Details、エラーレスポンス設計のベストプラクティスを解説。

この章で学ぶこと

  • HTTPステータスコードの適切な使い分けを理解する
  • エラーレスポンスの標準フォーマットを把握する
  • 実践的なAPIエラー設計を学ぶ
  • バリデーションエラーの設計パターンを習得する
  • エラーの国際化(i18n)対応を理解する
  • GraphQL/gRPC のエラー設計との比較を把握する

前提知識

このガイドを読む前に、以下の知識があると理解が深まります:

  • 基本的なプログラミングの知識
  • 関連する基礎概念の理解

1. HTTPステータスコード

1.1 ステータスコードの分類

2xx 成功:
  200 OK               — 汎用的な成功
  201 Created           — リソース作成成功
  202 Accepted          — 非同期処理の受付完了
  204 No Content        — 成功(レスポンスボディなし)
  206 Partial Content   — 部分的なコンテンツ(Range指定)

3xx リダイレクト:
  301 Moved Permanently  — 恒久的なリダイレクト
  302 Found              — 一時的なリダイレクト
  304 Not Modified       — キャッシュ有効

4xx クライアントエラー:
  400 Bad Request       — リクエストが不正(構文エラー等)
  401 Unauthorized      — 認証が必要(未認証)
  403 Forbidden         — 認可されていない(権限不足)
  404 Not Found         — リソースが存在しない
  405 Method Not Allowed — HTTPメソッドが不正
  406 Not Acceptable    — Accept ヘッダーに対応不可
  408 Request Timeout   — リクエストタイムアウト
  409 Conflict          — 競合(重複登録、楽観的ロック失敗等)
  410 Gone              — リソースが恒久的に削除済み
  413 Payload Too Large — ペイロードサイズ超過
  415 Unsupported Media Type — Content-Type 未対応
  422 Unprocessable Entity — バリデーションエラー
  429 Too Many Requests — レート制限超過

5xx サーバーエラー:
  500 Internal Server Error — サーバー内部エラー
  501 Not Implemented       — 未実装のエンドポイント
  502 Bad Gateway           — 上流サーバーエラー
  503 Service Unavailable   — サービス一時停止
  504 Gateway Timeout       — 上流サーバータイムアウト

判断基準:
  クライアントのミス → 4xx
  サーバーの問題 → 5xx
  リトライで解決する可能性 → 429, 503, 504

1.2 よくある間違い

間違い1: 全てのエラーに 200 を返す
  ✗ Bad:
    HTTP 200 OK
    { "success": false, "error": "User not found" }

  ✓ Good:
    HTTP 404 Not Found
    { "type": "...", "title": "Not Found", "status": 404, "detail": "..." }

  理由: HTTPクライアント、CDN、プロキシ、モニタリングツールは
        ステータスコードに基づいて動作する

間違い2: 401 と 403 の混同
  401 Unauthorized = 認証されていない(ログインしていない)
    → WWW-Authenticate ヘッダーを返す
    → クライアントは認証情報を送り直す

  403 Forbidden = 認可されていない(権限がない)
    → 再認証しても結果は変わらない
    → 管理者に権限を依頼する

間違い3: 400 と 422 の混用
  400 Bad Request = リクエストの構文が不正
    → JSONが壊れている、必須パラメータがない
    → パースできないレベルの問題

  422 Unprocessable Entity = 構文は正しいが意味的に不正
    → メールアドレスのフォーマットが違う
    → 数値が範囲外
    → ビジネスルール違反

間違い4: 500 の乱用
  → 500 は「予期しないエラー」のみに使う
  → バリデーションエラーを 500 で返すのは誤り
  → 適切な 4xx コードを選ぶ

間違い5: 404 のセキュリティリスク
  → リソースの存在を確認できてしまう
  → 場合によっては 403 を返す(リソースの存在を隠す)
  → 例: /api/admin/users → 権限がなければ 403(404 ではなく)

1.3 ステータスコード選択フローチャート

リクエスト受信
  ├─ JSON パースできない? → 400 Bad Request
  ├─ 認証トークンがない/無効? → 401 Unauthorized
  ├─ 権限が不足? → 403 Forbidden
  ├─ リソースが見つからない? → 404 Not Found
  ├─ HTTPメソッドが不正? → 405 Method Not Allowed
  ├─ レート制限超過? → 429 Too Many Requests
  ├─ バリデーションエラー?
  │   ├─ 必須パラメータ欠如 → 400 Bad Request
  │   └─ 値の意味的不正 → 422 Unprocessable Entity
  ├─ 競合(重複、楽観的ロック失敗)? → 409 Conflict
  ├─ 処理成功?
  │   ├─ リソース作成 → 201 Created
  │   ├─ 非同期受付 → 202 Accepted
  │   ├─ レスポンスボディなし → 204 No Content
  │   └─ その他 → 200 OK
  └─ サーバー内部エラー → 500 Internal Server Error

2. エラーレスポンスフォーマット

2.1 RFC 7807 Problem Details

// RFC 7807 Problem Details(推奨)
{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation Error",
  "status": 422,
  "detail": "入力値に問題があります",
  "instance": "/api/users",
  "errors": [
    {
      "field": "email",
      "message": "有効なメールアドレスを入力してください"
    },
    {
      "field": "password",
      "message": "8文字以上で入力してください"
    }
  ],
  "traceId": "abc-123-def"
}
RFC 7807 の各フィールド:

  type(必須):
    → エラーの種類を識別するURI
    → ドキュメントページのURLにすると便利
    → 例: "https://api.example.com/errors/validation"
    → デフォルト: "about:blank"

  title(必須):
    → 人間可読なエラータイトル
    → type に対応する短い説明
    → 例: "Validation Error"

  status(推奨):
    → HTTPステータスコード
    → レスポンスヘッダーと一致させる
    → 例: 422

  detail(推奨):
    → エラーの詳細な説明
    → このリクエスト固有の情報
    → 例: "入力値に問題があります"

  instance(オプション):
    → エラーが発生したリクエストのパス
    → デバッグに有用
    → 例: "/api/users"

  拡張フィールド(オプション):
    → RFC 7807 は拡張可能
    → errors, traceId, timestamp 等を追加可能

2.2 TypeScript 型定義と実装

// エラーレスポンスの型定義
interface ApiError {
  type: string;          // エラーの種類(URL or コード)
  title: string;         // 人間可読なタイトル
  status: number;        // HTTPステータス
  detail: string;        // 詳細メッセージ
  instance?: string;     // リクエストパス
  traceId?: string;      // トレーシングID
  timestamp?: string;    // 発生時刻
  errors?: FieldError[]; // フィールドレベルのエラー
}
 
interface FieldError {
  field: string;
  message: string;
  code?: string;
  rejectedValue?: unknown;
}
 
// アプリケーションエラーの基底クラス
class AppError extends Error {
  constructor(
    public readonly code: string,
    public readonly statusCode: number,
    message: string,
    public readonly details?: Record<string, unknown>,
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}
 
// 具体的なエラークラス
class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super('NOT_FOUND', 404, `${resource} with id '${id}' was not found`, {
      resource,
      id,
    });
  }
}
 
class ValidationError extends AppError {
  constructor(
    public readonly fields: FieldError[],
    message: string = '入力値に問題があります',
  ) {
    super('VALIDATION_ERROR', 422, message);
  }
}
 
class ConflictError extends AppError {
  constructor(resource: string, conflict: string) {
    super('CONFLICT', 409, `${resource}: ${conflict}`, {
      resource,
      conflict,
    });
  }
}
 
class UnauthorizedError extends AppError {
  constructor(message: string = '認証が必要です') {
    super('UNAUTHORIZED', 401, message);
  }
}
 
class ForbiddenError extends AppError {
  constructor(message: string = 'この操作を行う権限がありません') {
    super('FORBIDDEN', 403, message);
  }
}
 
class RateLimitError extends AppError {
  constructor(
    public readonly retryAfterSeconds: number,
    message: string = 'リクエスト制限を超過しました',
  ) {
    super('RATE_LIMIT_EXCEEDED', 429, message, { retryAfterSeconds });
  }
}
 
class InternalError extends AppError {
  constructor(
    message: string = 'サーバーエラーが発生しました',
    public readonly cause?: Error,
  ) {
    super('INTERNAL_ERROR', 500, message);
  }
}

2.3 Express エラーミドルウェア

// Express ミドルウェア
function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const traceId = req.headers['x-trace-id'] as string
    ?? req.headers['x-request-id'] as string
    ?? crypto.randomUUID();
 
  if (err instanceof AppError) {
    const response: ApiError = {
      type: `https://api.example.com/errors/${err.code.toLowerCase()}`,
      title: err.code.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
      status: err.statusCode,
      detail: err.message,
      instance: req.originalUrl,
      traceId,
      timestamp: new Date().toISOString(),
    };
 
    // バリデーションエラーの場合はフィールド情報を追加
    if (err instanceof ValidationError) {
      response.errors = err.fields;
    }
 
    // レート制限の場合は Retry-After ヘッダーを追加
    if (err instanceof RateLimitError) {
      res.setHeader('Retry-After', String(err.retryAfterSeconds));
    }
 
    // ログ出力
    if (err.statusCode >= 500) {
      logger.error({ err, traceId, path: req.originalUrl }, 'Server error');
    } else if (err.statusCode >= 400) {
      logger.warn({ err, traceId, path: req.originalUrl }, 'Client error');
    }
 
    res.status(err.statusCode).json(response);
  } else {
    // 予期しないエラー(内部詳細を隠す)
    logger.error(
      { err, traceId, path: req.originalUrl, stack: err.stack },
      'Unexpected error',
    );
 
    res.status(500).json({
      type: 'https://api.example.com/errors/internal',
      title: 'Internal Server Error',
      status: 500,
      detail: 'サーバーエラーが発生しました',
      instance: req.originalUrl,
      traceId,
      timestamp: new Date().toISOString(),
    });
  }
}
 
// ミドルウェアの登録
app.use(errorHandler);
 
// 404 ハンドラー
app.use((req: Request, res: Response) => {
  res.status(404).json({
    type: 'https://api.example.com/errors/not_found',
    title: 'Not Found',
    status: 404,
    detail: `${req.method} ${req.originalUrl} は存在しません`,
    instance: req.originalUrl,
    timestamp: new Date().toISOString(),
  });
});

2.4 NestJS でのエラー設計

// NestJS: Exception Filter
import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from '@nestjs/common';
 
@Catch()
export class GlobalExceptionFilter implements ExceptionFilter {
  constructor(private readonly logger: Logger) {}
 
  catch(exception: unknown, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest<Request>();
    const response = ctx.getResponse<Response>();
 
    const traceId = request.headers['x-trace-id'] as string
      ?? crypto.randomUUID();
 
    let status: number;
    let errorResponse: ApiError;
 
    if (exception instanceof AppError) {
      status = exception.statusCode;
      errorResponse = {
        type: `https://api.example.com/errors/${exception.code.toLowerCase()}`,
        title: exception.code,
        status,
        detail: exception.message,
        instance: request.url,
        traceId,
        timestamp: new Date().toISOString(),
      };
 
      if (exception instanceof ValidationError) {
        errorResponse.errors = exception.fields;
      }
    } else if (exception instanceof HttpException) {
      status = exception.getStatus();
      const exceptionResponse = exception.getResponse();
      errorResponse = {
        type: 'https://api.example.com/errors/http',
        title: HttpStatus[status] ?? 'Error',
        status,
        detail: typeof exceptionResponse === 'string'
          ? exceptionResponse
          : (exceptionResponse as any).message ?? 'エラーが発生しました',
        instance: request.url,
        traceId,
        timestamp: new Date().toISOString(),
      };
    } else {
      status = 500;
      this.logger.error(
        'Unexpected error',
        exception instanceof Error ? exception.stack : String(exception),
      );
      errorResponse = {
        type: 'https://api.example.com/errors/internal',
        title: 'Internal Server Error',
        status: 500,
        detail: 'サーバーエラーが発生しました',
        instance: request.url,
        traceId,
        timestamp: new Date().toISOString(),
      };
    }
 
    response.status(status).json(errorResponse);
  }
}

3. バリデーションエラーの設計

3.1 フィールドレベルのエラー

// バリデーションエラーの詳細設計
interface DetailedFieldError {
  field: string;         // フィールドのパス(ネストもドット記法で)
  code: string;          // エラーコード(機械可読)
  message: string;       // 人間可読メッセージ
  rejectedValue?: unknown; // 拒否された値(セキュリティに注意)
  constraints?: Record<string, unknown>; // 制約条件
}
 
// 例: ユーザー登録のバリデーションエラー
const validationErrorExample: ApiError = {
  type: 'https://api.example.com/errors/validation',
  title: 'Validation Error',
  status: 422,
  detail: '3件のバリデーションエラーがあります',
  instance: '/api/users',
  traceId: 'trace-abc-123',
  timestamp: '2025-01-15T10:30:00Z',
  errors: [
    {
      field: 'email',
      code: 'INVALID_FORMAT',
      message: '有効なメールアドレスを入力してください',
      rejectedValue: 'invalid-email',
      constraints: { pattern: '^[^@]+@[^@]+\\.[^@]+$' },
    },
    {
      field: 'password',
      code: 'TOO_SHORT',
      message: '8文字以上で入力してください',
      constraints: { minLength: 8 },
    },
    {
      field: 'profile.age',
      code: 'OUT_OF_RANGE',
      message: '0以上130以下の値を入力してください',
      rejectedValue: -1,
      constraints: { min: 0, max: 130 },
    },
  ],
};

3.2 バリデーションライブラリとの統合

// Zod との統合
import { z } from 'zod';
 
const CreateUserSchema = z.object({
  email: z.string().email('有効なメールアドレスを入力してください'),
  password: z.string().min(8, '8文字以上で入力してください'),
  name: z.string().min(1, '名前を入力してください').max(100),
  profile: z.object({
    age: z.number().int().min(0).max(130).optional(),
    bio: z.string().max(500).optional(),
  }).optional(),
});
 
// Zod エラーを API エラーに変換
function zodToFieldErrors(error: z.ZodError): FieldError[] {
  return error.errors.map((issue) => ({
    field: issue.path.join('.'),
    code: issue.code.toUpperCase(),
    message: issue.message,
  }));
}
 
// バリデーションミドルウェア
function validate<T>(schema: z.ZodSchema<T>) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      throw new ValidationError(zodToFieldErrors(result.error));
    }
    req.body = result.data;
    next();
  };
}
 
// 使用例
app.post('/api/users', validate(CreateUserSchema), async (req, res) => {
  const user = await userService.create(req.body);
  res.status(201).json(user);
});
# Python: Pydantic との統合
from pydantic import BaseModel, EmailStr, Field, validator
from fastapi import FastAPI, HTTPException
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
 
app = FastAPI()
 
 
class CreateUserRequest(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=100)
    name: str = Field(min_length=1, max_length=100)
    age: int | None = Field(None, ge=0, le=130)
 
    @validator('password')
    def password_strength(cls, v):
        if not any(c.isupper() for c in v):
            raise ValueError('大文字を1文字以上含めてください')
        if not any(c.isdigit() for c in v):
            raise ValueError('数字を1文字以上含めてください')
        return v
 
 
# Pydantic バリデーションエラーを RFC 7807 に変換
@app.exception_handler(RequestValidationError)
async def validation_exception_handler(
    request: Request,
    exc: RequestValidationError,
) -> JSONResponse:
    errors = []
    for error in exc.errors():
        field_path = '.'.join(str(loc) for loc in error['loc'] if loc != 'body')
        errors.append({
            'field': field_path,
            'code': error['type'].upper(),
            'message': error['msg'],
        })
 
    return JSONResponse(
        status_code=422,
        content={
            'type': 'https://api.example.com/errors/validation',
            'title': 'Validation Error',
            'status': 422,
            'detail': f'{len(errors)}件のバリデーションエラーがあります',
            'instance': str(request.url.path),
            'errors': errors,
            'timestamp': datetime.utcnow().isoformat() + 'Z',
        },
    )
 
 
@app.post('/api/users', status_code=201)
async def create_user(user: CreateUserRequest):
    return await user_service.create(user)

3.3 ビジネスルールバリデーション

// ビジネスルールのバリデーション
class OrderValidator {
  async validate(order: CreateOrderInput): Promise<FieldError[]> {
    const errors: FieldError[] = [];
 
    // 在庫チェック
    for (const item of order.items) {
      const stock = await this.stockService.getAvailable(item.productId);
      if (stock < item.quantity) {
        errors.push({
          field: `items[${item.productId}].quantity`,
          code: 'INSUFFICIENT_STOCK',
          message: `在庫が不足しています(残り${stock}個)`,
          rejectedValue: item.quantity,
          constraints: { available: stock },
        });
      }
    }
 
    // 注文金額チェック
    const total = order.items.reduce(
      (sum, item) => sum + item.price * item.quantity, 0,
    );
    if (total > 1_000_000) {
      errors.push({
        field: 'total',
        code: 'AMOUNT_EXCEEDS_LIMIT',
        message: '1回の注文は100万円以下にしてください',
        rejectedValue: total,
        constraints: { maxAmount: 1_000_000 },
      });
    }
 
    // 配送先チェック
    if (order.shippingAddress) {
      const isDeliverable = await this.shippingService.isDeliverable(
        order.shippingAddress.zipCode,
      );
      if (!isDeliverable) {
        errors.push({
          field: 'shippingAddress.zipCode',
          code: 'UNDELIVERABLE_AREA',
          message: 'この郵便番号への配送は対応していません',
          rejectedValue: order.shippingAddress.zipCode,
        });
      }
    }
 
    return errors;
  }
}
 
// コントローラーでの使用
app.post('/api/orders', async (req, res) => {
  // 構文バリデーション(Zod)
  const input = CreateOrderSchema.parse(req.body);
 
  // ビジネスルールバリデーション
  const validator = new OrderValidator();
  const errors = await validator.validate(input);
 
  if (errors.length > 0) {
    throw new ValidationError(errors);
  }
 
  const order = await orderService.create(input);
  res.status(201).json(order);
});

4. エラー設計のベストプラクティス

4.1 設計原則

1. 一貫性
   → 全エンドポイントで同じエラーフォーマット
   → ステータスコードの使い方を統一
   → Content-Type: application/problem+json(RFC 7807)

2. セキュリティ
   → 500エラーで内部情報を漏らさない
   → スタックトレースは本番では非表示
   → 「ユーザーが存在しない」vs「パスワードが違う」を区別しない
   → SQLエラーの詳細を返さない
   → 内部のクラス名やファイルパスを返さない

3. 機械可読性
   → エラーコードは文字列(enum対応)
   → HTTPステータスとエラーコードの組み合わせ
   → type フィールドでドキュメントへリンク

4. 人間可読性
   → detail フィールドで具体的なメッセージ
   → フィールドレベルのバリデーションエラー
   → エンドユーザーに表示可能なメッセージ

5. リトライ可能性の明示
   → 429: Retry-After ヘッダー
   → 503: Retry-After ヘッダー
   → エラーコードでリトライ判定可能に

6. デバッグ容易性
   → traceId でリクエストを追跡
   → timestamp で時系列を追跡
   → instance でエンドポイントを特定

4.2 エラーコード体系

// エラーコードの体系的な設計
const ERROR_CODES = {
  // 認証・認可
  AUTH_TOKEN_EXPIRED: { status: 401, title: 'Token Expired' },
  AUTH_TOKEN_INVALID: { status: 401, title: 'Invalid Token' },
  AUTH_INSUFFICIENT_PERMISSIONS: { status: 403, title: 'Insufficient Permissions' },
 
  // バリデーション
  VALIDATION_FAILED: { status: 422, title: 'Validation Failed' },
  VALIDATION_REQUIRED_FIELD: { status: 422, title: 'Required Field Missing' },
  VALIDATION_INVALID_FORMAT: { status: 422, title: 'Invalid Format' },
 
  // リソース
  RESOURCE_NOT_FOUND: { status: 404, title: 'Resource Not Found' },
  RESOURCE_ALREADY_EXISTS: { status: 409, title: 'Resource Already Exists' },
  RESOURCE_CONFLICT: { status: 409, title: 'Resource Conflict' },
  RESOURCE_GONE: { status: 410, title: 'Resource Gone' },
 
  // レート制限
  RATE_LIMIT_EXCEEDED: { status: 429, title: 'Rate Limit Exceeded' },
 
  // ビジネスロジック
  BUSINESS_INSUFFICIENT_BALANCE: { status: 422, title: 'Insufficient Balance' },
  BUSINESS_ORDER_LIMIT_EXCEEDED: { status: 422, title: 'Order Limit Exceeded' },
  BUSINESS_ACCOUNT_SUSPENDED: { status: 403, title: 'Account Suspended' },
 
  // サーバーエラー
  INTERNAL_ERROR: { status: 500, title: 'Internal Server Error' },
  SERVICE_UNAVAILABLE: { status: 503, title: 'Service Unavailable' },
  UPSTREAM_ERROR: { status: 502, title: 'Upstream Service Error' },
} as const;
 
type ErrorCode = keyof typeof ERROR_CODES;
 
// エラーコードから ApiError を構築
function createApiError(
  code: ErrorCode,
  detail: string,
  extras?: Partial<ApiError>,
): ApiError {
  const { status, title } = ERROR_CODES[code];
  return {
    type: `https://api.example.com/errors/${code.toLowerCase()}`,
    title,
    status,
    detail,
    ...extras,
  };
}

4.3 セキュリティ考慮事項

// セキュリティを考慮したエラーレスポンス
 
// 認証エラー: ユーザーの存在を漏らさない
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
 
  const user = await userService.findByEmail(email);
 
  // ✗ Bad: ユーザーの存在を漏らす
  // if (!user) throw new NotFoundError('User', email);
  // if (!bcrypt.compareSync(password, user.password)) throw new Error('Wrong password');
 
  // ✓ Good: 同じメッセージを返す
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    throw new UnauthorizedError('メールアドレスまたはパスワードが正しくありません');
  }
 
  // タイミング攻撃への対策
  // ユーザーが見つからない場合もハッシュ比較を行う
  const dummyHash = '$2b$10$dummyhashfortimingattackprevention';
  if (!user) {
    await bcrypt.compare(password, dummyHash); // 処理時間を均一化
    throw new UnauthorizedError('メールアドレスまたはパスワードが正しくありません');
  }
});
 
// 500エラー: 内部情報を隠す
function sanitizeError(err: Error, isProduction: boolean): ApiError {
  if (isProduction) {
    return {
      type: 'https://api.example.com/errors/internal',
      title: 'Internal Server Error',
      status: 500,
      detail: 'サーバーエラーが発生しました。しばらくしてから再度お試しください。',
      // スタックトレース、SQLクエリ、ファイルパス等は含めない
    };
  }
 
  // 開発環境では詳細情報を返す
  return {
    type: 'https://api.example.com/errors/internal',
    title: 'Internal Server Error',
    status: 500,
    detail: err.message,
    // 開発環境でのみ追加情報を含める
    ...(isProduction ? {} : {
      stack: err.stack,
      cause: err.cause ? String(err.cause) : undefined,
    }),
  };
}
 
// レート制限エラーの情報開示
// ✗ Bad: レート制限の詳細を公開
// { "detail": "100 requests per minute exceeded. Current: 105" }
 
// ✓ Good: 必要最小限の情報
// { "detail": "リクエスト制限を超過しました", "retryAfter": 30 }

5. エラーの国際化(i18n)

5.1 多言語対応の設計

// エラーメッセージの国際化
 
// メッセージカタログ
const errorMessages: Record<string, Record<string, string>> = {
  en: {
    'VALIDATION_FAILED': 'Validation failed',
    'VALIDATION_REQUIRED': '{field} is required',
    'VALIDATION_TOO_SHORT': '{field} must be at least {min} characters',
    'VALIDATION_TOO_LONG': '{field} must be at most {max} characters',
    'VALIDATION_INVALID_EMAIL': 'Please enter a valid email address',
    'NOT_FOUND': '{resource} not found',
    'UNAUTHORIZED': 'Authentication required',
    'FORBIDDEN': 'You do not have permission to perform this action',
    'RATE_LIMIT': 'Too many requests. Please try again later.',
    'INTERNAL_ERROR': 'An internal error occurred. Please try again later.',
  },
  ja: {
    'VALIDATION_FAILED': '入力値に問題があります',
    'VALIDATION_REQUIRED': '{field}は必須です',
    'VALIDATION_TOO_SHORT': '{field}は{min}文字以上で入力してください',
    'VALIDATION_TOO_LONG': '{field}は{max}文字以下で入力してください',
    'VALIDATION_INVALID_EMAIL': '有効なメールアドレスを入力してください',
    'NOT_FOUND': '{resource}が見つかりません',
    'UNAUTHORIZED': '認証が必要です',
    'FORBIDDEN': 'この操作を行う権限がありません',
    'RATE_LIMIT': 'リクエスト制限を超過しました。しばらくしてから再試行してください。',
    'INTERNAL_ERROR': 'サーバーエラーが発生しました。しばらくしてから再試行してください。',
  },
};
 
// フィールド名の翻訳
const fieldNames: Record<string, Record<string, string>> = {
  en: {
    'email': 'Email',
    'password': 'Password',
    'name': 'Name',
    'age': 'Age',
  },
  ja: {
    'email': 'メールアドレス',
    'password': 'パスワード',
    'name': '名前',
    'age': '年齢',
  },
};
 
// メッセージの解決
function resolveMessage(
  code: string,
  locale: string,
  params: Record<string, string | number> = {},
): string {
  const messages = errorMessages[locale] ?? errorMessages['en'];
  let template = messages[code] ?? messages['INTERNAL_ERROR'];
 
  // プレースホルダーを置換
  for (const [key, value] of Object.entries(params)) {
    template = template.replace(`{${key}}`, String(value));
  }
 
  return template;
}
 
// Accept-Language ヘッダーからロケールを決定
function getLocale(req: Request): string {
  const acceptLanguage = req.headers['accept-language'];
  if (!acceptLanguage) return 'en';
 
  // 簡易的なパース
  const preferred = acceptLanguage.split(',')[0].split(';')[0].trim().substring(0, 2);
  return errorMessages[preferred] ? preferred : 'en';
}
 
// i18n対応のエラーミドルウェア
function i18nErrorHandler(
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction,
): void {
  const locale = getLocale(req);
 
  if (err instanceof AppError) {
    const detail = resolveMessage(err.code, locale, err.details as any);
 
    const response: ApiError = {
      type: `https://api.example.com/errors/${err.code.toLowerCase()}`,
      title: err.code,
      status: err.statusCode,
      detail,
      instance: req.originalUrl,
    };
 
    if (err instanceof ValidationError) {
      response.errors = err.fields.map(field => ({
        ...field,
        message: resolveMessage(
          field.code ?? 'VALIDATION_FAILED',
          locale,
          {
            field: fieldNames[locale]?.[field.field] ?? field.field,
            ...field.constraints,
          } as any,
        ),
      }));
    }
 
    res.status(err.statusCode).json(response);
  } else {
    res.status(500).json({
      type: 'https://api.example.com/errors/internal',
      title: 'Internal Server Error',
      status: 500,
      detail: resolveMessage('INTERNAL_ERROR', locale),
    });
  }
}

6. クライアント側のエラーハンドリング

6.1 TypeScript HTTP クライアント

// APIクライアントのエラーハンドリング
class ApiClient {
  private baseUrl: string;
 
  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }
 
  async request<T>(
    path: string,
    options?: RequestInit,
  ): Promise<T> {
    const url = `${this.baseUrl}${path}`;
 
    try {
      const response = await fetch(url, {
        ...options,
        headers: {
          'Content-Type': 'application/json',
          'Accept': 'application/json',
          'Accept-Language': navigator.language,
          ...options?.headers,
        },
      });
 
      if (!response.ok) {
        const errorBody: ApiError = await response.json().catch(() => ({
          type: 'https://api.example.com/errors/unknown',
          title: 'Unknown Error',
          status: response.status,
          detail: response.statusText,
        }));
 
        throw new ApiRequestError(response.status, errorBody);
      }
 
      // 204 No Content の場合
      if (response.status === 204) {
        return undefined as T;
      }
 
      return response.json();
    } catch (error) {
      if (error instanceof ApiRequestError) {
        throw error;
      }
 
      // ネットワークエラー
      throw new NetworkError(
        'ネットワーク接続に問題があります',
        error as Error,
      );
    }
  }
}
 
// APIリクエストエラー
class ApiRequestError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly apiError: ApiError,
  ) {
    super(apiError.detail);
    this.name = 'ApiRequestError';
  }
 
  get isValidationError(): boolean {
    return this.statusCode === 422;
  }
 
  get isAuthError(): boolean {
    return this.statusCode === 401;
  }
 
  get isNotFound(): boolean {
    return this.statusCode === 404;
  }
 
  get isServerError(): boolean {
    return this.statusCode >= 500;
  }
 
  get isRetryable(): boolean {
    return [408, 429, 500, 502, 503, 504].includes(this.statusCode);
  }
 
  get fieldErrors(): FieldError[] {
    return this.apiError.errors ?? [];
  }
}
 
// React コンポーネントでの使用例
function UserRegistrationForm() {
  const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
  const [globalError, setGlobalError] = useState<string | null>(null);
 
  async function handleSubmit(data: FormData) {
    try {
      setFieldErrors({});
      setGlobalError(null);
 
      await api.request('/api/users', {
        method: 'POST',
        body: JSON.stringify(data),
      });
 
      navigate('/registration-complete');
    } catch (error) {
      if (error instanceof ApiRequestError) {
        if (error.isValidationError) {
          // フィールドレベルのエラーをフォームに表示
          const errors: Record<string, string> = {};
          for (const fieldError of error.fieldErrors) {
            errors[fieldError.field] = fieldError.message;
          }
          setFieldErrors(errors);
        } else if (error.isAuthError) {
          navigate('/login');
        } else {
          setGlobalError(error.apiError.detail);
        }
      } else if (error instanceof NetworkError) {
        setGlobalError('ネットワーク接続を確認してください');
      } else {
        setGlobalError('予期しないエラーが発生しました');
      }
    }
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="email" />
      {fieldErrors.email && <span className="error">{fieldErrors.email}</span>}
 
      <input name="password" type="password" />
      {fieldErrors.password && <span className="error">{fieldErrors.password}</span>}
 
      {globalError && <div className="alert alert-error">{globalError}</div>}
 
      <button type="submit">登録</button>
    </form>
  );
}

7. GraphQL のエラー設計

7.1 GraphQL エラーの特性

GraphQL のエラーは REST とは異なる:

  REST:
    → HTTPステータスコードでエラーの種類を示す
    → ボディにエラー詳細を含める
    → 1リクエスト = 1レスポンス

  GraphQL:
    → 常に HTTP 200 を返す(クエリ自体は成功)
    → errors フィールドでエラーを返す
    → 部分的な成功が可能(data と errors が共存)
    → 1リクエストに複数のクエリを含められる
// GraphQL エラーレスポンスの例
const graphqlErrorResponse = {
  data: {
    user: { id: '1', name: '田中太郎' },
    orders: null, // エラーで取得できなかった
  },
  errors: [
    {
      message: '注文情報の取得に失敗しました',
      locations: [{ line: 3, column: 5 }],
      path: ['orders'],
      extensions: {
        code: 'SERVICE_UNAVAILABLE',
        classification: 'ExecutionError',
        retryable: true,
      },
    },
  ],
};
 
// Apollo Server でのエラー定義
import { GraphQLError } from 'graphql';
 
class NotFoundGraphQLError extends GraphQLError {
  constructor(resource: string, id: string) {
    super(`${resource} with id '${id}' not found`, {
      extensions: {
        code: 'NOT_FOUND',
        resource,
        id,
        http: { status: 404 },
      },
    });
  }
}
 
class ValidationGraphQLError extends GraphQLError {
  constructor(errors: FieldError[]) {
    super('Validation failed', {
      extensions: {
        code: 'VALIDATION_ERROR',
        errors,
        http: { status: 422 },
      },
    });
  }
}
 
// Resolver でのエラー使用
const resolvers = {
  Query: {
    user: async (_, { id }) => {
      const user = await userService.findById(id);
      if (!user) {
        throw new NotFoundGraphQLError('User', id);
      }
      return user;
    },
  },
  Mutation: {
    createUser: async (_, { input }) => {
      const errors = await validator.validate(input);
      if (errors.length > 0) {
        throw new ValidationGraphQLError(errors);
      }
      return userService.create(input);
    },
  },
};

8. gRPC のエラー設計

8.1 gRPC ステータスコード

gRPC ステータスコード:
  OK (0)              — 成功
  CANCELLED (1)       — クライアントがキャンセル
  UNKNOWN (2)         — 不明なエラー
  INVALID_ARGUMENT (3) — 不正な引数
  DEADLINE_EXCEEDED (4) — デッドライン超過
  NOT_FOUND (5)       — リソースが存在しない
  ALREADY_EXISTS (6)   — リソースが既に存在
  PERMISSION_DENIED (7) — 権限なし
  RESOURCE_EXHAUSTED (8) — リソース枯渇
  FAILED_PRECONDITION (9) — 前提条件の不一致
  ABORTED (10)        — 操作が中断(トランザクション競合等)
  OUT_OF_RANGE (11)    — 範囲外
  UNIMPLEMENTED (12)   — 未実装
  INTERNAL (13)        — 内部エラー
  UNAVAILABLE (14)     — サービス利用不可
  DATA_LOSS (15)       — データ損失
  UNAUTHENTICATED (16) — 認証なし

HTTP ステータスとの対応:
  INVALID_ARGUMENT   ↔ 400 Bad Request
  UNAUTHENTICATED    ↔ 401 Unauthorized
  PERMISSION_DENIED  ↔ 403 Forbidden
  NOT_FOUND          ↔ 404 Not Found
  ALREADY_EXISTS     ↔ 409 Conflict
  RESOURCE_EXHAUSTED ↔ 429 Too Many Requests
  INTERNAL           ↔ 500 Internal Server Error
  UNAVAILABLE        ↔ 503 Service Unavailable
  DEADLINE_EXCEEDED  ↔ 504 Gateway Timeout
// gRPC エラー詳細(google.rpc.Status)
syntax = "proto3";
 
import "google/rpc/status.proto";
import "google/rpc/error_details.proto";
 
// エラー詳細を含むレスポンス
message ErrorResponse {
  google.rpc.Status status = 1;
}
 
// バリデーションエラーの詳細
// google.rpc.BadRequest を使用
message BadRequest {
  repeated FieldViolation field_violations = 1;
 
  message FieldViolation {
    string field = 1;
    string description = 2;
  }
}
// Go: gRPC エラーの送信
import (
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"
	"google.golang.org/genproto/googleapis/rpc/errdetails"
)
 
func (s *UserService) GetUser(ctx context.Context, req *pb.GetUserRequest) (*pb.User, error) {
	user, err := s.repo.FindByID(ctx, req.Id)
	if err != nil {
		return nil, status.Errorf(codes.Internal, "failed to fetch user: %v", err)
	}
	if user == nil {
		return nil, status.Errorf(codes.NotFound, "user %s not found", req.Id)
	}
	return user, nil
}
 
func (s *UserService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.User, error) {
	// バリデーションエラーの詳細
	violations := validateCreateUser(req)
	if len(violations) > 0 {
		st := status.New(codes.InvalidArgument, "validation failed")
		br := &errdetails.BadRequest{
			FieldViolations: violations,
		}
		st, _ = st.WithDetails(br)
		return nil, st.Err()
	}
 
	return s.repo.Create(ctx, req)
}

9. エラードキュメントの自動生成

9.1 OpenAPI でのエラー定義

# OpenAPI 3.0: エラーレスポンスの定義
openapi: "3.0.0"
info:
  title: Example API
  version: "1.0.0"
 
paths:
  /api/users:
    post:
      summary: ユーザー作成
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserRequest'
      responses:
        '201':
          description: ユーザー作成成功
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          $ref: '#/components/responses/Conflict'
        '422':
          $ref: '#/components/responses/ValidationError'
        '500':
          $ref: '#/components/responses/InternalError'
 
components:
  schemas:
    ProblemDetail:
      type: object
      required: [type, title, status, detail]
      properties:
        type:
          type: string
          format: uri
          description: エラーの種類を識別するURI
          example: "https://api.example.com/errors/validation"
        title:
          type: string
          description: エラータイトル
          example: "Validation Error"
        status:
          type: integer
          description: HTTPステータスコード
          example: 422
        detail:
          type: string
          description: エラーの詳細
          example: "入力値に問題があります"
        instance:
          type: string
          description: リクエストパス
          example: "/api/users"
        traceId:
          type: string
          description: トレーシングID
          example: "abc-123-def"
        timestamp:
          type: string
          format: date-time
          description: エラー発生時刻
        errors:
          type: array
          items:
            $ref: '#/components/schemas/FieldError'
 
    FieldError:
      type: object
      required: [field, message]
      properties:
        field:
          type: string
          description: エラーのあるフィールド
          example: "email"
        code:
          type: string
          description: エラーコード
          example: "INVALID_FORMAT"
        message:
          type: string
          description: エラーメッセージ
          example: "有効なメールアドレスを入力してください"
 
  responses:
    BadRequest:
      description: リクエストが不正
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
    ValidationError:
      description: バリデーションエラー
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
    Conflict:
      description: リソース競合
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'
    InternalError:
      description: サーバーエラー
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetail'

実践演習

演習1: 基本的な実装

以下の要件を満たすコードを実装してください。

要件:

  • 入力データの検証を行うこと
  • エラーハンドリングを適切に実装すること
  • テストコードも作成すること
# 演習1: 基本実装のテンプレート
class Exercise1:
    """基本的な実装パターンの演習"""
 
    def __init__(self):
        self.data = []
 
    def validate_input(self, value):
        """入力値の検証"""
        if value is None:
            raise ValueError("入力値がNoneです")
        return True
 
    def process(self, value):
        """データ処理のメインロジック"""
        self.validate_input(value)
        self.data.append(value)
        return self.data
 
    def get_results(self):
        """処理結果の取得"""
        return {
            'count': len(self.data),
            'data': self.data
        }
 
# テスト
def test_exercise1():
    ex = Exercise1()
    assert ex.process(1) == [1]
    assert ex.process(2) == [1, 2]
    assert ex.get_results()['count'] == 2
 
    try:
        ex.process(None)
        assert False, "例外が発生するべき"
    except ValueError:
        pass
 
    print("全テスト合格!")
 
test_exercise1()

演習2: 応用パターン

基本実装を拡張して、以下の機能を追加してください。

# 演習2: 応用パターン
from typing import List, Dict, Optional
from datetime import datetime
 
class AdvancedExercise:
    """応用パターンの演習"""
 
    def __init__(self, max_size: int = 100):
        self._items: List[Dict] = []
        self._max_size = max_size
        self._created_at = datetime.now()
 
    def add(self, key: str, value: any) -> bool:
        """アイテムの追加(サイズ制限付き)"""
        if len(self._items) >= self._max_size:
            return False
        self._items.append({
            'key': key,
            'value': value,
            'timestamp': datetime.now().isoformat()
        })
        return True
 
    def find(self, key: str) -> Optional[Dict]:
        """キーによる検索"""
        for item in reversed(self._items):
            if item['key'] == key:
                return item
        return None
 
    def remove(self, key: str) -> bool:
        """キーによる削除"""
        for i, item in enumerate(self._items):
            if item['key'] == key:
                self._items.pop(i)
                return True
        return False
 
    def stats(self) -> Dict:
        """統計情報"""
        return {
            'total_items': len(self._items),
            'max_size': self._max_size,
            'usage_percent': len(self._items) / self._max_size * 100,
            'uptime': str(datetime.now() - self._created_at)
        }
 
# テスト
def test_advanced():
    ex = AdvancedExercise(max_size=3)
    assert ex.add("a", 1) == True
    assert ex.add("b", 2) == True
    assert ex.add("c", 3) == True
    assert ex.add("d", 4) == False  # サイズ制限
    assert ex.find("b")['value'] == 2
    assert ex.remove("b") == True
    assert ex.find("b") is None
    stats = ex.stats()
    assert stats['total_items'] == 2
    print("応用テスト全合格!")
 
test_advanced()

演習3: パフォーマンス最適化

以下のコードのパフォーマンスを改善してください。

# 演習3: パフォーマンス最適化
import time
from functools import lru_cache
 
# 最適化前(O(n^2))
def slow_search(data: list, target: int) -> int:
    """非効率な検索"""
    for i in range(len(data)):
        for j in range(i + 1, len(data)):
            if data[i] + data[j] == target:
                return (i, j)
    return (-1, -1)
 
# 最適化後(O(n))
def fast_search(data: list, target: int) -> tuple:
    """ハッシュマップを使った効率的な検索"""
    seen = {}
    for i, num in enumerate(data):
        complement = target - num
        if complement in seen:
            return (seen[complement], i)
        seen[num] = i
    return (-1, -1)
 
# ベンチマーク
def benchmark():
    import random
    data = list(range(5000))
    random.shuffle(data)
    target = data[100] + data[4000]
 
    start = time.time()
    result1 = slow_search(data, target)
    slow_time = time.time() - start
 
    start = time.time()
    result2 = fast_search(data, target)
    fast_time = time.time() - start
 
    print(f"非効率版: {slow_time:.4f}秒")
    print(f"効率版:   {fast_time:.6f}秒")
    print(f"高速化率: {slow_time/fast_time:.0f}倍")
 
benchmark()

ポイント:

  • アルゴリズムの計算量を意識する
  • 適切なデータ構造を選択する
  • ベンチマークで効果を測定する

トラブルシューティング

よくあるエラーと解決策

エラー 原因 解決策
初期化エラー 設定ファイルの不備 設定ファイルのパスと形式を確認
タイムアウト ネットワーク遅延/リソース不足 タイムアウト値の調整、リトライ処理の追加
メモリ不足 データ量の増大 バッチ処理の導入、ページネーションの実装
権限エラー アクセス権限の不足 実行ユーザーの権限確認、設定の見直し
データ不整合 並行処理の競合 ロック機構の導入、トランザクション管理

デバッグの手順

  1. エラーメッセージの確認: スタックトレースを読み、発生箇所を特定する
  2. 再現手順の確立: 最小限のコードでエラーを再現する
  3. 仮説の立案: 考えられる原因をリストアップする
  4. 段階的な検証: ログ出力やデバッガを使って仮説を検証する
  5. 修正と回帰テスト: 修正後、関連する箇所のテストも実行する
# デバッグ用ユーティリティ
import logging
import traceback
from functools import wraps
 
# ロガーの設定
logging.basicConfig(
    level=logging.DEBUG,
    format='%(asctime)s [%(levelname)s] %(name)s: %(message)s'
)
logger = logging.getLogger(__name__)
 
def debug_decorator(func):
    """関数の入出力をログ出力するデコレータ"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        logger.debug(f"呼び出し: {func.__name__}(args={args}, kwargs={kwargs})")
        try:
            result = func(*args, **kwargs)
            logger.debug(f"戻り値: {func.__name__} -> {result}")
            return result
        except Exception as e:
            logger.error(f"例外発生: {func.__name__}: {e}")
            logger.error(traceback.format_exc())
            raise
    return wrapper
 
@debug_decorator
def process_data(items):
    """データ処理(デバッグ対象)"""
    if not items:
        raise ValueError("空のデータ")
    return [item * 2 for item in items]

パフォーマンス問題の診断

パフォーマンス問題が発生した場合の診断手順:

  1. ボトルネックの特定: プロファイリングツールで計測
  2. メモリ使用量の確認: メモリリークの有無をチェック
  3. I/O待ちの確認: ディスクやネットワークI/Oの状況を確認
  4. 同時接続数の確認: コネクションプールの状態を確認
問題の種類 診断ツール 対策
CPU負荷 cProfile, py-spy アルゴリズム改善、並列化
メモリリーク tracemalloc, objgraph 参照の適切な解放
I/Oボトルネック strace, iostat 非同期I/O、キャッシュ
DB遅延 EXPLAIN, slow query log インデックス、クエリ最適化

FAQ

Q1: このトピックを学ぶ上で最も重要なポイントは何ですか?

実践的な経験を積むことが最も重要です。理論だけでなく、実際にコードを書いて動作を確認することで理解が深まります。

Q2: 初心者がよく陥る間違いは何ですか?

基礎を飛ばして応用に進むことです。このガイドで説明している基本概念をしっかり理解してから、次のステップに進むことをお勧めします。

Q3: 実務ではどのように活用されていますか?

このトピックの知識は、日常的な開発業務で頻繁に活用されます。特にコードレビューやアーキテクチャ設計の際に重要になります。


まとめ

原則 ポイント
ステータスコード 正しいコードを選ぶ(4xx vs 5xx)
フォーマット RFC 7807 Problem Details 準拠
セキュリティ 内部情報を漏らさない
一貫性 全エンドポイントで統一
バリデーション フィールドレベルの詳細エラー
リトライ Retry-After ヘッダー
国際化 Accept-Language 対応
ドキュメント OpenAPI でエラー定義
エラーコード 体系的なコード設計
クライアントDX 使いやすいエラーレスポンス

次に読むべきガイド


参考文献

  1. RFC 7807. "Problem Details for HTTP APIs." IETF, 2016.
  2. RFC 9457. "Problem Details for HTTP APIs." IETF, 2023.(RFC 7807 の後継)
  3. Fielding, R. "REST APIs must be hypertext-driven." 2008.
  4. Google Cloud API Design Guide. "Errors." cloud.google.com.
  5. Microsoft REST API Guidelines. "Error Handling." github.com/microsoft.
  6. GraphQL Specification. "Errors." spec.graphql.org.
  7. gRPC Error Handling. "Status codes and their use." grpc.io.
  8. Zalando RESTful API Guidelines. "Error Handling." opensource.zalando.com.
  9. Stripe API Reference. "Errors." stripe.com/docs/api/errors.
  10. Twitter API Documentation. "Error Handling." developer.twitter.com.