mybook

エラーハンドリングの設計 — 開発者に優しいエラー応答

「このエラー、意味がわからない」

iOSエンジニアの林さんから怒りのSlackが届いた。

{
  "status": 500,
  "error": "Internal Server Error"
}

「これ、何が原因なのか全然わからない。デバッグできない」

ナツミはサーバーログを確認した。バリデーションエラーが500になっていた。エラーハンドリングが全くできていなかった。

「ごめんなさい、すぐ直します」

適切なエラー設計は、APIの使いやすさを大きく左右する。


RFC 7807 — Problem Details for HTTP APIs

HTTPエラーレスポンスの標準フォーマットがRFC 7807として定義されている。

{
  "type": "https://api.livly.jp/errors/validation_failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "物件名は必須です。価格は0以上の値を入力してください。",
  "instance": "/api/v1/properties",
  "errors": [
    {
      "field": "name",
      "message": "物件名は必須です",
      "code": "blank"
    },
    {
      "field": "price",
      "message": "価格は0以上の値を入力してください",
      "code": "greater_than_or_equal_to"
    }
  ]
}
フィールド意味
typeエラーの種類を示すURI
title人間が読めるエラータイトル
statusHTTPステータスコード
detail詳細な説明
instanceエラーが発生したリソースのパス

エラーの種類と設計方針

Loading diagram...

カスタム例外クラスの設計

まず、アプリケーション固有の例外クラスを定義する。

# app/errors/application_error.rb
module ApplicationError
  class Base < StandardError
    attr_reader :code, :status
 
    def initialize(message = nil, code: nil, status: :internal_server_error)
      super(message)
      @code = code
      @status = status
    end
  end
 
  # 認証エラー
  class AuthenticationError < Base
    def initialize(message = '認証が必要です')
      super(message, code: 'authentication_failed', status: :unauthorized)
    end
  end
 
  # 認可エラー
  class AuthorizationError < Base
    def initialize(message = 'この操作を行う権限がありません')
      super(message, code: 'forbidden', status: :forbidden)
    end
  end
 
  # リソース未発見
  class NotFoundError < Base
    def initialize(resource = 'リソース')
      super("#{resource}が見つかりません", code: 'not_found', status: :not_found)
    end
  end
 
  # バリデーションエラー
  class ValidationError < Base
    attr_reader :field_errors
 
    def initialize(record)
      @field_errors = record.errors.map do |error|
        { field: error.attribute, message: error.message, code: error.type }
      end
      super('入力内容に誤りがあります', code: 'validation_failed', status: :unprocessable_entity)
    end
  end
 
  # レート制限
  class RateLimitError < Base
    def initialize(retry_after = nil)
      @retry_after = retry_after
      super('リクエスト回数の制限を超えました', code: 'rate_limit_exceeded', status: :too_many_requests)
    end
  end
end

ErrorResponseのフォーマッター

# app/presenters/error_response.rb
class ErrorResponse
  BASE_URL = 'https://api.livly.jp/errors'
 
  def self.build(error, request: nil)
    case error
    when ApplicationError::ValidationError
      build_validation_error(error, request)
    when ApplicationError::Base
      build_application_error(error, request)
    when ActiveRecord::RecordNotFound
      build_not_found_error(request)
    else
      build_server_error(error, request)
    end
  end
 
  private
 
  def self.build_validation_error(error, request)
    {
      type: "#{BASE_URL}/validation_failed",
      title: 'Validation Failed',
      status: 422,
      detail: error.message,
      instance: request&.path,
      errors: error.field_errors
    }
  end
 
  def self.build_application_error(error, request)
    {
      type: "#{BASE_URL}/#{error.code}",
      title: error.class.name.demodulize,
      status: Rack::Utils::SYMBOL_TO_STATUS_CODE[error.status],
      detail: error.message,
      instance: request&.path
    }
  end
 
  def self.build_not_found_error(request)
    {
      type: "#{BASE_URL}/not_found",
      title: 'Not Found',
      status: 404,
      detail: 'リソースが見つかりません',
      instance: request&.path
    }
  end
 
  def self.build_server_error(error, request)
    # 本番では詳細を隠す
    detail = Rails.env.production? ? 'サーバーエラーが発生しました' : error.message
 
    {
      type: "#{BASE_URL}/internal_server_error",
      title: 'Internal Server Error',
      status: 500,
      detail: detail,
      instance: request&.path
    }
  end
end

rescue_from による一元ハンドリング

