如果你來自 Express.js 的世界,你可能習慣了在每個路由末端加上錯誤處理中介軟體,用 next(error)
將錯誤往下傳遞。在 Spring Boot 中,你會用 @ExceptionHandler
或 @ControllerAdvice
來集中處理例外。Python 的 FastAPI 則提供了優雅的 HTTPException
和自訂例外處理器。
今天我們要探討的是 Rails 如何將錯誤處理提升到另一個層次。在 Rails 的世界裡,錯誤不只是程式的異常狀態,更是與使用者溝通的機會。我們不只要「處理」錯誤,更要「設計」錯誤體驗。
為什麼說錯誤處理如此重要?想像你正在開發的 LMS 系統:學生提交作業時遇到網路問題、講師上傳超過限制的影片檔案、管理員嘗試刪除還有學生註冊的課程。每個錯誤場景都是系統與使用者的接觸點,處理得好能建立信任,處理不當則會造成挫折。
在第二週的學習中,我們已經建立了認證、授權和 API 版本控制。這些功能都會產生各種錯誤:token 過期、權限不足、版本不支援。今天我們要建立一個統一、優雅、可追蹤的錯誤處理系統,為明天的測試驅動開發打下基礎。
Rails 將錯誤分為幾個清晰的層次,每個層次有不同的處理策略:
# Rails 的錯誤層次體系
module ErrorHierarchy
# 層次一:框架層錯誤
# Rails 內建的標準錯誤,通常對應到 HTTP 狀態碼
# - ActiveRecord::RecordNotFound → 404
# - ActionController::ParameterMissing → 400
# - ActionController::RoutingError → 404
# 層次二:業務邏輯錯誤
# 應用程式特定的業務規則違反
class BusinessLogicError < StandardError; end
class InsufficientPermissionError < BusinessLogicError; end
class CourseFullError < BusinessLogicError; end
# 層次三:外部服務錯誤
# 第三方 API、資料庫連線等外部依賴的錯誤
class ExternalServiceError < StandardError; end
class PaymentGatewayError < ExternalServiceError; end
class VideoProcessingError < ExternalServiceError; end
end
這種分層不是任意的設計,而是基於不同錯誤需要不同的處理策略。框架層錯誤通常有標準的處理方式,業務邏輯錯誤需要友善的使用者提示,外部服務錯誤則需要重試機制和降級策略。
讓我們比較不同框架的錯誤處理理念:
框架 | 設計理念 | 實作方式 | 優劣權衡 |
---|---|---|---|
Rails | 約定優於配置,統一處理 | rescue_from 宣告式處理 |
簡潔但需要理解約定 |
Express | 中介軟體鏈式處理 | 錯誤處理中介軟體 | 靈活但容易遺漏 |
Spring Boot | 註解驅動,AOP 切面 | @ExceptionHandler |
強大但較複雜 |
FastAPI | 型別驅動,明確定義 | 例外類別與處理器 | 清晰但較繁瑣 |
Rails 的優勢在於它提供了一套完整的錯誤處理約定。你不需要在每個控制器重複相同的錯誤處理邏輯,而是在 ApplicationController
中統一定義。
讓我們從最基礎的錯誤處理開始,逐步建立完整的系統:
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# 宣告式的錯誤處理,從最具體到最通用的順序
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from ActionController::ParameterMissing, with: :handle_bad_request
rescue_from StandardError, with: :handle_internal_error
private
def handle_not_found(exception)
render_error(:not_found, '找不到指定的資源')
end
def handle_bad_request(exception)
render_error(:bad_request, '請求參數不完整')
end
def handle_internal_error(exception)
# 在開發環境顯示詳細錯誤,生產環境只顯示通用訊息
if Rails.env.development?
render_error(:internal_server_error, exception.message)
else
render_error(:internal_server_error, '系統錯誤,請稍後再試')
end
end
def render_error(status, message)
render json: {
error: {
status: Rack::Utils::SYMBOL_TO_STATUS_CODE[status],
message: message,
timestamp: Time.current.iso8601
}
}, status: status
end
end
統一的錯誤格式不只是為了美觀,更是為了讓前端能夠一致地處理錯誤:
# app/models/concerns/error_response.rb
module ErrorResponse
extend ActiveSupport::Concern
# 錯誤回應的標準結構
# 參考了 JSON API 規範和 RFC 7807 (Problem Details)
class ErrorSerializer
attr_reader :status, :errors
def initialize(status, errors = [])
@status = status
@errors = errors.is_a?(Array) ? errors : [errors]
end
def to_json
{
error: {
status: status_code,
type: error_type,
title: error_title,
detail: error_detail,
errors: formatted_errors,
timestamp: Time.current.iso8601,
request_id: Current.request_id # 用於追蹤錯誤
}
}
end
private
def status_code
Rack::Utils::SYMBOL_TO_STATUS_CODE[status]
end
def error_type
# 提供機器可讀的錯誤類型
case status
when :not_found then 'resource_not_found'
when :unprocessable_entity then 'validation_failed'
when :unauthorized then 'authentication_required'
when :forbidden then 'permission_denied'
else 'unknown_error'
end
end
def error_title
# 人類可讀的錯誤標題
I18n.t("errors.titles.#{status}", default: '發生錯誤')
end
def error_detail
# 詳細的錯誤說明
I18n.t("errors.details.#{status}", default: errors.first.to_s)
end
def formatted_errors
errors.map do |error|
case error
when ActiveModel::Errors
format_validation_errors(error)
when Hash
error
else
{ message: error.to_s }
end
end
end
def format_validation_errors(errors)
errors.map do |attribute, message|
{
field: attribute,
message: message,
code: "invalid_#{attribute}"
}
end
end
end
end
LMS 系統有許多特定的業務規則,違反這些規則時需要清楚的錯誤提示:
# app/errors/business_errors.rb
module BusinessErrors
# 基礎業務錯誤類別
class BaseError < StandardError
attr_reader :code, :status, :details
def initialize(message = nil, code: nil, status: :unprocessable_entity, details: {})
@code = code || self.class.name.demodulize.underscore
@status = status
@details = details
super(message || default_message)
end
private
def default_message
I18n.t("errors.business.#{@code}", default: '業務邏輯錯誤')
end
end
# LMS 特定的業務錯誤
class CourseFullError < BaseError
def initialize(course, max_students)
super(
"課程已額滿",
code: 'course_full',
details: {
course_id: course.id,
current_students: course.enrollments.count,
max_students: max_students
}
)
end
end
class EnrollmentDeadlinePassedError < BaseError
def initialize(course, deadline)
super(
"註冊期限已過",
code: 'enrollment_deadline_passed',
details: {
course_id: course.id,
deadline: deadline.iso8601
}
)
end
end
class InsufficientCourseProgressError < BaseError
def initialize(required_progress, current_progress)
super(
"學習進度不足",
code: 'insufficient_progress',
details: {
required: required_progress,
current: current_progress,
missing: required_progress - current_progress
}
)
end
end
class AssignmentAlreadySubmittedError < BaseError
def initialize(assignment)
super(
"作業已經提交,無法重複提交",
code: 'assignment_already_submitted',
status: :conflict,
details: {
assignment_id: assignment.id,
submitted_at: assignment.submitted_at.iso8601
}
)
end
end
end
# 在控制器中使用業務錯誤
class EnrollmentsController < ApplicationController
rescue_from BusinessErrors::BaseError do |error|
render json: ErrorSerializer.new(
error.status,
{
code: error.code,
message: error.message,
details: error.details
}
).to_json, status: error.status
end
def create
course = Course.find(params[:course_id])
# 檢查業務規則
if course.full?
raise BusinessErrors::CourseFullError.new(course, course.max_students)
end
if course.enrollment_deadline.past?
raise BusinessErrors::EnrollmentDeadlinePassedError.new(course, course.enrollment_deadline)
end
# 正常的註冊流程
enrollment = current_user.enrollments.create!(course: course)
render json: enrollment, status: :created
end
end
生產環境的錯誤追蹤是關鍵。讓我們整合 Sentry(也可以選擇 Rollbar 或 Honeybadger):
# config/initializers/sentry.rb
Sentry.init do |config|
config.dsn = Rails.credentials.sentry[:dsn]
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
# 環境配置
config.enabled_environments = %w[production staging]
config.environment = Rails.env
# 效能監控
config.traces_sample_rate = 0.1 # 採樣 10% 的請求
config.profiles_sample_rate = 0.1
# 過濾敏感資訊
config.before_send = lambda do |event, hint|
# 移除敏感參數
if event.request && event.request.data
event.request.data = filter_sensitive_data(event.request.data)
end
event
end
# 自訂標籤,方便分類和搜尋
config.set_tags = lambda do |scope|
scope.set_tag(:api_version, Current.api_version)
scope.set_tag(:user_role, Current.user&.role)
scope.set_tag(:feature, Current.feature_name)
end
end
# 建立錯誤上下文追蹤
class ApplicationController < ActionController::API
around_action :track_error_context
private
def track_error_context
# 設定 Sentry 上下文
Sentry.with_scope do |scope|
scope.set_user(
id: current_user&.id,
email: current_user&.email,
role: current_user&.role
)
scope.set_context('request', {
url: request.url,
method: request.method,
ip: request.remote_ip,
user_agent: request.user_agent
})
yield
end
rescue => exception
# 記錄額外的診斷資訊
Sentry.capture_exception(exception) do |scope|
scope.set_context('diagnostics', {
controller: controller_name,
action: action_name,
params: filtered_params,
session_id: session.id
})
end
raise # 重新拋出例外,讓 rescue_from 處理
end
def filtered_params
# 過濾敏感參數
params.except(:password, :token, :secret).to_unsafe_h
end
end
除了即時追蹤,我們還需要分析錯誤趨勢:
# app/models/error_metric.rb
class ErrorMetric < ApplicationRecord
# 使用 PostgreSQL 的 JSONB 儲存錯誤詳情
# 這樣可以靈活查詢又保持效能
scope :recent, -> { where('created_at > ?', 24.hours.ago) }
scope :by_status, ->(status) { where(status: status) }
scope :by_controller, ->(controller) { where("details->>'controller' = ?", controller) }
# 記錄錯誤指標
def self.track_error(exception, context = {})
create!(
error_type: exception.class.name,
message: exception.message,
status: context[:status] || 500,
details: {
controller: context[:controller],
action: context[:action],
user_id: context[:user_id],
request_id: context[:request_id],
backtrace: exception.backtrace&.first(5) # 只存前 5 行
},
occurred_at: Time.current
)
end
# 分析方法
def self.error_rate(period = 1.hour)
total_requests = RequestMetric.where('created_at > ?', period.ago).count
error_count = where('occurred_at > ?', period.ago).count
return 0 if total_requests.zero?
(error_count.to_f / total_requests * 100).round(2)
end
def self.top_errors(limit = 10)
group(:error_type)
.where('occurred_at > ?', 24.hours.ago)
.order('count_all DESC')
.limit(limit)
.count
end
def self.error_trend(days = 7)
where('occurred_at > ?', days.days.ago)
.group_by_day(:occurred_at)
.count
end
end
# 建立背景任務定期分析
class ErrorAnalysisJob < ApplicationJob
queue_as :low_priority
def perform
# 計算關鍵指標
metrics = {
error_rate_1h: ErrorMetric.error_rate(1.hour),
error_rate_24h: ErrorMetric.error_rate(24.hours),
top_errors: ErrorMetric.top_errors,
trend: ErrorMetric.error_trend
}
# 檢查是否需要告警
if metrics[:error_rate_1h] > 5.0 # 錯誤率超過 5%
ErrorAlertMailer.high_error_rate(metrics).deliver_later
end
# 存入 Redis 供儀表板使用
Rails.cache.write('error_metrics', metrics, expires_in: 5.minutes)
end
end
當外部服務出現問題時,我們需要優雅降級而非完全失敗:
# app/services/circuit_breaker.rb
class CircuitBreaker
attr_reader :name, :failure_threshold, :timeout, :reset_timeout
def initialize(name, failure_threshold: 5, timeout: 60, reset_timeout: 120)
@name = name
@failure_threshold = failure_threshold
@timeout = timeout
@reset_timeout = reset_timeout
@state = :closed
@failures = 0
@last_failure_time = nil
end
def call
case @state
when :open
if can_attempt_reset?
@state = :half_open
attempt_call { yield }
else
raise CircuitOpenError, "斷路器開啟中:#{@name}"
end
when :half_open
attempt_call { yield }
when :closed
attempt_call { yield }
end
end
private
def attempt_call
result = yield
on_success
result
rescue => exception
on_failure(exception)
raise
end
def on_success
@failures = 0
@state = :closed if @state == :half_open
end
def on_failure(exception)
@failures += 1
@last_failure_time = Time.current
if @failures >= @failure_threshold
@state = :open
Rails.logger.error "斷路器開啟:#{@name},失敗次數:#{@failures}"
end
end
def can_attempt_reset?
@last_failure_time && Time.current - @last_failure_time > @reset_timeout
end
class CircuitOpenError < StandardError; end
end
# 在 LMS 中使用斷路器保護外部服務
class VideoProcessingService
def initialize
@circuit_breaker = CircuitBreaker.new(
'video_processing',
failure_threshold: 3,
timeout: 30,
reset_timeout: 60
)
end
def process_video(video_file)
@circuit_breaker.call do
# 呼叫外部影片處理服務
external_api_call(video_file)
end
rescue CircuitBreaker::CircuitOpenError => e
# 降級策略:返回預設值或使用快取
Rails.logger.warn "影片處理服務暫時不可用,使用降級策略"
{
status: 'pending',
message: '影片處理服務暫時繁忙,請稍後再試',
retry_after: 60
}
end
end
某些暫時性錯誤值得重試,但要避免雪崩效應:
# app/models/concerns/retryable.rb
module Retryable
extend ActiveSupport::Concern
class_methods do
def with_retry(max_attempts: 3, base_delay: 1, max_delay: 16, exceptions: [StandardError])
attempt = 0
delay = base_delay
begin
attempt += 1
yield
rescue *exceptions => e
if attempt >= max_attempts
Rails.logger.error "重試失敗:#{e.message},嘗試次數:#{attempt}"
raise
end
# 指數退避:1, 2, 4, 8, 16...
delay = [delay * 2, max_delay].min
# 加入隨機抖動避免同時重試
actual_delay = delay * (0.5 + rand * 0.5)
Rails.logger.info "重試中:第 #{attempt} 次嘗試,等待 #{actual_delay.round(2)} 秒"
sleep(actual_delay)
retry
end
end
end
end
# 使用重試機制的實際案例
class PaymentService
include Retryable
def charge_user(user, amount)
with_retry(
max_attempts: 3,
base_delay: 2,
exceptions: [Net::ReadTimeout, Stripe::APIConnectionError]
) do
Stripe::Charge.create(
amount: amount,
currency: 'twd',
customer: user.stripe_customer_id,
description: "LMS 課程費用"
)
end
rescue Stripe::CardError => e
# 信用卡錯誤不應該重試
raise BusinessErrors::PaymentFailedError.new(e.message)
end
end
讓我們看看如何在 LMS 的課程註冊功能中整合所有的錯誤處理概念:
# app/controllers/api/v1/enrollments_controller.rb
module Api
module V1
class EnrollmentsController < ApplicationController
# 定義特定的錯誤處理
rescue_from BusinessErrors::CourseFullError, with: :handle_course_full
rescue_from BusinessErrors::EnrollmentDeadlinePassedError, with: :handle_deadline_passed
rescue_from PaymentService::PaymentError, with: :handle_payment_error
def create
# 使用事務確保資料一致性
enrollment = nil
ActiveRecord::Base.transaction do
course = find_course
validate_enrollment_eligibility(course)
# 建立註冊記錄
enrollment = current_user.enrollments.build(
course: course,
enrolled_at: Time.current,
status: 'pending_payment'
)
# 處理付款(可能失敗)
process_payment(course) if course.paid?
enrollment.status = 'active'
enrollment.save!
# 發送確認郵件(使用背景任務避免阻塞)
EnrollmentMailer.confirmation(enrollment).deliver_later
end
render json: EnrollmentSerializer.new(enrollment), status: :created
rescue ActiveRecord::RecordInvalid => e
# 資料驗證失敗
render_validation_errors(e.record.errors)
end
private
def find_course
Course.find(params[:course_id])
rescue ActiveRecord::RecordNotFound
raise BusinessErrors::CourseNotFoundError.new(params[:course_id])
end
def validate_enrollment_eligibility(course)
# 檢查多個業務規則
validator = EnrollmentValidator.new(course, current_user)
unless validator.valid?
raise BusinessErrors::EnrollmentNotAllowedError.new(
validator.error_message,
details: validator.error_details
)
end
end
def process_payment(course)
PaymentService.new.charge_for_course(
user: current_user,
course: course,
amount: course.price,
idempotency_key: "enrollment_#{current_user.id}_#{course.id}"
)
rescue PaymentService::PaymentError => e
# 記錄付款失敗但不中斷註冊
ErrorMetric.track_error(e, {
controller: 'enrollments',
action: 'create',
user_id: current_user.id
})
# 標記為待付款狀態
enrollment.status = 'pending_payment'
enrollment.payment_failure_reason = e.message
end
def handle_course_full(error)
render json: {
error: {
type: 'course_full',
message: '抱歉,此課程已額滿',
details: {
course_id: error.details[:course_id],
waitlist_available: true,
waitlist_position: error.details[:waitlist_position]
}
}
}, status: :conflict
end
def handle_deadline_passed(error)
render json: {
error: {
type: 'enrollment_closed',
message: '註冊期限已過',
details: {
deadline: error.details[:deadline],
next_session: error.details[:next_session_date]
}
}
}, status: :unprocessable_entity
end
def handle_payment_error(error)
# 付款錯誤需要特別處理
Sentry.capture_exception(error, level: :warning)
render json: {
error: {
type: 'payment_failed',
message: '付款處理失敗',
details: {
reason: error.user_message, # 不洩漏技術細節
support_ticket_id: create_support_ticket(error)
}
}
}, status: :payment_required
end
def create_support_ticket(error)
# 自動建立客服工單
SupportTicket.create!(
user: current_user,
category: 'payment_issue',
description: error.technical_message,
priority: 'high'
).ticket_number
end
end
end
end
完整的錯誤處理需要完整的測試覆蓋:
# spec/controllers/api/v1/enrollments_controller_spec.rb
RSpec.describe Api::V1::EnrollmentsController, type: :request do
let(:user) { create(:user) }
let(:course) { create(:course) }
let(:headers) { auth_headers(user) }
describe 'POST /api/v1/courses/:course_id/enrollments' do
context '正常註冊流程' do
it '成功建立註冊記錄' do
post "/api/v1/courses/#{course.id}/enrollments", headers: headers
expect(response).to have_http_status(:created)
expect(json_response['enrollment']['status']).to eq('active')
end
end
context '錯誤處理' do
context '當課程不存在' do
it '返回 404 錯誤' do
post '/api/v1/courses/999999/enrollments', headers: headers
expect(response).to have_http_status(:not_found)
expect(json_response['error']['type']).to eq('resource_not_found')
end
end
context '當課程已額滿' do
before do
# 填滿課程
create_list(:enrollment, course.max_students, course: course)
end
it '返回衝突錯誤並提供候補資訊' do
post "/api/v1/courses/#{course.id}/enrollments", headers: headers
expect(response).to have_http_status(:conflict)
expect(json_response['error']['type']).to eq('course_full')
expect(json_response['error']['details']).to include('waitlist_available')
end
end
context '當付款失敗' do
before do
allow_any_instance_of(PaymentService)
.to receive(:charge_for_course)
.and_raise(PaymentService::PaymentError.new('Card declined'))
end
it '返回付款錯誤但仍建立待付款註冊' do
post "/api/v1/courses/#{course.id}/enrollments", headers: headers
expect(response).to have_http_status(:payment_required)
expect(json_response['error']['type']).to eq('payment_failed')
# 確認註冊記錄已建立但狀態為待付款
enrollment = user.enrollments.last
expect(enrollment).to be_present
expect(enrollment.status).to eq('pending_payment')
end
it '記錄錯誤到監控系統' do
expect(Sentry).to receive(:capture_exception).once
post "/api/v1/courses/#{course.id}/enrollments", headers: headers
end
end
context '併發註冊處理' do
it '正確處理競爭條件' do
# 模擬最後一個名額的競爭
create_list(:enrollment, course.max_students - 1, course: course)
# 使用執行緒模擬併發請求
results = []
threads = 2.times.map do
Thread.new do
post "/api/v1/courses/#{course.id}/enrollments", headers: headers
results << response.status
end
end
threads.each(&:join)
# 應該有一個成功,一個失敗
expect(results).to include(201) # created
expect(results).to include(409) # conflict
end
end
end
end
end
誤區一:過度捕捉例外
來自 Java 背景的開發者可能習慣捕捉所有例外:
# 錯誤示範:過度防禦
def process_data
begin
# 所有程式碼都包在 begin-rescue 中
user = User.find(params[:id])
course = Course.find(params[:course_id])
# ... 更多邏輯
rescue => e
# 捕捉所有錯誤,掩蓋了真正的問題
render json: { error: '發生錯誤' }
end
end
# 正確做法:讓框架處理預期的錯誤
def process_data
user = User.find(params[:id]) # 讓 Rails 處理 RecordNotFound
course = Course.find(params[:course_id])
# 只捕捉特定的業務邏輯錯誤
validate_enrollment!(user, course)
rescue BusinessErrors::EnrollmentError => e
# 針對性處理
render_business_error(e)
end
誤區二:洩漏技術細節
來自 Node.js 的開發者可能習慣直接返回錯誤物件:
# 錯誤示範:洩漏內部實作
rescue ActiveRecord::StatementInvalid => e
render json: {
error: e.message, # "PG::UniqueViolation: ERROR: duplicate key value..."
backtrace: e.backtrace # 整個堆疊追蹤
}
# 正確做法:提供有用但安全的錯誤訊息
rescue ActiveRecord::StatementInvalid => e
# 記錄詳細錯誤供內部除錯
Rails.logger.error "Database error: #{e.message}"
Sentry.capture_exception(e)
# 返回友善的錯誤訊息
render json: {
error: {
message: '資料處理失敗,請稍後再試',
support_id: SecureRandom.uuid # 用於追蹤
}
}, status: :internal_server_error
錯誤處理不應該成為效能瓶頸:
# 效能優化的錯誤處理
class OptimizedErrorHandler
# 使用類別變數快取常用的錯誤回應
@@error_templates = {}
def self.render_error(type, details = {})
# 快取錯誤模板避免重複產生
@@error_templates[type] ||= load_error_template(type)
# 合併動態資料
@@error_templates[type].merge(details)
end
# 避免在錯誤處理中做昂貴的操作
def self.log_error(exception, context)
# 使用背景任務處理非關鍵的錯誤記錄
ErrorLoggingJob.perform_later(
error_class: exception.class.name,
message: exception.message,
context: context,
backtrace: exception.backtrace&.first(10) # 限制堆疊大小
)
end
end
練習目標
這個練習將幫助你理解 Rails 的錯誤處理機制,並建立一個可重用的錯誤處理系統。你將學習如何組織錯誤類別、統一錯誤格式,以及追蹤請求。
詳細步驟說明
建立錯誤類別階層
首先,我們需要定義清晰的錯誤分類,這樣才能針對不同類型的錯誤採取適當的處理策略。
實作統一的錯誤序列化器
錯誤序列化器確保所有錯誤都以一致的格式返回,這對前端開發者來說非常重要。
設定 rescue_from 處理器
使用 Rails 的 rescue_from 機制,我們可以優雅地捕捉和處理各種錯誤。
加入請求 ID 追蹤
請求 ID 是追蹤錯誤的關鍵,它能幫助我們在日誌中找到完整的請求流程。
完整解答
# Step 1: 建立錯誤類別階層
# app/errors/application_error.rb
module ApplicationError
# 基礎錯誤類別,所有自訂錯誤都繼承自此
class BaseError < StandardError
attr_reader :status, :code, :details
def initialize(message = nil, status: :internal_server_error, code: nil, details: {})
@status = status
@code = code || self.class.name.demodulize.underscore
@details = details
super(message || default_message)
end
private
def default_message
I18n.t("errors.#{@code}", default: '發生錯誤')
end
end
# 認證相關錯誤
class AuthenticationError < BaseError
def initialize(message = '需要登入')
super(message, status: :unauthorized, code: 'authentication_required')
end
end
# 授權相關錯誤
class AuthorizationError < BaseError
def initialize(message = '權限不足')
super(message, status: :forbidden, code: 'permission_denied')
end
end
# 資源不存在錯誤
class ResourceNotFoundError < BaseError
def initialize(resource_type, resource_id)
super(
"找不到指定的#{resource_type}",
status: :not_found,
code: 'resource_not_found',
details: { resource_type: resource_type, resource_id: resource_id }
)
end
end
# 驗證錯誤
class ValidationError < BaseError
def initialize(errors)
super(
'資料驗證失敗',
status: :unprocessable_entity,
code: 'validation_failed',
details: { errors: format_errors(errors) }
)
end
private
def format_errors(errors)
if errors.respond_to?(:full_messages)
errors.full_messages
else
Array(errors)
end
end
end
end
# Step 2: 實作統一的錯誤序列化器
# app/serializers/error_serializer.rb
class ErrorSerializer
def initialize(error, request_id = nil)
@error = error
@request_id = request_id || Current.request_id
end
def to_json
{
error: {
type: error_type,
message: error_message,
code: error_code,
status: status_code,
details: error_details,
timestamp: Time.current.iso8601,
request_id: @request_id
}
}
end
private
def error_type
case @error
when ApplicationError::BaseError
@error.code
when ActiveRecord::RecordNotFound
'record_not_found'
when ActionController::ParameterMissing
'parameter_missing'
else
'internal_error'
end
end
def error_message
case @error
when ApplicationError::BaseError
@error.message
when ActiveRecord::RecordNotFound
'找不到指定的資源'
when ActionController::ParameterMissing
"缺少必要參數:#{@error.param}"
else
Rails.env.production? ? '系統錯誤' : @error.message
end
end
def error_code
@error.respond_to?(:code) ? @error.code : error_type
end
def status_code
if @error.respond_to?(:status)
Rack::Utils::SYMBOL_TO_STATUS_CODE[@error.status]
else
500
end
end
def error_details
if @error.respond_to?(:details)
@error.details
else
{}
end
end
end
# Step 3: 設定 rescue_from 處理器
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
# 設定請求 ID
before_action :set_request_id
# 錯誤處理器(從具體到通用的順序很重要)
rescue_from ApplicationError::BaseError, with: :handle_application_error
rescue_from ActiveRecord::RecordNotFound, with: :handle_not_found
rescue_from ActiveRecord::RecordInvalid, with: :handle_validation_error
rescue_from ActionController::ParameterMissing, with: :handle_parameter_missing
rescue_from StandardError, with: :handle_standard_error
private
# Step 4: 加入請求 ID 追蹤
def set_request_id
# 使用 HTTP header 中的請求 ID,或生成新的
Current.request_id = request.headers['X-Request-Id'] || SecureRandom.uuid
# 將請求 ID 加入回應 header
response.headers['X-Request-Id'] = Current.request_id
end
def handle_application_error(error)
log_error(error)
render_error(error, error.status)
end
def handle_not_found(error)
log_error(error, level: :info)
render_error(error, :not_found)
end
def handle_validation_error(error)
validation_error = ApplicationError::ValidationError.new(error.record.errors)
log_error(validation_error, level: :info)
render_error(validation_error, :unprocessable_entity)
end
def handle_parameter_missing(error)
log_error(error, level: :warn)
render_error(error, :bad_request)
end
def handle_standard_error(error)
log_error(error, level: :error)
# 在生產環境發送到錯誤追蹤服務
if Rails.env.production?
Sentry.capture_exception(error) if defined?(Sentry)
end
render_error(error, :internal_server_error)
end
def render_error(error, status)
serializer = ErrorSerializer.new(error)
render json: serializer.to_json, status: status
end
def log_error(error, level: :error)
logger.public_send(level) do
"Error: #{error.class} - #{error.message}\n" \
"Request ID: #{Current.request_id}\n" \
"Backtrace:\n#{error.backtrace&.first(5)&.join("\n")}"
end
end
end
# 建立 Current 類別來儲存請求範圍的資料
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
attribute :request_id
attribute :user
attribute :api_version
end
測試你的實作
建立測試檔案來驗證錯誤處理是否正常運作:
# spec/requests/error_handling_spec.rb
require 'rails_helper'
RSpec.describe 'Error Handling', type: :request do
describe 'GET /api/v1/not_existing_endpoint' do
it '返回 404 錯誤與正確格式' do
get '/api/v1/not_existing_endpoint'
expect(response).to have_http_status(:not_found)
json = JSON.parse(response.body)
expect(json['error']).to include(
'type' => 'routing_error',
'status' => 404,
'request_id' => be_present,
'timestamp' => be_present
)
end
end
describe 'POST /api/v1/courses without required params' do
it '返回 400 錯誤與缺少的參數資訊' do
post '/api/v1/courses', params: {}
expect(response).to have_http_status(:bad_request)
json = JSON.parse(response.body)
expect(json['error']['type']).to eq('parameter_missing')
expect(json['error']['message']).to include('缺少必要參數')
end
end
describe 'Request ID tracking' do
it '在回應中包含請求 ID' do
get '/api/v1/courses'
expect(response.headers['X-Request-Id']).to be_present
end
it '使用客戶端提供的請求 ID' do
request_id = SecureRandom.uuid
get '/api/v1/courses', headers: { 'X-Request-Id' => request_id }
expect(response.headers['X-Request-Id']).to eq(request_id)
end
end
end
驗證方式
執行以下命令來測試你的錯誤處理系統:
# 測試 404 錯誤
curl -i http://localhost:3000/api/v1/courses/999999
# 測試 400 錯誤(缺少參數)
curl -i -X POST http://localhost:3000/api/v1/enrollments \
-H "Content-Type: application/json" \
-d '{}'
# 測試 401 錯誤(未認證)
curl -i http://localhost:3000/api/v1/admin/users
# 測試請求 ID 追蹤
curl -i -H "X-Request-Id: test-123" http://localhost:3000/api/v1/courses
挑戰目標
斷路器模式是微服務架構中的重要模式,它能防止故障的連鎖反應。在這個挑戰中,你將為 LMS 的影片轉碼服務實作一個完整的斷路器,學習如何保護系統免受外部服務故障的影響。
詳細實作指引
斷路器有三種狀態:
完整解答
# app/services/circuit_breaker_v2.rb
class CircuitBreakerV2
# 斷路器的狀態機實作
class State
attr_reader :circuit_breaker
def initialize(circuit_breaker)
@circuit_breaker = circuit_breaker
end
def call(&block)
raise NotImplementedError
end
def on_success
# 子類別實作
end
def on_failure
# 子類別實作
end
end
# 關閉狀態:正常運作
class ClosedState < State
def call(&block)
begin
result = block.call
on_success
result
rescue => e
on_failure
raise
end
end
def on_success
circuit_breaker.reset_failure_count
end
def on_failure
circuit_breaker.record_failure
if circuit_breaker.threshold_reached?
circuit_breaker.trip_breaker
end
end
end
# 開啟狀態:快速失敗
class OpenState < State
def call(&block)
if circuit_breaker.timeout_expired?
circuit_breaker.attempt_reset
circuit_breaker.state.call(&block)
else
raise CircuitOpenError.new(
"斷路器開啟中",
service: circuit_breaker.name,
retry_after: circuit_breaker.time_until_retry
)
end
end
end
# 半開狀態:測試恢復
class HalfOpenState < State
def call(&block)
begin
result = block.call
on_success
result
rescue => e
on_failure
raise
end
end
def on_success
circuit_breaker.reset
end
def on_failure
circuit_breaker.trip_breaker
end
end
# 自訂錯誤類別
class CircuitOpenError < StandardError
attr_reader :service, :retry_after
def initialize(message, service:, retry_after:)
@service = service
@retry_after = retry_after
super(message)
end
end
attr_reader :name, :failure_threshold, :success_threshold,
:timeout, :state, :metrics
def initialize(name, options = {})
@name = name
@failure_threshold = options[:failure_threshold] || 5
@success_threshold = options[:success_threshold] || 2
@timeout = options[:timeout] || 60
@failure_count = 0
@success_count = 0
@last_failure_time = nil
@state = ClosedState.new(self)
# 監控指標
@metrics = {
total_calls: 0,
successful_calls: 0,
failed_calls: 0,
rejected_calls: 0,
state_changes: []
}
# 使用 Redis 儲存狀態(支援分散式系統)
@redis = Redis.new
@state_key = "circuit_breaker:#{name}:state"
@metrics_key = "circuit_breaker:#{name}:metrics"
load_state
end
def call(&block)
@metrics[:total_calls] += 1
begin
@state.call(&block)
rescue CircuitOpenError => e
@metrics[:rejected_calls] += 1
# 執行降級策略
if block_given?
fallback_result = yield_fallback
return fallback_result if fallback_result
end
raise
end
end
def reset_failure_count
@failure_count = 0
@success_count += 1
@metrics[:successful_calls] += 1
save_state
end
def record_failure
@failure_count += 1
@success_count = 0
@last_failure_time = Time.current
@metrics[:failed_calls] += 1
save_state
end
def threshold_reached?
@failure_count >= @failure_threshold
end
def timeout_expired?
return false unless @last_failure_time
Time.current - @last_failure_time >= @timeout
end
def time_until_retry
return 0 unless @last_failure_time
[@timeout - (Time.current - @last_failure_time), 0].max
end
def trip_breaker
transition_to(OpenState.new(self))
@last_failure_time = Time.current
# 發送告警
notify_state_change(:open)
end
def attempt_reset
transition_to(HalfOpenState.new(self))
notify_state_change(:half_open)
end
def reset
@failure_count = 0
@success_count = 0
@last_failure_time = nil
transition_to(ClosedState.new(self))
notify_state_change(:closed)
end
# 監控介面
def status
{
name: @name,
state: state_name,
failure_count: @failure_count,
success_count: @success_count,
last_failure: @last_failure_time&.iso8601,
time_until_retry: time_until_retry,
metrics: @metrics
}
end
private
def state_name
@state.class.name.demodulize.underscore.gsub('_state', '')
end
def transition_to(new_state)
old_state = state_name
@state = new_state
@metrics[:state_changes] << {
from: old_state,
to: state_name,
timestamp: Time.current.iso8601
}
Rails.logger.info "斷路器 #{@name} 狀態轉換:#{old_state} -> #{state_name}"
save_state
end
def save_state
state_data = {
state: state_name,
failure_count: @failure_count,
success_count: @success_count,
last_failure_time: @last_failure_time&.to_i
}
@redis.set(@state_key, state_data.to_json, ex: 3600)
@redis.set(@metrics_key, @metrics.to_json, ex: 3600)
end
def load_state
state_json = @redis.get(@state_key)
return unless state_json
state_data = JSON.parse(state_json)
@failure_count = state_data['failure_count']
@success_count = state_data['success_count']
@last_failure_time = Time.at(state_data['last_failure_time']) if state_data['last_failure_time']
@state = case state_data['state']
when 'open' then OpenState.new(self)
when 'half_open' then HalfOpenState.new(self)
else ClosedState.new(self)
end
metrics_json = @redis.get(@metrics_key)
@metrics = JSON.parse(metrics_json).symbolize_keys if metrics_json
end
def notify_state_change(new_state)
# 發送通知到監控系統
CircuitBreakerNotificationJob.perform_later(
name: @name,
state: new_state,
metrics: @metrics
)
end
def yield_fallback
# 子類別可以覆寫此方法提供降級策略
nil
end
end
# 影片處理服務的斷路器實作
# app/services/video_processing_circuit_breaker.rb
class VideoProcessingCircuitBreaker < CircuitBreakerV2
def initialize
super('video_processing',
failure_threshold: 3,
success_threshold: 2,
timeout: 30
)
end
def process_video(video_file, options = {})
call do
# 實際的影片處理邏輯
VideoProcessingAPI.new.process(video_file, options)
end
rescue CircuitOpenError => e
# 降級策略:返回預設處理狀態
handle_circuit_open(video_file, e)
end
private
def handle_circuit_open(video_file, error)
Rails.logger.warn "影片處理服務斷路器開啟,使用降級策略"
# 將任務加入延遲佇列
VideoProcessingRetryJob.set(wait: error.retry_after.seconds)
.perform_later(video_file.id)
# 返回降級回應
{
status: 'queued',
message: '影片處理服務暫時繁忙,已加入處理佇列',
estimated_time: error.retry_after + 60,
job_id: SecureRandom.uuid
}
end
end
# 使用斷路器的控制器
# app/controllers/api/v1/video_uploads_controller.rb
class Api::V1::VideoUploadsController < ApplicationController
def create
video = current_user.videos.build(video_params)
if video.save
# 使用斷路器保護的影片處理
processing_result = video_processing_service.process_video(
video.file,
quality: params[:quality] || 'auto'
)
video.update!(
processing_status: processing_result[:status],
processing_job_id: processing_result[:job_id]
)
render json: {
video: VideoSerializer.new(video),
processing: processing_result
}, status: :created
else
render_validation_errors(video.errors)
end
end
private
def video_processing_service
@video_processing_service ||= VideoProcessingCircuitBreaker.new
end
def video_params
params.require(:video).permit(:title, :description, :file)
end
end
# 監控儀表板
# app/controllers/admin/circuit_breakers_controller.rb
class Admin::CircuitBreakersController < Admin::BaseController
def index
@circuit_breakers = [
VideoProcessingCircuitBreaker.new,
PaymentGatewayCircuitBreaker.new,
EmailServiceCircuitBreaker.new
].map(&:status)
render json: {
circuit_breakers: @circuit_breakers,
summary: calculate_summary(@circuit_breakers)
}
end
def reset
breaker_name = params[:name]
breaker = find_circuit_breaker(breaker_name)
if breaker
breaker.reset
render json: { message: "斷路器 #{breaker_name} 已重置" }
else
render json: { error: '找不到指定的斷路器' }, status: :not_found
end
end
private
def find_circuit_breaker(name)
case name
when 'video_processing'
VideoProcessingCircuitBreaker.new
when 'payment_gateway'
PaymentGatewayCircuitBreaker.new
when 'email_service'
EmailServiceCircuitBreaker.new
end
end
def calculate_summary(breakers)
{
total: breakers.count,
open: breakers.count { |b| b[:state] == 'open' },
half_open: breakers.count { |b| b[:state] == 'half_open' },
closed: breakers.count { |b| b[:state] == 'closed' },
total_calls: breakers.sum { |b| b[:metrics][:total_calls] },
total_failures: breakers.sum { |b| b[:metrics][:failed_calls] },
failure_rate: calculate_failure_rate(breakers)
}
end
def calculate_failure_rate(breakers)
total_calls = breakers.sum { |b| b[:metrics][:total_calls] }
total_failures = breakers.sum { |b| b[:metrics][:failed_calls] }
return 0 if total_calls.zero?
((total_failures.to_f / total_calls) * 100).round(2)
end
end
測試案例
# spec/services/circuit_breaker_v2_spec.rb
require 'rails_helper'
RSpec.describe CircuitBreakerV2 do
let(:breaker) { described_class.new('test_service', failure_threshold: 2, timeout: 1) }
describe '狀態轉換' do
context '從 Closed 到 Open' do
it '在達到失敗閾值後開啟' do
# 第一次失敗
expect { breaker.call { raise 'Error' } }.to raise_error('Error')
expect(breaker.status[:state]).to eq('closed')
# 第二次失敗,達到閾值
expect { breaker.call { raise 'Error' } }.to raise_error('Error')
expect(breaker.status[:state]).to eq('open')
end
end
context '從 Open 到 Half-Open' do
before do
# 觸發斷路器開啟
2.times { breaker.call { raise 'Error' } rescue nil }
end
it '在超時後轉為半開狀態' do
expect(breaker.status[:state]).to eq('open')
# 等待超時
sleep(1.1)
# 下次呼叫應該進入半開狀態
expect { breaker.call { 'success' } }.not_to raise_error
expect(breaker.status[:state]).to eq('closed')
end
end
context '從 Half-Open 到 Closed' do
before do
# 設定為半開狀態
2.times { breaker.call { raise 'Error' } rescue nil }
sleep(1.1)
end
it '成功後恢復為關閉狀態' do
result = breaker.call { 'success' }
expect(result).to eq('success')
expect(breaker.status[:state]).to eq('closed')
end
end
context '從 Half-Open 回到 Open' do
before do
# 設定為半開狀態
2.times { breaker.call { raise 'Error' } rescue nil }
sleep(1.1)
end
it '失敗後回到開啟狀態' do
expect { breaker.call { raise 'Error' } }.to raise_error('Error')
expect(breaker.status[:state]).to eq('open')
end
end
end
describe '降級策略' do
let(:video_breaker) { VideoProcessingCircuitBreaker.new }
let(:video_file) { create(:video_file) }
it '在斷路器開啟時返回降級回應' do
# 觸發斷路器
3.times do
video_breaker.process_video(video_file) rescue nil
end
# 應該返回降級回應而非拋出錯誤
result = video_breaker.process_video(video_file)
expect(result[:status]).to eq('queued')
expect(result[:message]).to include('暫時繁忙')
expect(result[:job_id]).to be_present
end
end
describe '監控指標' do
it '正確記錄各種指標' do
# 成功呼叫
breaker.call { 'success' }
# 失敗呼叫
breaker.call { raise 'Error' } rescue nil
metrics = breaker.status[:metrics]
expect(metrics[:total_calls]).to eq(2)
expect(metrics[:successful_calls]).to eq(1)
expect(metrics[:failed_calls]).to eq(1)
expect(metrics[:rejected_calls]).to eq(0)
end
end
end
評估標準
你的斷路器實作應該滿足以下標準:
正確的狀態轉換
合理的閾值設定
優雅的降級處理
完整的測試覆蓋
通過這兩個練習,你應該已經掌握了 Rails 錯誤處理的核心概念和進階模式。記住,好的錯誤處理不只是技術問題,更是使用者體驗的重要組成部分。
今天我們深入探討了 Rails 的錯誤處理哲學,從基礎的 rescue_from 到進階的斷路器模式。我們學到了:
知識層面:
思維層面:
實踐層面:
完成今天的學習後,你應該能夠:
深入閱讀:
相關 Gem:
sentry-rails
:完整的錯誤追蹤解決方案rollbar
:另一個優秀的錯誤監控服務circuit_breaker
:現成的斷路器實作明天我們將探討測試驅動開發(TDD)與 RSpec 實踐。如果說今天學習的是如何優雅地處理失敗,那明天就是如何預防失敗的發生。我們會深入 RSpec 的 DSL,學習如何寫出既是規格說明又是可執行測試的程式碼。準備好了嗎?讓測試成為你的設計工具,而不只是驗證工具。