Skip to content

Commit 820d208

Browse files
etewiahclaude
andcommitted
Add structured error handling framework
Create custom error class hierarchy for better error handling: - ApplicationError base class with structured logging support - TenantErrors for multi-tenancy (TenantNotFoundError, etc.) - ExternalServiceErrors for API integrations - ImportExportErrors for data operations - SubscriptionErrors for payment/billing Add ErrorHandling controller concern that: - Integrates with existing StructuredLogger - Provides rescue handlers for custom errors - Includes helper methods (log_rescued_exception, log_and_continue) Update api_manage BaseController with structured logging for standard rescue handlers (RecordNotFound, RecordInvalid, etc.) Add comprehensive documentation in docs/architecture/error-handling.md with migration guide for updating existing rescue blocks. Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com>
1 parent 7323c71 commit 820d208

File tree

8 files changed

+820
-3
lines changed

8 files changed

+820
-3
lines changed

app/controllers/api_manage/v1/base_controller.rb

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ module V1
1515
class BaseController < ActionController::Base
1616
include SubdomainTenant
1717
include ActiveStorage::SetCurrent
18+
include ErrorHandling
1819

1920
skip_before_action :verify_authenticity_token
2021

@@ -23,15 +24,18 @@ class BaseController < ActionController::Base
2324

2425
# JSON API responses
2526
rescue_from ActiveRecord::RecordNotFound do |e|
26-
render json: { error: 'Not found', message: e.message }, status: :not_found
27+
StructuredLogger.warn("[API] Record not found", path: request.path, error: e.message)
28+
render json: { success: false, error: 'Not found', message: e.message }, status: :not_found
2729
end
2830

2931
rescue_from ActiveRecord::RecordInvalid do |e|
30-
render json: { error: 'Validation failed', errors: e.record.errors.full_messages }, status: :unprocessable_entity
32+
StructuredLogger.info("[API] Validation failed", path: request.path, errors: e.record.errors.to_hash)
33+
render json: { success: false, error: 'Validation failed', errors: e.record.errors.full_messages }, status: :unprocessable_entity
3134
end
3235

3336
rescue_from ActionController::ParameterMissing do |e|
34-
render json: { error: 'Bad request', message: e.message }, status: :bad_request
37+
StructuredLogger.warn("[API] Parameter missing", path: request.path, param: e.param.to_s)
38+
render json: { success: false, error: 'Bad request', message: e.message }, status: :bad_request
3539
end
3640

