👋

Genadi Samokovarov

twitter.com/gsamokovarov
github.com/gsamokovarov
>

Bulgaria

Bulgaria

Aleko Konstantinov

To Chicago and Back

Minneapolis

To Minneapolis and Back

Sofia

Sofia

Sofia

Sofia

17–18 May in Sofia, Bulgaria

Resolve Errors Straight from the Error Pages!

Rails 5.2

>
>

Rails 6.0

>
>
>

Actionable Errors

Actionable errors let's you dispatch actions from Rails' error pages.

Credits

class PendingMigrationError < MigrationError
  include ActiveSupport::ActionableError

  action "Run pending migrations" do
    ActiveRecord::Tasks::DatabaseTasks.migrate
  end
end 
>

We're in the Unpacking Rails track 🤔

Error Handling in Rails

Rack

Rack is a protocol between Ruby web servers and frameworks.

Rack Application

require 'rack'

app = proc do |env|
  ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end

Rack::Handler::WEBrick.run app 
require 'rack'

app = proc do |env| 👈
  ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']]
end

Rack::Handler::WEBrick.run app 
require 'rack'

app = proc do |env|
  ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']] 👈
end

Rack::Handler::WEBrick.run app 

The foundation of HTTP middlewares in the Ruby 🌍

Rack Middleware

class Middleware
  def initialize(app)
    @app = app
  end

  def call(env)
    # Pass control to the next request handler.
    @app.call(env)
  end
end 
class Middleware
  def initialize(app)
    @app = app 👈
  end

  def call(env)
    # Pass control to the next request handler.
    @app.call(env)
  end
end 
class Middleware
  def initialize(app)
    @app = app
  end

  def call(env)
    # Pass control to the next request handler.
    @app.call(env) 👈
  end
end 
class Middleware
  def initialize(app)
    @app = app
  end

  def call(env)
    # Or stop the request here and return.
    ['200', {'Content-Type' => 'text/html'}, ['A barebones rack app.']] 👈
  end
end 

Rails Middleware Stack

$ rails middleware
use ActionDispatch::HostAuthorization
use Rack::Sendfile
use ActionDispatch::Static
use ActionDispatch::Executor
use ActiveSupport::Cache::Strategy::LocalCache::Middleware
use Rack::Runtime
use Rack::MethodOverride
use ActionDispatch::RequestId
use ActionDispatch::RemoteIp
use Sprockets::Rails::QuietAssets
use Rails::Rack::Logger
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
use ActionDispatch::Reloader
use ActionDispatch::Callbacks
use ActiveRecord::Migration::CheckPending
use ActionDispatch::Cookies
use ActionDispatch::Session::CookieStore
use ActionDispatch::Flash
use ActionDispatch::ContentSecurityPolicy::Middleware
use Rack::Head
use Rack::ConditionalGet
use Rack::ETag
use Rack::TempfileReaper
run Banitsa::Application.routes 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions 👈
use ActionDispatch::ActionableExceptions
# ... 

ActionDispatch::DebugExceptions

>
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception)
  end
end 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env) 👈

    response
  rescue Exception => exception
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception)
  end
end 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception 👈
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception)
  end
end 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception 👈
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception)
  end
end 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions? 👈
    render_exception(request, exception)
  end
end 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception) 👈
  end
end 
>
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions 👈
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 

ActionDispatch::ShowExceptions

>
class ActionDispatch::ShowExceptions
  FAILSAFE_RESPONSE = [500, { "Content-Type" => "text/plain" },
    ["500 Internal Server Error\n"]]

  def initialize(app, exceptions_app)
    @app = app
    @exceptions_app = exceptions_app
  end

  def call(env)
    request = ActionDispatch::Request.new env
    @app.call(env)
  rescue Exception => exception
    if request.show_exceptions?
      render_exception(request, exception)
    else
      raise exception
    end
  end
