[Rails] Exception handling

Продолжаю свои увлекательные (или не очень) рассказы про рабочие будни и задачи, которые надо было решать. Сразу к делу:

Задача

Сделать кастомные страницы исключений для апи и веб интерфейсов. Стандартизировать подход к отлову и отдаче исключений.

№ Контекст

Иногда в приложении происходит неведомая х**ня что-то странное, и вылезает исключение, с ответом в виде статичной html страницы и в апи тоже, отчего случается веселье на всех концах.

А что там под капотом?

Под капотом Rack-based приложение на Rails. Rack был изначально придуман, чтобы упростить всем жизнь, он представляет из себя слоеный пирог middleware. Каждый слой это Ruby объект, принимающий в конструктор объект app, отвечающий на метод call, в который прокидывается environment, а возвращается массив(Array), состоящий ровно из трех элементов: status, headers, body. Начиная с версии руби 1.9 body должно реализовывать метод each, т.е. по сути быть тем же массивом.

Middleware является реализацией то ли паттерна Chain of Responsibility, то ли Pipeline, этого момента я сам до конца не понял. С одной стороны, оно как бы отдает на выход то, что принимает следующий слой, с другой, реквест, который пришел, обрабатывают не все слои, а только те, которые активированы или требованиям которых запрос удовлетворяет. Ну как-то так...

А как это делают?

Готовые решения уже есть, но чтобы все самому прочувствовать, надо залезть во внутренности Rails и посмотреть, закатали рукава и вперед.

Чтобы получить весь список middleware из приложения набираем в консоли:

$ rake middleware

Смотрим на понятно-непонятные буковки и нас интересует:
use ActionDispatch::ShowExceptions

В нашем приложении используется последняя стабильная сборка 3.2.9, поэтому её и будем копать:

actionpack/lib/action_dispatch/middleware/show_exceptions.rb

def call(env)
  begin
    response = @app.call(env)
  rescue Exception => exception
	raise exception if env['action_dispatch.show_exceptions'] == false
  end
  response || render_exception(env, exception)
end

Ничего особо сложного, прокидываем дальше запрос и ждем пока вылетит исключение, если такая ситуация не возникла, то просто отдадим response, если что-то пошло не так, то поймаем Exception(т.е. любое исключение), т.к. в конфиге рельсов можно настроить все что хочешь, то и этот middleware можно отключить, если так, то ошибка необработанной уйдет дальше по цепочке. Если слой не отключен, то вызовем метод _render_exception//. А вот в нем творятся интересные вещи(код приведу не полностью):

def render_exception(env, exception)
  wrapper = ExceptionWrapper.new(env, exception)
  ...
  env["PATH_INFO"] = "/#{status}"
  response = @exceptions_app.call(env)
rescue Exception => failsafe_error
  $stderr.puts "..."
  FAILSAFE_RESPONSE
end

Во-первых, исключение отдается в ExceptionWrapper, который сомтрит готовый маппинг известных ошибок в символьный аналог статус кодов. Во-вторых, в переменную пути складывается статус-код, что нам впоследствии пригодится, и далее, обработка всего и вся отдается некоему @exceptions_app. Это еще что такое?

А это ,всего навсего, еще одно middleware, которое по дефолту в Rails занимается обработкой исключений. Если почитаете гайды, то узнаете, что имя ему PublicExceptions. Все, что там происходит, так это прокидывание статуса(404, 422, 500) дальше по цепочке и рендеринг стандартных страниц с ошибками, которые лежат в public.

А дальше что?

А дальше, сконфигурировать можно все, поэтому переопределим exceptions_app. Тут встает вопрос, писать своё middleware или взять что-то готовое?

за свое против
органично вписывается в архитектуру то не знает, не найдет
свобода мысли сложность тестирования

А какое готовое? Есть ли такое приложение или слой, который органично вписывался бы в существующую структуру и при этом решал поставленную задачу? Да.

В application.rb укажем:

config.exceptions_app = self.routes

Как вы помните в path_info складывается путь /404, /500, /422 их будем ловить в routes.rb. Перенаправим на обычный контроллер, назовем его errors.

match '/404', :to => "errors#not_found"
match '/422', :to => "errors#unprocessable_entity"
match '/500', :to => "errors#internal_error"

Теперь создадим errors_controller.rb в папке /app/controllers/, никакой авторизации, эти страницы должны быть доступны для всех. Добавляем

respond_to :json, :html

def not_found
  respond_with({:message => 'Not Found'}, :status => :not_found)
 end

Аналогично определяем еще 2 метода, заменив только статусы на соответствующие ошибкам(422 - :unprocessable_entity, 500 - :internal_server_error).

Напишем тесты — ссылка на github. Ничего сложного, код опущу для экономии места.

Далее сделаем view отдельно для html и json и сложим их в app/views/errors/
Последнее, что надо сделать, чтобы в режиме development увидеть кастомные страницы, это заменить значение одного параметра в config/environments/development.rb true -> false.

config.consider_all_requests_local = false

Полный код можно посмотреть и скачать с github demo_exception_handling. После этого можно сделать bundle install, затем запустить сервер(bundle exec rails s) и обратиться по адресам стандартных страничек ошибок:

Там можно увидеть наши простенькие кастомные страницы, а что самое классное, если обратить к этим страницам с форматом json, то получим

{ message: 'Not Found' }

а не статичный html. Если потребуется можно выводить какие-то специфические коды ошибок в апи и отдавать их в json как будет удобно. В общем простор для фантазии. На этом я думаю все.

Откуда черпал информацию: