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
-
#excp_string(exception, options) ⇒ Object
Build exception string for error reporting emails.
-
#mail_to_for_error_reporting(exception, options = {}) ⇒ Object
Generate mailto link for error reporting.
-
#render_400(_exception = nil) ⇒ Object
Render 400 Bad Request.
-
#render_404(exception = nil) ⇒ Object
Render 404 Not Found.
-
#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.
-
#render_410(_exception = nil) ⇒ Object
Render 410 Gone — tells search engines the resource is permanently removed.
-
#render_500(exception = nil) ⇒ Object
Render 500 Internal Server Error.
-
#render_invalid_authenticity_token(exception = nil) ⇒ Object
Render CSRF token invalid error (warning severity).
-
#render_ip_spoof_error(exception = nil) ⇒ Object
Render IP spoofing detection error (warning severity).
-
#render_unpermitted_parameters(exception = nil) ⇒ void
Surface unpermitted strong-parameter input as a user-visible 4xx instead of a 500.
- #safe_referer_or_fallback ⇒ Object
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, ) str = [] str << "Bug report url: #{[:bug_url]}" if [: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, = {}) str = excp_string(exception, ) link_name = [:link_name] || 'Send details to Heatwave team' css_classes = [: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.
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 = 'Some inputs were not recognized. Please check the form and try again.' respond_to do |format| format.turbo_stream do flash.now[:error] = render turbo_stream: [], status: :unprocessable_content end format.html do flash[:error] = redirect_to safe_referer_or_fallback end format.json { render json: { error: , params: exception&.params }, status: :unprocessable_content } format.any { head :unprocessable_content } end end |
#safe_referer_or_fallback ⇒ Object
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 |