end 
class ActionDispatch::ShowExceptions
  FAILSAFE_RESPONSE = [500, { "Content-Type" => "text/plain" },
    ["500 Internal Server Error\n"]]

  def initialize(app, exceptions_app)
    @app = app
    @exceptions_app = exceptions_app 👈
  end

  def call(env)
    request = ActionDispatch::Request.new env
    @app.call(env)
  rescue Exception => exception
    if request.show_exceptions?
      render_exception(request, exception)
    else
      raise exception
    end
  end
end 
class ActionDispatch::PublicExceptions
  attr_accessor :public_path

  def call(env)
    request      = ActionDispatch::Request.new(env)
    status       = request.path_info[1..-1].to_i
    content_type = ...

    render status, content_type,
      status: status,
      error: Rack::Utils::HTTP_STATUS_CODES.fetch(status, Rack::Utils::HTTP_STATUS_CODES[500])
  end

  private
    def render(status, content_type, body)
      path = "#{public_path}/#{status}.html"

      render_format(status, "text/html", File.read(path))
    end
end 
class ActionDispatch::ShowExceptions
  FAILSAFE_RESPONSE = [500, { "Content-Type" => "text/plain" },
    ["500 Internal Server Error\n"]]

  def initialize(app, exceptions_app)
    @app = app
    @exceptions_app = exceptions_app
  end

  def call(env)
    request = ActionDispatch::Request.new env
    @app.call(env) 👈
  rescue Exception => exception
    if request.show_exceptions?
      render_exception(request, exception)
    else
      raise exception
    end
  end
end 
class ActionDispatch::ShowExceptions
  FAILSAFE_RESPONSE = [500, { "Content-Type" => "text/plain" },
    ["500 Internal Server Error\n"]]

  def initialize(app, exceptions_app)
    @app = app
    @exceptions_app = exceptions_app
  end

  def call(env)
    request = ActionDispatch::Request.new env
    @app.call(env)
  rescue Exception => exception 👈
    if request.show_exceptions?
      render_exception(request, exception)
    else
      raise exception
    end
  end
end 
class ActionDispatch::ShowExceptions
  FAILSAFE_RESPONSE = [500, { "Content-Type" => "text/plain" },
    ["500 Internal Server Error\n"]]

  def initialize(app, exceptions_app)
    @app = app
    @exceptions_app = exceptions_app
  end

  def call(env)
    request = ActionDispatch::Request.new env
    @app.call(env)
  rescue Exception => exception
    if request.show_exceptions?
      render_exception(request, exception) 👈
    else
      raise exception
    end
  end
end 
>
# public/500.html
We're sorry, but something went wrong (500)

We're sorry, but something went wrong.

If you are the application owner check the logs for more information.

class ActionDispatch::ShowExceptions
  FAILSAFE_RESPONSE = [500, { "Content-Type" => "text/plain" }, 👈
    ["500 Internal Server Error\n"]]

  def initialize(app, exceptions_app)
    @app = app
    @exceptions_app = exceptions_app
  end

  def call(env)
    request = ActionDispatch::Request.new env
    @app.call(env)
  rescue Exception => exception
    if request.show_exceptions?
      render_exception(request, exception)
    else
      raise exception
    end
  end
end 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware 👈
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 

WebConsole::Middleware

>
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted? 👈

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request) 👈
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request) 👈
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
>
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request) 👈
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env) 👈

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env) 👈

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) 👈 && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers) 👈
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ]
  end
end 
class WebConsole::Middleware
  def call(env)
    request = create_regular_or_whiny_request(env)
    return @app.call(env) unless request.permitted?

    if id = id_for_repl_session_update(request)
      return update_repl_session(id, request)
    elsif id = id_for_repl_session_stack_frame_change(request)
      return change_stack_trace(id, request)
    end

    status, headers, body = @app.call(env)

    if (session = Session.from(Thread.current)) && acceptable_content_type?(headers)
      template = Template.new(env, session)
      body, headers = Injector.new(body, headers).inject(template.render("index"))
    end

    [ status, headers, body ] 👈
  end
end 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware 👈
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware 👈 ⁉️
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 

Rails 5.2

