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



324
325
326
327
328
329
330
331
332
333
334
335
336
# File 'app/concerns/controllers/error_rendering.rb', line 324

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



339
340
341
342
343
344
345
346
347
348
349
# File 'app/concerns/controllers/error_rendering.rb', line 339

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



102
103
104
105
# File 'app/concerns/controllers/error_rendering.rb', line 102

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

#render_404(exception = nil) ⇒ Object

Render 404 Not Found



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
# File 'app/concerns/controllers/error_rendering.rb', line 139

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

  # Self-healing rename: if this www URL is a prior path of a page that moved,
  # 301 to its current path instead of 404ing. Reported (not raised) so the
  # hot ones can later be promoted to Cloudflare bulk redirects.
  if !is_crm_request? && request.format.html? && (target = sitemap_history_redirect_target)
    ErrorReporting.from_controller(self).informational(
      "SiteMap history redirect: #{request.path} -> #{target}",
      reason: 'sitemap_history_redirect', from: request.path, to: target
    )
    return redirect_to(cms_link(target), status: :moved_permanently, allow_other_host: false)
  end

  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



109
110
111
112
# File 'app/concerns/controllers/error_rendering.rb', line 109

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



115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'app/concerns/controllers/error_rendering.rb', line 115

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



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
91
92
93
94
95
96
97
98
99
# File 'app/concerns/controllers/error_rendering.rb', line 37

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.to_s) }
    @backtrace = @backtrace.map { |line| line.sub(Rails.root.join.to_s, '') }
  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(' ').to_s }
  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
rescue ActionController::RespondToMismatchError
  # The original action already matched a format via its own `respond_to`
  # and then raised before rendering completed (so `@rendered_format` is
  # set). Rails 7 raises RespondToMismatchError if our `respond_to` above
  # negotiates a different format, which masks the underlying error and
  # produces a noisy secondary incident (AppSignal #2117). Fall back to
  # a bare 500 so the original error is what gets reported, not this one.
  head :internal_server_error
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.



192
193
194
195
196
197
198
199
200
# File 'app/concerns/controllers/error_rendering.rb', line 192

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)



240
241
242
243
244
# File 'app/concerns/controllers/error_rendering.rb', line 240

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

#render_unpermitted_parameters(exception = nil) ⇒ void

This method returns an undefined value.

Surface unpermitted strong-parameter input as a user-visible 4xx instead
of a 500.

config.action_controller.action_on_unpermitted_parameters = :raise
(config/application.rb) turns any shape mismatch — most commonly a
form rendering a single-select picker for a key the controller permits
as an Array (foo: []) — into a 500. AppSignal #5083 / #4972 are both
examples of that pattern. Until every report's permits and form widgets
are aligned, treat this as a 4xx-class user-visible error: flash through
TurboStreamFlashable, redirect HTML responses back to where they came
from, and report at warning severity so we still see incidents in
AppSignal but they don't page anyone.

Parameters:

  • exception (ActionController::UnpermittedParameters, nil) (defaults to: nil)

    the
    rescued exception; carries the rejected key list and original params.

See Also:



219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'app/concerns/controllers/error_rendering.rb', line 219

def render_unpermitted_parameters(exception = nil)
  ErrorReporting.from_controller(self).warning(exception, reason: 'unpermitted_parameters') if exception
  reset_cloudflare_cache

  message = 'Some inputs were not recognized. Please check the form and try again.'

  respond_to do |format|
    format.turbo_stream do
      flash.now[:error] = message
      render turbo_stream: [], status: :unprocessable_content
    end
    format.html do
      flash[:error] = message
      redirect_to safe_referer_or_fallback
    end
    format.json { render json: { error: message, params: exception&.params }, status: :unprocessable_content }
    format.any { head :unprocessable_content }
  end
end

#safe_referer_or_fallbackObject



310
311
312
313
314
315
316
317
318
319
320
321
# File 'app/concerns/controllers/error_rendering.rb', line 310

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