3741
private
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# frozen_string_literal: true
2+
3+
# ErrorHandling Concern
4+
#
5+
# Provides structured error logging and rescue handlers for controllers.
6+
# Integrates with StructuredLogger for consistent JSON logging and Sentry.
7+
#
8+
# Include in controllers that need consistent error handling.
9+
#
10+
# Usage:
11+
# class MyController < ApplicationController
12+
# include ErrorHandling
13+
# end
14+
#
15+
# The concern provides:
16+
# - Structured logging via StructuredLogger (JSON format, Sentry integration)
17+
# - Consistent API error responses
18+
# - Rescue handlers for common error types
19+
# - Helper methods for logging caught exceptions
20+
#
21+
module ErrorHandling
22+
extend ActiveSupport::Concern
23+
24+
included do
25+
# Rescue application errors with proper logging and response
26+
rescue_from ApplicationError do |error|
27+
log_application_error(error)
28+
render_error_response(error)
29+
end
30+
31+
# Rescue external service errors
32+
rescue_from ExternalServiceError do |error|
33+
log_external_service_error(error)
34+
render_error_response(error)
35+
end
36+
37+
# Rescue tenant errors
38+
rescue_from TenantNotFoundError, TenantMismatchError, TenantContextRequiredError do |error|
39+
log_application_error(error, level: :warn)
40+
render_error_response(error)
41+
end
42+
43+
# Rescue subscription errors
44+
rescue_from SubscriptionError, FeatureNotAvailableError do |error|
45+
log_application_error(error, level: :info)
46+
render_error_response(error)
47+
end
48+
end
49+
50+
private
51+
52+
# Log an ApplicationError with structured context
53+
#
54+
# @param error [ApplicationError] The error to log
55+
# @param level [Symbol] Log level (:info, :warn, :error)
56+
def log_application_error(error, level: :warn)
57+
context = error_base_context.merge(error.respond_to?(:to_log_hash) ? error.to_log_hash : {})
58+
59+
case level
60+
when :info
61+
StructuredLogger.info("[#{error.class.name}] #{error.message}", **context)
62+
when :warn
63+
StructuredLogger.warn("[#{error.class.name}] #{error.message}", **context)
64+
else
65+
StructuredLogger.error("[#{error.class.name}] #{error.message}", **context)
66+
end
67+
end
68+
69+
# Log an external service error with full context
70+
#
71+
# @param error [ExternalServiceError] The error to log
72+
def log_external_service_error(error)
73+
context = error_base_context.merge(
74+
service_name: error.service_name,
75+
original_error: error.original_error&.message
76+
).compact
77+
78+
StructuredLogger.error("[ExternalService] #{error.message}", **context)
79+
end
80+
81+
# Log a rescued exception with full context
82+
# Use this in rescue blocks for proper error tracking
83+
#
84+
# @param error [Exception] The rescued exception
85+
# @param context_message [String] Description of what was being attempted
86+
# @param extra_context [Hash] Additional context to include
87+
#
88+
# @example
89+
# begin
90+
# process_payment
91+
# rescue Stripe::CardError => e
92+
# log_rescued_exception(e, context_message: "Processing payment", order_id: order.id)
93+
# # handle gracefully...
94+
# end
95+
def log_rescued_exception(error, context_message: nil, **extra_context)
96+
context = error_base_context.merge(extra_context)
97+
message = context_message || "Rescued exception"
98+
99+
StructuredLogger.exception(error, "[#{controller_name}##{action_name}] #{message}", **context)
100+
end
101+
102+
# Log an error without raising (for graceful degradation)
103+
# Use when you want to catch an error, log it, but continue execution
104+
#
105+
# @param error [Exception] The error that occurred
106+
# @param fallback_value [Object] What to return instead
107+
# @param context_message [String] What was being attempted
108+
#
109+
# @example
110+
# def fetch_optional_data
111+
# SomeService.call
112+
# rescue SomeError => e
113+
# log_and_continue(e, fallback_value: [], context_message: "Fetching optional data")
114+
# end
115+
def log_and_continue(error, fallback_value: nil, context_message: nil, **extra_context)
116+
log_rescued_exception(error, context_message: context_message, **extra_context)
117+
fallback_value
118+
end
119+
120+
# Base context included in all error logs
121+
def error_base_context
122+
{
123+
controller: controller_name,
124+
action: action_name,
125+
path: request.path,
126+
method: request.method,
127+
user_id: respond_to?(:current_user, true) ? current_user&.id : nil,
128+
website_id: respond_to?(:current_website, true) ? current_website&.id : nil
129+
}.compact
130+
end
131+
132+
# Render a JSON error response for ApplicationError subclasses
133+
def render_error_response(error)
134+
status = error.respond_to?(:http_status) ? error.http_status : :internal_server_error
135+
136+
if request.format.json? || request.content_type&.include?('json')
137+
render json: error_to_json(error), status: status
138+
else
139+
# For HTML requests, set flash and redirect or render error page
140+
flash[:error] = error.message
141+
if request.referer.present?
142+
redirect_back(fallback_location: root_path)
143+
else
144+
render plain: error.message, status: status
145+
end
146+
end
147+
end
148+
149+
# Convert error to JSON response format
150+
def error_to_json(error)
151+
response = { success: false }
152+
153+
if error.respond_to?(:to_api_response)
154+
response.merge(error.to_api_response)
155+
else
156+
response.merge(
157+
error: error.class.name.demodulize.underscore.upcase,
158+
message: error.message
159+
)
160+
end
161+
end
162+
end