class ActionDispatch::DebugExceptions
  def render_exception_with_web_console(request, exception)
    render_exception_without_web_console(request, exception).tap do
      backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
      error = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).exception

      # Get the original exception if ExceptionWrapper decides to follow it.
      Thread.current[:__web_console_exception] = error

      # ActionView::Template::Error bypass ExceptionWrapper original
      # exception following. The backtrace in the view is generated from
      # reaching out to cause in the view.
      if error.is_a?(ActionView::Template::Error)
        Thread.current[:__web_console_exception] = error.cause
      end
    end
  end

  alias_method :render_exception_without_web_console, :render_exception
  alias_method :render_exception, :render_exception_with_web_console
end 
class ActionDispatch::DebugExceptions
  def render_exception_with_web_console(request, exception)
    render_exception_without_web_console(request, exception).tap do
      backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
      error = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).exception

      # Get the original exception if ExceptionWrapper decides to follow it.
      Thread.current[:__web_console_exception] = error

      # ActionView::Template::Error bypass ExceptionWrapper original
      # exception following. The backtrace in the view is generated from
      # reaching out to cause in the view.
      if error.is_a?(ActionView::Template::Error)
        Thread.current[:__web_console_exception] = error.cause
      end
    end
  end

  alias_method :render_exception_without_web_console, :render_exception 👈
  alias_method :render_exception, :render_exception_with_web_console
end 
class ActionDispatch::DebugExceptions
  def render_exception_with_web_console(request, exception)
    render_exception_without_web_console(request, exception).tap do
      backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
      error = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).exception 👈

      # Get the original exception if ExceptionWrapper decides to follow it.
      Thread.current[:__web_console_exception] = error

      # ActionView::Template::Error bypass ExceptionWrapper original
      # exception following. The backtrace in the view is generated from
      # reaching out to cause in the view.
      if error.is_a?(ActionView::Template::Error)
        Thread.current[:__web_console_exception] = error.cause
      end
    end
  end

  alias_method :render_exception_without_web_console, :render_exception
  alias_method :render_exception, :render_exception_with_web_console
end 
class ActionDispatch::DebugExceptions
  def render_exception_with_web_console(request, exception)
    render_exception_without_web_console(request, exception).tap do
      backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
      error = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).exception

      # Get the original exception if ExceptionWrapper decides to follow it.
      Thread.current[:__web_console_exception] = error 👈

      # ActionView::Template::Error bypass ExceptionWrapper original
      # exception following. The backtrace in the view is generated from
      # reaching out to cause in the view.
      if error.is_a?(ActionView::Template::Error)
        Thread.current[:__web_console_exception] = error.cause
      end
    end
  end

  alias_method :render_exception_without_web_console, :render_exception
  alias_method :render_exception, :render_exception_with_web_console
end 
class ActionDispatch::DebugExceptions
  def render_exception_with_web_console(request, exception)
    render_exception_without_web_console(request, exception).tap do
      backtrace_cleaner = request.get_header("action_dispatch.backtrace_cleaner")
      error = ActionDispatch::ExceptionWrapper.new(backtrace_cleaner, exception).exception

      # Get the original exception if ExceptionWrapper decides to follow it.
      Thread.current[:__web_console_exception] = error

      # ActionView::Template::Error bypass ExceptionWrapper original
      # exception following. The backtrace in the view is generated from
      # reaching out to cause in the view.
      if error.is_a?(ActionView::Template::Error) 👈
        Thread.current[:__web_console_exception] = error.cause
      end
    end
  end

  alias_method :render_exception_without_web_console, :render_exception
  alias_method :render_exception, :render_exception_with_web_console
end 

🙈 🙉 🙊

Rails 6

The Interceptor API

🙅‍♀️🐒

class WebConsole::Railtie < ::Rails::Railtie
  initializer 'web_console.initialize' do
    ActionDispatch::DebugExceptions
      .register_interceptor(WebConsole::Interceptor)
  end
end 
module WebConsole::Interceptor
  def self.call(request, exception)
    backtrace_cleaner = request.get_header('action_dispatch.backtrace_cleaner')
    error = ExceptionWrapper.new(backtrace_cleaner, exception).exception

    # Get the original exception if ExceptionWrapper decides to follow it.
    Thread.current[:__web_console_exception] = error

    # ActionView::Template::Error bypass ExceptionWrapper original
    # exception following. The backtrace in the view is generated from
    # reaching out to original_exception in the view.
    if error.is_a?(ActionView::Template::Error)
      Thread.current[:__web_console_exception] = error.cause
    end
  end
