エラーハンドリング — 開発者に優しいエラー
使えないエラーメッセージ
「このエラーって何ですか?」
パートナー企業のエンジニア、ヤマダさんからのSlack。スクリーンショットには:
{ "error": "Something went wrong" }HTTP 500。これだけ。
「何が悪かったのか、どこを直せばいいのかが全くわからない」
サクラは恥ずかしくなった。自分もこのエラーを返すコードを書いていたのだ。良いエラーメッセージは、ドキュメントの次に大切な開発者体験だ。
良いエラーとは何か
Loading diagram...
RFC 7807 Problem Details
HTTPのエラーレスポンスには標準仕様がある。RFC 7807「Problem Details for HTTP APIs」だ。
{
"type": "https://docs.techbridge.jp/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "リクエストのパラメーターにエラーがあります",
"instance": "/api/v1/users/123",
"errors": [
{
"field": "email",
"code": "invalid_format",
"message": "メールアドレスの形式が正しくありません"
},
{
"field": "name",
"code": "too_short",
"message": "名前は2文字以上必要です",
"minimum": 2
}
],
"request_id": "req_2a3b4c5d6e"
}エラーコードの設計
エラーコードは機械可読で、ヒューマンリーダブルなmessageとセットにする。
# config/error_codes.rb
ERROR_CODES = {
# 認証エラー (4xx)
unauthorized: {
code: "unauthorized",
http_status: 401,
message: "認証が必要です"
},
invalid_token: {
code: "invalid_token",
http_status: 401,
message: "トークンが無効です"
},
token_expired: {
code: "token_expired",
http_status: 401,
message: "トークンの有効期限が切れています"
},
forbidden: {
code: "forbidden",
http_status: 403,
message: "このリソースへのアクセス権限がありません"
},
# リソースエラー (4xx)
not_found: {
code: "not_found",
http_status: 404,
message: "リソースが見つかりません"
},
conflict: {
code: "conflict",
http_status: 409,
message: "リソースが競合しています"
},
validation_error: {
code: "validation_error",
http_status: 422,
message: "入力値にエラーがあります"
},
rate_limit_exceeded: {
code: "rate_limit_exceeded",
http_status: 429,
message: "レート制限に達しました"
},
# サーバーエラー (5xx)
internal_error: {
code: "internal_error",
http_status: 500,
message: "サーバーエラーが発生しました"
},
service_unavailable: {
code: "service_unavailable",
http_status: 503,
message: "サービスが一時的に利用できません"
}
}.freezeエラーハンドラーの実装
# app/controllers/concerns/error_handler.rb
module ErrorHandler
extend ActiveSupport::Concern
included do
rescue_from StandardError, with: :handle_standard_error
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from ActiveRecord::RecordInvalid, with: :handle_record_invalid
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
rescue_from AuthenticationError, with: :handle_unauthorized
rescue_from AuthorizationError, with: :handle_forbidden
rescue_from ApiError, with: :handle_api_error
end
private
def handle_not_found(e)
render_problem(
type: "not_found",
detail: "#{e.model} (id: #{e.id}) が見つかりません"
)
end
def handle_record_invalid(e)
render json: {
type: "https://docs.techbridge.jp/errors/validation-error",
title: "Validation Error",
status: 422,
detail: "入力値にエラーがあります",
instance: request.path,
errors: format_validation_errors(e.record.errors),
request_id: request_id
}, status: :unprocessable_entity
end
def handle_bad_request(e)
render_problem(
type: "bad_request",
detail: e.message
)
end
def handle_unauthorized(e)
render_problem(
type: "unauthorized",
detail: e.message
)
end
def handle_forbidden(e)
render_problem(
type: "forbidden",
detail: e.message
)
end
def handle_standard_error(e)
# 本番環境では詳細を隠す
detail = Rails.env.production? ?
"内部エラーが発生しました" :
e.message
# エラーログ(Sentry等に送信)
Rails.logger.error("[#{request_id}] #{e.class}: #{e.message}\n#{e.backtrace.first(10).join("\n")}")
ErrorReporter.report(e, request_id: request_id)
render_problem(
type: "internal_error",
detail: detail
)
end
def render_problem(type:, detail: nil)
error_info = ERROR_CODES.fetch(type.to_sym, ERROR_CODES[:internal_error])
render json: {
type: "https://docs.techbridge.jp/errors/#{error_info[:code]}",
title: type.to_s.humanize,
status: error_info[:http_status],
detail: detail || error_info[:message],
instance: request.path,
request_id: request_id
}, status: error_info[:http_status]
end
def format_validation_errors(errors)
errors.map do |error|
{
field: error.attribute.to_s,
code: error.type.to_s,
message: error.full_message
}
end
end
def request_id
@request_id ||= request.headers["X-Request-ID"] || SecureRandom.hex(8)
end
endrequest_idによるトレーシング
# config/initializers/request_id.rb
Rails.application.config.middleware.insert_before(
ActionDispatch::RequestId,
Rack::Builder.new do
use ActionDispatch::RequestId
run ->(env) { [200, {}, []] }
end
)
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include ErrorHandler
before_action :set_request_id
after_action :log_request
private
def set_request_id
# クライアントからのIDを優先、なければ生成
@request_id = request.headers["X-Request-ID"] || SecureRandom.hex(8)
response.headers["X-Request-ID"] = @request_id
end
def log_request
Rails.logger.info({
request_id: @request_id,
method: request.method,
path: request.path,
status: response.status,
duration_ms: ((Time.now - @request_start) * 1000).round
}.to_json)
end
endクライアントはリクエストIDをログに残し、サポート問い合わせ時に提示する:
GET /api/v1/users/999
X-Request-ID: client-generated-id-123
HTTP/1.1 404 Not Found
X-Request-ID: client-generated-id-123
Content-Type: application/problem+json
{
"type": "https://docs.techbridge.jp/errors/not_found",
"title": "Not Found",
"status": 404,
"detail": "User (id: 999) が見つかりません",
"instance": "/api/v1/users/999",
"request_id": "client-generated-id-123"
}
INFO
X-Request-ID を全リクエスト・レスポンスに含めることで、ログから特定のリクエストを追跡できます。サポート問い合わせ時に「request_idを教えてください」と聞くだけで原因が特定できます。
バリデーションエラーの詳細
# app/models/user.rb
class User < ApplicationRecord
validates :name, presence: true, length: { minimum: 2, maximum: 50 }
validates :email, presence: true, uniqueness: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :age, numericality: { greater_than_or_equal_to: 0, less_than: 150 }, allow_nil: true
end
# バリデーションエラーのレスポンス例
{
"type": "https://docs.techbridge.jp/errors/validation-error",
"title": "Validation Error",
"status": 422,
"detail": "入力値にエラーがあります",
"instance": "/api/v1/users",
"errors": [
{
"field": "name",
"code": "too_short",
"message": "名前は2文字以上で入力してください",
"minimum": 2
},
{
"field": "email",
"code": "invalid",
"message": "メールアドレスは有効な形式で入力してください"
},
{
"field": "email",
"code": "taken",
"message": "このメールアドレスはすでに登録されています"
}
],
"request_id": "req_abc123"
}エラードキュメントページ
エラーコードごとにドキュメントページを用意する。type URLからアクセスできる。
# 404 Not Found
## 原因
指定したリソースが存在しない場合に返されます。
## よくある原因
- IDが間違っている
- リソースが削除されている
- アクセス権限がないため存在しないように見える
## 対処法
- IDを確認してください
- 一覧エンドポイントで存在を確認してください
- 認証トークンが正しいことを確認してください(403との混同を避けるため意図的に404を返す場合があります)
## エラー例
```json
{
"type": "https://docs.techbridge.jp/errors/not_found",
"status": 404,
"detail": "User (id: 999) が見つかりません"
}
## Sentryによるエラー追跡
```ruby
# Gemfile
gem 'sentry-ruby'
gem 'sentry-rails'
# config/initializers/sentry.rb
Sentry.init do |config|
config.dsn = ENV["SENTRY_DSN"]
config.breadcrumbs_logger = [:active_support_logger]
config.traces_sample_rate = 0.1 # 10%のトレース
config.before_send = ->(event, hint) {
# 404などの想定内エラーはSentryに送らない
if hint[:exception].is_a?(ActiveRecord::RecordNotFound)
nil
else
event
end
}
end
# エラーレポーターモジュール
module ErrorReporter
def self.report(exception, context = {})
Sentry.capture_exception(exception, extra: context)
end
end
テスト
# spec/requests/error_handling_spec.rb
RSpec.describe "Error Handling" do
describe "404 Not Found" do
it "RFC 7807形式のエラーを返す" do
get "/api/v1/users/99999",
headers: { "X-API-Key" => create(:api_key).key }
expect(response).to have_http_status(:not_found)
expect(response.content_type).to include("application/json")
body = JSON.parse(response.body)
expect(body["type"]).to include("not_found")
expect(body["status"]).to eq(404)
expect(body["request_id"]).to be_present
end
end
describe "422 Validation Error" do
it "フィールドごとのエラー詳細を返す" do
post "/api/v1/users",
params: { user: { name: "X", email: "invalid" } }.to_json,
headers: {
"Content-Type" => "application/json",
"X-API-Key" => create(:api_key).key
}
expect(response).to have_http_status(:unprocessable_entity)
body = JSON.parse(response.body)
errors = body["errors"]
field_errors = errors.map { |e| e["field"] }
expect(field_errors).to include("name", "email")
end
end
endWARNING
本番環境では内部エラー(500)の詳細(スタックトレースなど)をレスポンスに含めないでください。セキュリティ情報(ファイルパス、ライブラリバージョン等)が漏洩します。詳細はログとSentryに記録し、request_idで追跡します。
サクラの変化
ヤマダさんから再びSlackが来た。
「エラーメッセージが親切になった! request_id をサポートに伝えたら5分で解決した」
良いエラーメッセージは、サポートコストを劇的に削減する。サクラはこれをドキュメントに書き加えた:
「エラーを恥じるな。エラーをわかりやすくする努力を惜しむな」
次章では、APIを使いたくなるドキュメントの書き方を学ぶ。