# app/controllers/api/v1/base_controller.rb
module Api
  module V1
    class BaseController < ActionController::API
      include ActionController::HttpAuthentication::Token::ControllerMethods
 
      rescue_from ApplicationError::Base,           with: :render_application_error
      rescue_from ApplicationError::ValidationError,with: :render_validation_error
      rescue_from ActiveRecord::RecordNotFound,     with: :render_not_found
      rescue_from ActionController::ParameterMissing, with: :render_parameter_missing
      rescue_from Pundit::NotAuthorizedError,       with: :render_forbidden
 
      private
 
      def render_application_error(error)
        response_body = ErrorResponse.build(error, request: request)
        render json: response_body, status: error.status
      end
 
      def render_validation_error(error)
        response_body = ErrorResponse.build(error, request: request)
        render json: response_body, status: :unprocessable_entity
      end
 
      def render_not_found(error)
        render json: {
          type: 'https://api.livly.jp/errors/not_found',
          title: 'Not Found',
          status: 404,
          detail: error.message.presence || 'リソースが見つかりません',
          instance: request.path
        }, status: :not_found
      end
 
      def render_parameter_missing(error)
        render json: {
          type: 'https://api.livly.jp/errors/parameter_missing',
          title: 'Parameter Missing',
          status: 400,
          detail: "必須パラメータがありません: #{error.param}",
          instance: request.path
        }, status: :bad_request
      end
 
      def render_forbidden
        render json: {
          type: 'https://api.livly.jp/errors/forbidden',
          title: 'Forbidden',
          status: 403,
          detail: 'この操作を行う権限がありません',
          instance: request.path
        }, status: :forbidden
      end
    end
  end
end

コントローラでの使い方

module Api
  module V1
    class PropertiesController < Api::V1::BaseController
      def create
        @property = current_user.properties.build(property_params)
 
        # saveが失敗したらValidationErrorを発生させる
        unless @property.save
          raise ApplicationError::ValidationError.new(@property)
        end
 
        render json: PropertySerializer.new(@property).serializable_hash,
               status: :created
      end
 
      def show
        # find_byでnilになる場合を自前でハンドル
        @property = Property.find_by(id: params[:id])
        raise ApplicationError::NotFoundError.new('物件') unless @property
        authorize @property
 
        render json: PropertySerializer.new(@property).serializable_hash
      end
    end
  end
end

INFO

ActiveRecord::RecordNotFoundfind が自動で発生させるので、rescue_from で一元処理すれば find_by + 手動raise が不要になる。使い分けはチームのスタイル次第。


バリデーションエラーのレスポンス例

{
  "type": "https://api.livly.jp/errors/validation_failed",
  "title": "Validation Failed",
  "status": 422,
  "detail": "入力内容に誤りがあります",
  "instance": "/api/v1/properties",
  "errors": [
    {
      "field": "name",
      "message": "を入力してください",
      "code": "blank"
    },
    {
      "field": "price",
      "message": "は0以上の値にしてください",
      "code": "greater_than_or_equal_to"
    }
  ]
}

iOSアプリ側はこの構造を元に、各フィールドの下にエラーメッセージを表示できる。


エラーのログと監視

本番環境では、エラーをSentryやDatadogに送る。

# config/initializers/sentry.rb
Sentry.init do |config|
  config.dsn = Rails.application.credentials.dig(:sentry, :dsn)
  config.breadcrumbs_logger = [:active_support_logger, :http_logger]
  config.traces_sample_rate = 0.1  # 10%のトレースをサンプリング
 
  # 4xxエラーは除外(クライアントエラーはSentryに送らない)
  config.excluded_exceptions += [
    'ApplicationError::AuthenticationError',
    'ApplicationError::AuthorizationError',
    'ApplicationError::NotFoundError',
    'ApplicationError::ValidationError',
    'ActiveRecord::RecordNotFound'
  ]
end
# BaseControllerで500エラーの時のみSentryに送る
def render_server_error(error)
  Sentry.capture_exception(error) if Rails.env.production?
 
  render json: {
    type: 'https://api.livly.jp/errors/internal_server_error',
    title: 'Internal Server Error',
    status: 500,
    detail: 'サーバーエラーが発生しました。しばらく後にお試しください。',
    instance: request.path
  }, status: :internal_server_error
end

WARNING

本番では詳細なエラーを隠す。スタックトレースやSQL文をレスポンスに含めると、攻撃者に内部構造を知らせてしまう。本番では汎用メッセージのみ返し、詳細はSentryやログに記録する。


AWS CloudWatch Alarmでエラー監視

Loading diagram...
# CloudWatch Alarmの作成(AWS CLI)
aws cloudwatch put-metric-alarm \
  --alarm-name "API-5xx-Error-Rate" \
  --metric-name "5XXError" \
  --namespace "AWS/ApiGateway" \
  --statistic "Sum" \
  --period 60 \
  --threshold 10 \
  --comparison-operator "GreaterThanThreshold" \
  --evaluation-periods 1 \
  --alarm-actions arn:aws:sns:ap-northeast-1:xxx:alerts

まとめ

  • RFC 7807に準拠した type, title, status, detail, instance の構造でエラーレスポンスを統一する
  • カスタム例外クラスで codestatus を持たせ、rescue_from で一元ハンドリングする
  • バリデーションエラーはフィールドごとのエラー配列を返してクライアントのUI表示を助ける
  • 本番環境では500エラーの詳細を隠し、SentryやCloudWatchで監視する
  • 4xxエラーはSentryに送らず、5xxのみ通知することでアラートの品質を保つ

次章では、APIのパフォーマンスを向上させるキャッシュ戦略を学ぶ。