end 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions 👈
use ActionDispatch::ActionableExceptions
# ... 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception)
  end
end 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception
    invoke_interceptors(request, exception) 👈
    raise exception unless request.show_exceptions?
    render_exception(request, exception)
  end
end 

Application vs Framework Traces

>

Actionable Errors

>
class PendingMigrationError < MigrationError
  include ActiveSupport::ActionableError

  action "Run pending migrations" do
    ActiveRecord::Tasks::DatabaseTasks.migrate
  end
end 
class PendingMigrationError < MigrationError
  include ActiveSupport::ActionableError 👈

  action "Run pending migrations" do
    ActiveRecord::Tasks::DatabaseTasks.migrate
  end
end 
class PendingMigrationError < MigrationError
  include ActiveSupport::ActionableError

  action "Run pending migrations" do 👈
    ActiveRecord::Tasks::DatabaseTasks.migrate
  end
end 
class PendingMigrationError < MigrationError
  include ActiveSupport::ActionableError

  action "Run pending migrations" do
    ActiveRecord::Tasks::DatabaseTasks.migrate 👈
  end
end 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions
# ... 
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions 👈
use ActionDispatch::ActionableExceptions
# ... 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception)
  end
end 
class ActionDispatch::DebugExceptions
  def call(env)
    request = ActionDispatch::Request.new env
    _, headers, body = response = @app.call(env)

    response
  rescue Exception => exception
    invoke_interceptors(request, exception)
    raise exception unless request.show_exceptions?
    render_exception(request, exception) 👈
  end
end 
# action_dispatch/middleware/templates/rescues/diagnostics.html.erb
<%= render "rescues/actions", exception: @exception, request: @request %> 
# action_dispatch/middleware/templates/rescues/_actions.html.erb
<% actions = ActiveSupport::ActionableError.actions(exception) %>

<% if actions.any? %>
  
<% actions.each do |action, _| %> <%= button_to action, ActionDispatch::ActionableExceptions.endpoint, params: { error: exception.class.name, action: action, location: request.path } %> <% end %>
<% end %>
$ rails middleware
# ...
use ActionDispatch::ShowExceptions
use WebConsole::Middleware
use ActionDispatch::DebugExceptions
use ActionDispatch::ActionableExceptions 👈
# ... 
class ActionDispatch::ActionableExceptions
  def call(env)
    request = ActionDispatch::Request.new(env)
    return @app.call(env) unless actionable_request?(request)

    ActiveSupport::ActionableError.dispatch \
      request.params[:error].to_s.safe_constantize,
      request.params[:action]

    redirect_to request.params[:location]
  end
end 
class ActionDispatch::ActionableExceptions
  def call(env)
    request = ActionDispatch::Request.new(env)
    return @app.call(env) unless actionable_request?(request) 👈

    ActiveSupport::ActionableError.dispatch \
      request.params[:error].to_s.safe_constantize,
      request.params[:action]

    redirect_to request.params[:location]
  end
end 
class ActionDispatch::ActionableExceptions
  def call(env)
    request = ActionDispatch::Request.new(env)
    return @app.call(env) unless actionable_request?(request)

    ActiveSupport::ActionableError.dispatch \ 👈
      request.params[:error].to_s.safe_constantize,
      request.params[:action]

    redirect_to request.params[:location]
  end
end 
class ActionDispatch::ActionableExceptions
  def call(env)
    request = ActionDispatch::Request.new(env)
    return @app.call(env) unless actionable_request?(request)

    ActiveSupport::ActionableError.dispatch \
      request.params[:error].to_s.safe_constantize,
      request.params[:action]

    redirect_to request.params[:location] 👈
  end
end 

😰

😴

Strategies

Middleware Checker

