ActionController::RoutingError en Ruby on Rails, una solución simple y rápida

En el proyecto en el que trabajo a diario, comenzamos a recibir numerosas solicitudes falsas de la nada. Esto resultó en múltiples excepciones del tipo ActionController::RoutingError siendo logueadas en Rollbar (aplicación para trackeo de errores en tiempo real). Como consecuencia, en pocas horas, alcanzamos el cupo mensual, algo que nunca había sucedido antes. Si te está ocurriendo algo similar, , acá te comparto una solución buena, bonita y rápida para que no te vuelva a ocurrir 🙌.

Como solución, se nos ocurrió hacer que solo se registrara esa excepción en Rollbar si había un usuario logueado. De esta forma, había muchísimas más chances de que el error fuera un problema real de la aplicación y no una request falsa. Nuestro primer intento fue agregar un “concern” que manejara la excepción y decidiera qué hacer según si había un usuario logueado o no. Sorpresa, ¡no funcionó! ❌.

# app/controllers/concerns/error_handler.rb

module ErrorHandler
extend ActiveSupport::Concern

included do
rescue_from ActionController::RoutingError, with: :handle_routing_error
end

private

def handle_routing_error
if user_signed_in?
Rollbar.error(...)
end
super
end
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include ErrorHandler
...
end

Googleando (el que no lo hace que tire la primera piedra), resulta que esa excepción no la maneja Rails si no Rack (muuuucho antes de que llegue a Rails) 😱.

Primer descubrimiento: no todas las excepciones pueden ser rescatadas usando el método rescue_from.

¿Cómo lo solucionamos entonces? Definiendo las rutas de error de manera dinámica, es decir, redefiniendo las rutas de error en config/routes.rb y creando un controlador para ellas. Incluso, si tienen ganas y tiempo, pueden aprovechar para definir las pantallas de error acorde al sistema y dejar de usar las predeterminadas de Rails 😉.

Paso a paso

  1. Empezamos agregando el siguiente código al final del archivo config/routes.rbEs crucial que sea la última ruta del archivo, ya que Rails las busca de manera ordenada y, de lo contrario, todas las rutas definidas por debajo de esta quedarán inutilizables.
Rails.application.routes.draw do
# todas las demás rutas

match "/422", to: "errors#unprocessable_entity", via: :all
match "/500", :to => "errors#internal_server_error", :via =>:all
match "*unmatched", to: 'errors#not_found', via: :all, constraints: lambda { |req|
req.path.exclude? 'rails/active_storage'
}
end

Entonces, definimos las rutas de error que vienen por defecto en una aplicación Rails. Se preguntarán por qué no definí a la última ruta como “/404” y eso es porque al definirla de la forma “*unmatched” logramos que esta ruta funcione a modo de comodín 🃏, logrando capturar todas las solicitudes que no coincidan con las rutas definidas previamente. A su vez, al final de esta ruta sumamos una constraint que permite excluir determinadas rutas. En este caso, las rutas que no se tendrán en cuenta son aquellas que contengan ‘rails/active_storage’, permitiendo que la aplicación muestre las imágenes.

⚠️ Pueden modificar o quitar la restricción según las necesidades del proyect.

Segundo descubrimiento: se puede usar la función lambda para definir rutas.

2. Ahora creamos el controlador ErrorsController y añadimos los métodos que acabamos de definir.

class ErrorsController < ActionController::Base
protect_from_forgery with: :exception

def not_found
render file: Rails.public_path.join('404.html'), status: :not_found, layout: false
end

def internal_server_error
render file: Rails.public_path.join('500.html'), status: :internal_server_error, layout: false }
end

def unprocessable_entity
render file: Rails.public_path.join('422.html'), status: :unprocessable_entity, layout: false }
end
end

3. Por último, para poder probar que todo funciona correctamente en desarrollo, necesitamos agregar la siguiente línea config.consider_all_requests_local = false en config/environment/development.rb.

4. Extra: Si también deseas redefinir las vistas, crea el layout asociado, las vistas asociadas en app/views/errors, y actualiza el controlador para que renderice esas vistas. Por último, borra los archivos /public/404.html/public/500.html y /public/422.html.

class ErrorsController < ActionController::Base
protect_from_forgery with: :exception

def not_found
render status: :not_found
end

def internal_server_error
render status: :internal_server_error
end

def unprocessable_entity
render status: :unprocessable_entity
end
end