エラーハンドリングの設計 — 開発者に優しいエラー応答
「このエラー、意味がわからない」
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 | 人間が読めるエラータイトル |
status | HTTPステータスコード |
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
endErrorResponseのフォーマッター
# 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
endrescue_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
endINFO
ActiveRecord::RecordNotFound は find が自動で発生させるので、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
endWARNING
本番では詳細なエラーを隠す。スタックトレースや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の構造でエラーレスポンスを統一する - カスタム例外クラスで
codeとstatusを持たせ、rescue_fromで一元ハンドリングする - バリデーションエラーはフィールドごとのエラー配列を返してクライアントのUI表示を助ける
- 本番環境では500エラーの詳細を隠し、SentryやCloudWatchで監視する
- 4xxエラーはSentryに送らず、5xxのみ通知することでアラートの品質を保つ
次章では、APIのパフォーマンスを向上させるキャッシュ戦略を学ぶ。