Module: Controllers::ErrorRendering

Extended by:
ActiveSupport::Concern
Included in:
ApplicationController
Defined in:
app/concerns/controllers/error_rendering.rb

Overview

Unified error rendering for HTTP error responses

Provides consistent error pages across CRM and WWW with proper:

  • Error reporting to AppSignal with appropriate severity
  • Format-aware responses (HTML, JSON, Turbo Stream, etc.)
  • Cache busting for error pages

Error Severity Mapping:

  • 500 errors → web namespace (critical)
  • CSRF/IP Spoof → web_warning namespace (warning)
  • 404 errors → web_info namespace (informational)

Constant Summary collapse

NON_CONTENT_PATH_PREFIXES =

Convert a 404 URL path to a Swiftype search query, but only for content-like paths.
Application routes (accounts, cart, sessions, etc.) are never real content pages —
auto-searching Swiftype for them produces noise and pollutes search analytics.

%w[
  accounts customer_sessions my_cart my_account my_quotes my_projects
  my_rooms my_orders my_contacts my_addresses preset_jobs globals
  api ahoy
].freeze

Instance Method Summary collapse

Instance Method Details

#excp_string(exception, options) ⇒ Object

Build exception string for error reporting emails



255
256
257
258
259
260
261
262
263
264
265
266
267
# File 'app/concerns/controllers/error_rendering.rb', line 255

def excp_string(exception, options)
  str = []
  str << "Bug report url: #{options[:bug_url]}" if options[:bug_url]
  str << "Request: #{request&.request_method} #{request&.original_url}"
  str << "Request UUID: #{request&.uuid}"
  str << "Exception: #{exception&.to_s&.truncate(1000)}"
  if request&.request_parameters.present?
    str << 'Params:'
    request&.request_parameters&.each { |k, v| str << "#{k}: #{v}" }
  end
  str << '------ Put your comments below ------'
  str
end

#mail_to_for_error_reporting(exception, options = {}) ⇒ Object

Generate mailto link for error reporting



270
271
272
273
274
275
276
277
278
279
280
# File 'app/concerns/controllers/error_rendering.rb', line 270

def mail_to_for_error_reporting(exception, options = {})
  str = excp_string(exception, options)
  link_name = options[:link_name] || 'Send details to Heatwave team'
  css_classes = options[:class] || 'btn btn-lg btn-primary'
  debug_info = str.join("\n")
  view_context.mail_to 'heatwaveteam@warmlyyours.com',
                       link_name,
                       class: css_classes,
                       subject: "[HW] #{exception&.to_s&.truncate(100)}",
                       body: debug_info
end

#render_400(_exception = nil) ⇒ Object

Render 400 Bad Request



93
94
95
96
# File 'app/concerns/controllers/error_rendering.rb', line 93

def render_400(_exception = nil)
  reset_cloudflare_cache
  head :bad_request
end

#render_404(exception = nil) ⇒ Object

Render 404 Not Found



130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
# File 'app/concerns/controllers/error_rendering.rb', line 130

def render_404(exception = nil)
  @skip_analytics = true
  @exception = exception
  reset_cloudflare_cache

  ErrorReporting.from_controller(self).informational(exception, reason: 'not_found') if exception.is_a?(ActiveRecord::RecordNotFound) || exception.is_a?(ActionController::RoutingError)

  if is_crm_request?
    @bug_url = nil
    layout_to_use = 'crm/crm'
    error_page = 'error_pages/crm/404'
  else
    layout_to_use = 'www/cms_page'
    error_page = 'error_pages/www/404'
    elements = request.path.split('/') - %w[en-US en-CA fr-CA]
    @q ||= search_query_from_path(elements)
  end

  respond_to do |format|
    format.html do
      flash[:error] = 'The requested page could not be found.'
      render error_page, status: :not_found, layout: layout_to_use
    end
    format.turbo_stream do
      flash[:error] = 'The requested resource could not be found.'
      render turbo_stream: [], status: :not_found
    end
    format.xml { head :not_found }
    format.json { render json: { not_found: '1', exception: exception.to_s } }
    format.any { head :not_found }
  end
end

#render_406(_exception = nil) ⇒ Object

Render 406 Not Acceptable (for unsupported request formats)
Not reported to AppSignal - this is expected behavior when bots/clients request unsupported formats



100
101
102
103
# File 'app/concerns/controllers/error_rendering.rb', line 100

def render_406(_exception = nil)
  reset_cloudflare_cache
  head :not_acceptable
end

#render_410(_exception = nil) ⇒ Object

Render 410 Gone — tells search engines the resource is permanently removed



106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'app/concerns/controllers/error_rendering.rb', line 106