class ActiveRecord::Migration::CheckPending
  def initialize(app)
    @app = app
    @last_check = 0
  end

  def call(env)
    mtime = ActiveRecord::Base.connection.migration_context.last_migration.mtime.to_i
    if @last_check < mtime
      ActiveRecord::Migration.check_pending!(ActiveRecord::Base.connection)
      @last_check = mtime
    end
    @app.call(env)
  end
end 
class ActiveRecord::Migration::CheckPending
  def initialize(app)
    @app = app
    @last_check = 0
  end

  def call(env)
    mtime = ActiveRecord::Base.connection.migration_context.last_migration.mtime.to_i
    if @last_check < mtime
      ActiveRecord::Migration.check_pending!(ActiveRecord::Base.connection) 👈
      @last_check = mtime
    end
    @app.call(env)
  end
end 
class ActiveRecord::Railtie < Rails::Railtie
  initializer "active_record.migration_error" do
    if config.active_record.delete(:migration_error) == :page_load
      config.app_middleware.insert_after ::ActionDispatch::Callbacks,
        ActiveRecord::Migration::CheckPending
    end
  end
end 

Generic approach, but requires too much work... 😕

Act on Existing Errors

Work in Progress 🏗

rails/rails#36071
class ActionMailbox::SetupError < ActionMailbox::Error
  include ActiveSupport::ActionableError

  def initialize(message = nil)
    super(message || <<~MESSAGE)
      Action Mailbox does not appear to be installed. Do you want to
      install it now?
    MESSAGE
  end

  action "Install now" do
    Rails::Command.invoke "active_storage:install"
    Rails::Command.invoke "action_mailbox:install"
    Rails::Command.invoke "db:migrate"
  end
end 
class ActionMailbox::SetupError < ActionMailbox::Error
  include ActiveSupport::ActionableError 👈

  def initialize(message = nil)
    super(message || <<~MESSAGE)
      Action Mailbox does not appear to be installed. Do you want to
      install it now?
    MESSAGE
  end

  action "Install now" do
    Rails::Command.invoke "active_storage:install"
    Rails::Command.invoke "action_mailbox:install"
    Rails::Command.invoke "db:migrate"
  end
end 
class ActionMailbox::SetupError < ActionMailbox::Error
  include ActiveSupport::ActionableError

  def initialize(message = nil)
    super(message || <<~MESSAGE)
      Action Mailbox does not appear to be installed. Do you want to
      install it now?
    MESSAGE
  end

  action "Install now" do 👈
    Rails::Command.invoke "active_storage:install"
    Rails::Command.invoke "action_mailbox:install"
    Rails::Command.invoke "db:migrate"
  end
end 
class ActionMailbox::Engine < Rails::Engine
  initializer "action_mailbox.setup" do
    ActionDispatch::ActionableExceptions.on ActiveRecord::StatementInvalid do |err|
      if err.to_s.match?(ActionMailbox::InboundEmail.table_name)
        raise ActionMailbox::SetupError
      end
    end
  end
end 
class ActionMailbox::Engine < Rails::Engine
  initializer "action_mailbox.setup" do
    ActionDispatch::ActionableExceptions.on ActiveRecord::StatementInvalid do |err| 👈
      if err.to_s.match?(ActionMailbox::InboundEmail.table_name)
        raise ActionMailbox::SetupError
      end
    end
  end
end 
class ActionMailbox::Engine < Rails::Engine
  initializer "action_mailbox.setup" do
    ActionDispatch::ActionableExceptions.on ActiveRecord::StatementInvalid do |err|
      if err.to_s.match?(ActionMailbox::InboundEmail.table_name) 👈
        raise ActionMailbox::SetupError
      end
    end
  end
end 
class ActionMailbox::Engine < Rails::Engine
  initializer "action_mailbox.setup" do
    ActionDispatch::ActionableExceptions.on ActiveRecord::StatementInvalid do |err|
      if err.to_s.match?(ActionMailbox::InboundEmail.table_name)
        raise ActionMailbox::SetupError 👈
      end
    end
  end
end 

Please, give us feedback! 🙏

rails/rails#36071

TL;DR

We can improve the development experience!

Actionable errors are only a single step.

Can be useful for the Rails Conductor.

Nobody reads the docs!

gem install break

Thank you!