app/errors/application_error.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# frozen_string_literal: true
2+
3+
# Base error class for all application-specific errors
4+
#
5+
# Provides structured error information for logging and API responses.
6+
# All domain-specific errors should inherit from this class.
7+
#
8+
# Usage:
9+
# raise ApplicationError.new("Something went wrong", code: "GENERIC_ERROR")
10+
# raise TenantNotFoundError.new("Website not found")
11+
#
12+
# Logging:
13+
# rescue ApplicationError => e
14+
# Rails.logger.error(e.to_log_hash)
15+
#
16+
class ApplicationError < StandardError
17+
attr_reader :code, :details, :http_status
18+
19+
# @param message [String] Human-readable error message
20+
# @param code [String, nil] Machine-readable error code (defaults to class name)
21+
# @param details [Hash] Additional context for debugging
22+
# @param http_status [Symbol] HTTP status code for API responses (default: :internal_server_error)
23+
def initialize(message = nil, code: nil, details: {}, http_status: :internal_server_error)
24+
@code = code || self.class.name.demodulize.underscore.upcase
25+
@details = details
26+
@http_status = http_status
27+
super(message || default_message)
28+
end
29+
30+
# Returns a hash suitable for structured logging
31+
# @return [Hash]
32+
def to_log_hash
33+
{
34+
error_class: self.class.name,
35+
error_code: code,
36+
message: message,
37+
details: details.presence
38+
}.compact
39+
end
40+
41+
# Returns a hash suitable for JSON API responses
42+
# @return [Hash]
43+
def to_api_response
44+
{
45+
error: code,
46+
message: message,
47+
details: details.presence
48+
}.compact
49+
end
50+
51+
private
52+
53+
def default_message
54+
"An error occurred"
55+
end
56+
end
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# frozen_string_literal: true
2+
3+
# External service related errors
4+
#
5+
# These errors are raised when external API calls fail.
6+
#
7+
8+
# Base class for external service errors
9+
class ExternalServiceError < ApplicationError
10+
attr_reader :service_name, :original_error
11+
12+
def initialize(message = nil, service_name: nil, original_error: nil, details: {})
13+
@service_name = service_name
14+
@original_error = original_error
15+
16+
details[:service] = service_name if service_name
17+
details[:original_error] = original_error&.message if original_error
18+
19+
super(
20+
message || "External service error",
21+
code: "EXTERNAL_SERVICE_ERROR",
22+
details: details,
23+
http_status: :bad_gateway
24+
)
25+
end
26+
27+
def to_log_hash
28+
super.merge(
29+
service_name: service_name,
30+
original_error_class: original_error&.class&.name,
31+
original_error_message: original_error&.message
32+
).compact
33+
end
34+
end
35+
36+
# Raised when an external API times out
37+
class ExternalServiceTimeoutError < ExternalServiceError
38+
def initialize(message = nil, service_name: nil, timeout_seconds: nil, details: {})
39+
details[:timeout_seconds] = timeout_seconds if timeout_seconds
40+
super(
41+
message || "External service request timed out",
42+
service_name: service_name,
43+
details: details
44+
)
45+
@code = "EXTERNAL_SERVICE_TIMEOUT"
46+
@http_status = :gateway_timeout
47+
end
48+
end
49+
50+
# Raised when an external API returns an error response
51+
class ExternalServiceApiError < ExternalServiceError
52+
attr_reader :status_code, :response_body
53+
54+
def initialize(message = nil, service_name: nil, status_code: nil, response_body: nil, details: {})
55+
@status_code = status_code
56+
@response_body = response_body
57+
58+
details[:status_code] = status_code if status_code
59+
details[:response_body] = truncate_response(response_body) if response_body
60+
61+
super(
62+
message || "External service returned an error",
63+
service_name: service_name,
64+
details: details
65+
)
66+
@code = "EXTERNAL_SERVICE_API_ERROR"
67+
end
68+
69+
private
70+
71+
def truncate_response(body)
72+
return body if body.nil? || body.length <= 500
73+
"#{body[0..500]}... (truncated)"
74+
end
75+
end
76+
77+
# Raised when an external service rate limits our requests
78+
class ExternalServiceRateLimitError < ExternalServiceError
79+
attr_reader :retry_after
80+
81+
def initialize(message = nil, service_name: nil, retry_after: nil, details: {})
82+
@retry_after = retry_after
83+
details[:retry_after] = retry_after if retry_after
84+
85+
super(
86+
message || "External service rate limit exceeded",
87+
service_name: service_name,
88+
details: details
89+
)
90+
@code = "EXTERNAL_SERVICE_RATE_LIMITED"
91+
@http_status = :too_many_requests
92+
end
93+
end
94+
95+
# Raised when external service credentials are missing or invalid
96+
class ExternalServiceConfigurationError < ExternalServiceError
97+
def initialize(message = nil, service_name: nil, missing_config: nil, details: {})
98+
details[:missing_config] = missing_config if missing_config
99+
100+
super(
101+
message || "External service not configured",
102+
service_name: service_name,
103+
details: details
104+
)
105+
@code = "EXTERNAL_SERVICE_NOT_CONFIGURED"
106+
@http_status = :service_unavailable
107+
end
108+
end

0 commit comments

Comments
 (0)