def render_410(_exception = nil)
  @skip_analytics = true
  reset_cloudflare_cache

  respond_to do |format|
    format.html do
      if is_crm_request?
        render 'error_pages/crm/404', status: :gone, layout: 'crm/crm'
      else
        elements = request.path.split('/') - %w[en-US en-CA fr-CA]
        @q ||= search_query_from_path(elements)
        render 'error_pages/www/404', status: :gone, layout: 'www/cms_page'
      end
    end
    format.turbo_stream do
      flash[:error] = 'This page has been permanently removed.'
      render turbo_stream: [], status: :gone
    end
    format.json { render json: { gone: true }, status: :gone }
    format.any { head :gone }
  end
end

#render_500(exception = nil) ⇒ Object

Render 500 Internal Server Error



36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'app/concerns/controllers/error_rendering.rb', line 36

def render_500(exception = nil)
  ErrorReporting.from_controller(self).error(exception) if exception
  @bug_url = nil

  return if performed?

  if (@exception = exception)
    @backtrace = @exception.backtrace.select { |line| line.starts_with?("#{Rails.root.join('')}") }
    @backtrace = @backtrace.map { |line| line.sub("#{Rails.root.join('')}", '') }
  end

  if is_crm_request?
    layout_to_use = current_user ? 'crm/crm' : false
    error_page = 'error_pages/crm/500'
    js_payload = { error: "#{[exception.to_s, (view_context.link_to('Report to Heatwaveteam', mail_to_for_error_reporting(@exception, bug_url: @bug_url)) if @bug_url)].compact.join(' ')}" }
  else
    layout_to_use = false
    error_page = 'error_pages/www/500'
    js_payload = { error: exception.to_s }
  end

  reset_cloudflare_cache
  respond_to do |format|
    format.turbo_stream do
      if js_error_redirect?
        referrer = request.env['HTTP_REFERER'].presence
        @redirect_url = referrer || cms_link('/')
        render 'shared/redirect', status: :internal_server_error
      else
        flash[:error] = "We're sorry, but something went wrong."
        render turbo_stream: [], status: :internal_server_error
      end
    end
    format.html do
      if turbo_frame_request?
        if is_crm_request? && request.headers['Turbo-Frame'].present?
          render partial: 'shared/turbo_frame_error',
                 locals: {
                   frame_id: request.headers['Turbo-Frame'],
                   exception: exception,
                   show_details: Rails.env.in?(%w[development staging]) || current_user&.admin?
                 },
                 status: :internal_server_error
        else
          render 'error_pages/turbo_frame_500', status: :internal_server_error, layout: false
        end
      else
        render error_page, status: :internal_server_error, layout: layout_to_use
      end
    end
    format.json { render json: js_payload }
    format.xml { head '500' }
    format.any { head '500' }
  end
end

#render_invalid_authenticity_token(exception = nil) ⇒ Object

Render CSRF token invalid error (warning severity)

NOTE: We intentionally do NOT call reset_session here. Edge-cached pages
lack a CSRF meta tag; after Turbo navigation there is a brief window
before globals.json injects a fresh token. A form POST during that window
triggers this handler. Calling reset_session turns a harmless stale-token
race into a real logout — which is the reported "logged out after search"
bug. The redirect already blocks the stale request; globals.json will
supply a valid token on the next page load.



172
173
174
175
176
177
178
179
180
# File 'app/concerns/controllers/error_rendering.rb', line 172

def render_invalid_authenticity_token(exception = nil)
  ErrorReporting.from_controller(self).warning(exception, reason: 'csrf_token_invalid')
  reset_cloudflare_cache
  respond_to do |format|
    format.html { redirect_to safe_referer_or_fallback, alert: 'Please try again.' }
    format.json { render json: { error: 'Invalid authenticity token' }, status: :unprocessable_entity }
    format.any { head :unprocessable_entity }
  end
end

#render_ip_spoof_error(exception = nil) ⇒ Object

Render IP spoofing detection error (warning severity)



183
184
185
186
187
# File 'app/concerns/controllers/error_rendering.rb', line 183

def render_ip_spoof_error(exception = nil)
  ErrorReporting.from_controller(self).warning(exception, reason: 'ip_spoof_detected')
  reset_cloudflare_cache
  head :bad_request
end

#safe_referer_or_fallbackObject



241
242
243
244
245
246
247
248
249
250
251
252
# File 'app/concerns/controllers/error_rendering.rb', line 241

def safe_referer_or_fallback
  ref = request.referer
  return root_path if ref.blank?

  uri = URI.parse(ref)
  return root_path unless uri.scheme.nil? || %w[http https].include?(uri.scheme)
  return root_path unless uri.host.nil? || uri.host == request.host

  ref
rescue URI::InvalidURIError
  root_path
end