mybook

エラーハンドリング — 開発者に優しいエラー

使えないエラーメッセージ

「このエラーって何ですか?」

パートナー企業のエンジニア、ヤマダさんからの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
end

request_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
end

WARNING

本番環境では内部エラー(500)の詳細(スタックトレースなど)をレスポンスに含めないでください。セキュリティ情報(ファイルパス、ライブラリバージョン等)が漏洩します。詳細はログとSentryに記録し、request_idで追跡します。

サクラの変化

ヤマダさんから再びSlackが来た。

「エラーメッセージが親切になった! request_id をサポートに伝えたら5分で解決した」

良いエラーメッセージは、サポートコストを劇的に削減する。サクラはこれをドキュメントに書き加えた:

「エラーを恥じるな。エラーをわかりやすくする努力を惜しむな」

次章では、APIを使いたくなるドキュメントの書き方を学ぶ。