rescue_from: this app's one line, from a bad slug to a custom page

Free preview 4 min to read

Where this app raises the exception

Course and lesson pages in this app are found by slug, not by numeric id. The query objects behind them:

# app/queries/catalog/find_course_by_slug_query.rb
Result.new(course: ::Catalog::Course.active.find_by!(slug: slug))

# app/queries/catalog/find_lesson_by_slug_query.rb
lesson = course.lessons.find_by(slug: slug)
return Result.new(lesson: lesson, renamed: false) if lesson

Result.new(lesson: course.lessons.where("? = ANY(previous_slugs)", slug).take!, renamed: true)

find_by!, the bang version, raises ActiveRecord::RecordNotFound when no row matches: Catalog::FindCourseBySlugQuery.call(slug: "does-not-exist") raises ActiveRecord::RecordNotFound: Couldn't find Catalog::Course .... The lesson query tries the current slug first, then the lesson's earlier slugs, so an old link to a renamed lesson can still be redirected. Only when neither matches does take! raise the same exception. A mistyped URL, an old bookmark to a removed course or a slug that never existed all end up as this one exception class.

The one line that catches it

app/controllers/application_controller.rb has one rescue_from call, and every controller in this app inherits from it:

class ApplicationController < ActionController::Base
  rescue_from ActiveRecord::RecordNotFound, with: :render_not_found
end

One line. Every action in every controller, from Catalog::CoursesController to Enrollment::LessonCompletionsController and Public::ProfilesController, gets the same behavior when it raises ActiveRecord::RecordNotFound, without any of them writing its own begin/rescue.

render_not_found, as written

with: :render_not_found names a private method, also in ApplicationController:

def render_not_found
  respond_to do |format|
    format.html do
      @not_found = not_found_context
      @suggested_courses = Public::NotFoundSuggestionsQuery.call.courses
      render "errors/not_found", status: :not_found
    end
    format.any { head :not_found }
  end
end

def not_found_context
  if params[:username].present?
    { kind: :profile, name: params[:username] }
  elsif params[:course_slug].present? && params[:slug].present?
    { kind: :lesson, name: params[:slug].to_s.tr("-", " ") }
  elsif params[:slug].present? || params[:course_slug].present?
    { kind: :course, name: (params[:slug] || params[:course_slug]).to_s.tr("-", " ") }
  else
    { kind: :page, name: nil }
  end
end

not_found_context reads whichever params the failed request had and decides what went missing. It can be a profile (params[:username]), a lesson (both course_slug and slug), a course (just one of them) or a generic page. render_not_found branches on format. An HTML request gets the styled errors/not_found view, with that context and a list of suggested courses. Anything else, such as an API client asking for JSON, gets a bare head :not_found: a 404 status with no body.

The view it renders

app/views/errors/not_found.html.erb uses @not_found[:kind] to pick an icon: a person for a missing profile, a book for anything else. It also looks up the heading, body and hint text through t("errors.not_found.heading.#{kind}", name: name) and its siblings, one i18n key per kind. Below that, @suggested_courses, when there are any, renders as a grid of course cards with the same catalog_course_path links and level badges used across the app.

What one rescue_from line replaces

Without it, every action that can raise ActiveRecord::RecordNotFound would need its own begin/rescue around the same steps: decide the kind, load suggestions, render the view, set the status. rescue_from, registered once on ApplicationController, keeps that logic in one place instead of copied into every controller that looks something up by slug.

What this course covers

Seven lessons, built on this app's code and Rails' installed source. This lesson covers this app's use, start to finish. Lesson 2 covers the two forms rescue_from accepts, with: and a block. Lesson 3 covers what happens with more than one rescue_from: declaration order and inheritance, tested against Rails' matching algorithm. Lesson 4 covers is_a? and the family of ActiveRecord exceptions this app never had to name one by one. Lesson 5 covers Rails' default mapping from exceptions to status codes, and what a custom rescue_from adds. Lesson 6 covers the two ways an exception can still get through: no handler matches, or the handler itself fails. Lesson 7 ends with a small simulator of the matching algorithm.

Lesson 1 of 7 · free

Keep going with lesson 2

with: or a block: the two forms rescue_from accepts

Compares the with: and block forms, which share one slot, and shows how arity controls the exception argument.

A free account also gives you the quizzes, your progress and notes, and the discussion.

Type to start searching...