Escribir aplicaciones web robustas el arte perdido de la gestión de excepciones

Como desarrolladores, queremos que las aplicaciones que construimos sean resistentes cuando se trata de fallas, pero ¿cómo lograr este objetivo? Si cree que la exageración, los microservicios y un protocolo de comunicación inteligente son la respuesta a todos sus problemas, o tal vez la conmutación por error automática de DNS. Si bien ese tipo de cosas tiene su lugar y lo convierte en una presentación de conferencia interesante, la verdad un poco menos atractiva es que hacer una aplicación robusta comienza con su código. Pero, incluso las aplicaciones bien diseñadas y probadas a menudo carecen de un componente vital de código resistente: manejo de excepciones.

Contenido Patrocinado

Este contenido fue encargado por Engine Yard y fue escrito y / o editado por el equipo de Tuts +. Nuestro objetivo con el contenido patrocinado es publicar tutoriales relevantes y objetivos, estudios de casos y entrevistas inspiradoras que ofrezcan un valor educativo genuino a nuestros lectores y nos permitan financiar la creación de contenido más útil..

Nunca dejé de sorprenderme por cómo el manejo de excepciones subutilizado tiende a ser incluso dentro de las bases de código maduras. Veamos un ejemplo.


Qué puede salir mal?

Supongamos que tenemos una aplicación Rails, y una de las cosas que podemos hacer con esta aplicación es obtener una lista de los últimos tweets para un usuario, dado su manejo. Nuestro TweetsController podría verse así:

clase TweetsController < ApplicationController def show person = Person.find_or_create_by(handle: params[:handle]) if person.persisted? @tweets = person.fetch_tweets else flash[:error] = "Unable to create person with handle: #person.handle" end end end

Y el Persona El modelo que utilizamos podría ser similar al siguiente:

clase persona < ActiveRecord::Base def fetch_tweets client = Twitter::REST::Client.new do |config| config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end client.user_timeline(handle).map|tweet| tweet.text end end

Este código parece perfectamente razonable, hay docenas de aplicaciones que tienen un código como este en producción, pero veamos un poco más de cerca.

  • find_or_create_by es un método de Rails, no es un método 'bang', por lo que no debería lanzar excepciones, pero si miramos la documentación podemos ver que debido a la forma en que funciona este método, puede generar una ActiveRecord :: RecordNotUnique error. Esto no sucederá a menudo, pero si nuestra aplicación tiene una cantidad de tráfico decente, es más probable de lo que podría esperar (lo he visto muchas veces).
  • Mientras estamos en el tema, cualquier biblioteca que utilice puede generar errores inesperados debido a errores dentro de la misma biblioteca y Rails no es una excepción. Dependiendo de nuestro nivel de paranoia podríamos esperar nuestro find_or_create_by lanzar cualquier tipo de error inesperado en cualquier momento (un buen nivel de paranoia es algo bueno cuando se trata de construir software robusto). Si no tenemos una forma global de manejar errores inesperados (veremos esto más adelante), es posible que queramos manejarlos individualmente..
  • Entonces hay person.fetch_tweets que crea una instancia de un cliente de Twitter y trata de obtener algunos tweets. Esta será una llamada de red y es propensa a todo tipo de fallas. Es posible que queramos leer la documentación para averiguar cuáles son los posibles errores que podemos esperar, pero sabemos que los errores no solo son posibles aquí, sino que también son bastante probables (por ejemplo, la API de Twitter puede estar inactiva, una persona con ese control podría no existe etc.). No poner alguna lógica de manejo de excepciones en las llamadas de red es un problema.

Nuestra pequeña cantidad de código tiene algunos problemas serios, intentemos mejorarlo.


La cantidad correcta de manejo de excepciones

Envolveremos nuestro find_or_create_by y empujarlo hacia abajo en el Persona modelo:

clase persona < ActiveRecord::Base class << self def find_or_create_by_handle(handle) begin Person.find_or_create_by(handle: handle) rescue ActiveRecord::RecordNotUnique Rails.logger.warn  "Encountered a non-fatal RecordNotUnique error for: #handle"  retry rescue => e Rails.logger.error "Se encontró un error al intentar encontrar o crear una Persona para: # handle, # e.message # e.backtrace.join (" \ n ")" nil end end final fin

Hemos manejado el ActiveRecord :: RecordNotUnique de acuerdo con la documentación y ahora sabemos a ciencia cierta que obtendremos una Persona objeto o nulo Si algo va mal. Este código ahora es sólido, pero ¿qué hay de recuperar nuestros tweets?

clase persona < ActiveRecord::Base def fetch_tweets client.user_timeline(handle).map|tweet| tweet.text rescue => e Rails.logger.error "Error al obtener tweets para: # handle, # e.message # e.backtrace.join (" \ n ")" nil end private def client @client || = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token_secret end.

Impulsamos la creación de instancias del cliente de Twitter en su propio método privado y, como no sabíamos qué podía salir mal cuando recuperábamos los tweets, rescatábamos todo..

Es posible que hayas escuchado en algún lugar que siempre debes detectar errores específicos. Este es un objetivo loable, pero la gente a menudo lo malinterpreta como "si no puedo captar algo específico, no captaré nada". En realidad, si no puedes capturar algo específico, ¡debes atrapar todo! De esta manera, al menos, tiene la oportunidad de hacer algo, incluso si solo es para registrar y volver a generar el error.

Un aparte en OO Design

Para hacer nuestro código más robusto, nos vimos obligados a refactorizar y ahora nuestro código es posiblemente mejor que antes. Puede utilizar su deseo de un código más resistente para informar sus decisiones de diseño.

Un aparte en las pruebas

Cada vez que agrega alguna lógica de manejo de excepciones a un método, también es una ruta adicional a través de ese método y debe ser probada. Es vital que pruebes el camino excepcional, tal vez más que probar el camino feliz. Si algo sale mal en el camino feliz, ahora tiene el seguro adicional del rescate bloque para evitar que su aplicación se caiga. Sin embargo, cualquier lógica dentro del bloque de rescate en sí no tiene tal seguro. Prueba tu ruta excepcional bien, para que cosas tontas como escribir mal el nombre de una variable dentro de la rescate el bloqueo no hace que su aplicación explote (esto me ha sucedido tantas veces, en serio, solo pruebe su rescate bloques).


Qué hacer con los errores que detectamos

He visto este tipo de código innumerables veces a través de los años:

comenzar widgetron.create rescate # no es necesario que haga nada al final

Rescatamos una excepción y no hacemos nada con ella. Esto es casi siempre una mala idea. Cuando esté depurando un problema de producción dentro de seis meses, tratando de averiguar por qué su 'widgetron' no se muestra en la base de datos, no recordará ese inocente comentario y horas de frustración..

¡No trague excepciones! Como mínimo, debe registrar cualquier excepción que detecte, por ejemplo:

begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" end

De esta manera podemos rastrear los registros y tendremos la causa y el seguimiento de la pila del error para analizar.

Mejor aún, puedes usar un servicio de monitoreo de errores, como Rollbar, que es bastante bueno. Hay muchas ventajas para esto:

  • Sus mensajes de error no se intercalan con otros mensajes de registro
  • Obtendrá estadísticas sobre la frecuencia con la que ha ocurrido el mismo error (para que pueda averiguar si se trata de un problema grave o no)
  • Puede enviar información adicional junto con el error para ayudarlo a diagnosticar el problema
  • Puede recibir notificaciones (por correo electrónico, pagerduty, etc.) cuando se producen errores en su aplicación
  • Puede hacer un seguimiento de las implementaciones para ver cuándo se introdujeron o arreglaron errores particulares
  • etc.
comenzar foo.bar rescate => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) end

Por supuesto, puede registrar y usar un servicio de monitoreo como se indica arriba.

Si tu rescate bloque es lo último en un método, recomiendo tener un retorno explícito:

def my_method begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) nil end end

Puede que no siempre quieras volver nulo, a veces puede que esté mejor con un objeto nulo o cualquier otra cosa que tenga sentido en el contexto de su aplicación. El uso constante de valores de retorno explícitos ahorrará a todos mucha confusión.

También puede volver a elevar el mismo error o generar otro diferente dentro de su rescate bloquear. Un patrón que a menudo encuentro útil es envolver la excepción existente en una nueva y elevarla para no perder el rastro de pila original (incluso escribí una joya para esto ya que Ruby no proporciona esta funcionalidad de forma inmediata). ). Más adelante en el artículo, cuando hablemos de servicios externos, le mostraré por qué esto puede ser útil..


Manejo de errores a nivel mundial

Rails le permite especificar cómo manejar las solicitudes de recursos de un determinado formato (HTML, XML, JSON) usando responder a y responder con. Raramente veo aplicaciones que usan correctamente esta funcionalidad, después de todo si no usas un responder a bloquear todo funciona bien y Rails renderiza tu plantilla correctamente. Golpeamos nuestro controlador de tweets a través de. / tweets / yukihiro_matz y obtén una página HTML llena de los últimos tweets de Matzs. Lo que la gente suele olvidar es que es muy fácil intentar y solicitar un formato diferente del mismo recurso, por ejemplo,. /tweets/yukihiro_matz.json. En este punto, Rails intentará con valentía devolver una representación JSON de los tweets de Matzs, pero no irá bien ya que la vista no existe. Un ActionView :: MissingTemplate el error se levantará y nuestra aplicación explotará de una manera espectacular. Y JSON es un formato legítimo, en una aplicación de alto tráfico es probable que obtenga una solicitud de /tweets/yukihiro_matz.foobar. Tuts + recibe este tipo de solicitudes todo el tiempo (probablemente de los robots que intentan ser inteligentes).

La lección es esta: si no planea devolver una respuesta legítima para un formato particular, evite que sus controladores intenten cumplir con las solicitudes de esos formatos. En el caso de nuestro TweetsController:

clase TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end end

Ahora cuando recibamos solicitudes de formatos espurios obtendremos una más relevante. ActionController :: UnknownFormat error. Nuestros controladores se sienten un poco más apretados, lo cual es una gran cosa cuando se trata de hacerlos más robustos..

Manejando los errores en el camino de los rieles

El problema que tenemos ahora es que, a pesar de nuestro error semánticamente agradable, nuestra aplicación todavía está explotando en la cara de nuestros usuarios. Aquí es donde entra en juego el manejo de excepciones globales. A veces nuestra aplicación producirá errores a los que queremos responder de manera constante, sin importar de dónde provengan (como nuestro ActionController :: UnknownFormat). También hay errores que el marco puede generar antes de que cualquiera de nuestros códigos entre en juego. Un ejemplo perfecto de esto es ActionController :: RoutingError. Cuando alguien solicita una URL que no existe, como / tweets2 / yukihiro_matz, No hay ningún lugar donde podamos conectarnos para rescatar este error, usando el manejo tradicional de excepciones. Aquí es donde Rails ' excepciones_app viene en.

Puede configurar una aplicación Rack en aplicacion.rb para ser llamado cuando se produce un error que no hemos manejado (como nuestro ActionController :: RoutingError o ActionController :: UnknownFormat). La forma en que normalmente verá este uso es configurar su aplicación de rutas como excepciones_app, luego, defina las distintas rutas para los errores que desea manejar y diríjalos a un controlador de errores especiales que cree. Entonces nuestro aplicacion.rb se vería así:

... config.exceptions_app = self.routes ... 

Nuestro rutas.rb entonces contendrá lo siguiente:

… Coincide '/ 404' => 'errores # not_found', a través de:: todos coinciden '/ 406' => 'errores # not_acceptable', vía:: todos coinciden '/ 500' => 'errores # internal_server_error', vía: :todos… 

En este caso nuestro ActionController :: RoutingError sería recogido por el 404 ruta y el ActionController :: UnknownFormat será recogido por el 406 ruta. Hay muchos errores posibles que pueden surgir. Pero mientras manejas los comunes (404, 500, 422 Para comenzar, puedes agregar otros si y cuando suceden..

Dentro de nuestro controlador de errores, ahora podemos representar las plantillas relevantes para cada tipo de error junto con nuestro diseño (si no es un 500) para mantener la marca. También podemos registrar los errores y enviarlos a nuestro servicio de monitoreo, aunque la mayoría de los servicios de monitoreo se conectarán a este proceso automáticamente para que usted no tenga que enviar los errores usted mismo. Ahora, cuando nuestra aplicación explota, lo hace con suavidad, con el código de estado correcto en función del error y una página en la que podemos darle al usuario una idea de lo que sucedió y lo que puede hacer (soporte de contacto): una experiencia infinitamente mejor. Más importante aún, nuestra aplicación parecerá (y en realidad será) mucho más sólida.

Errores múltiples del mismo tipo en un controlador

En cualquier controlador de Rails podemos definir errores específicos para ser manejados globalmente dentro de ese controlador (sin importar en qué acción se produzcan), lo hacemos a través de rescue_from. La pregunta es cuándo usar. rescatado de? Por lo general, me parece que un buen patrón es usarlo para los errores que pueden ocurrir en varias acciones (por ejemplo, el mismo error en más de una acción). Si un error solo será producido por una acción, manéjelo a través de la comenzar ... rescatar ... terminar mecanismo, pero si es probable que obtengamos el mismo error en varios lugares y queramos manejarlo de la misma manera, es un buen candidato para un rescatado de. Digamos nuestro TweetsController también tiene un crear acción:

clase TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end def create… end end

Digamos también que ambas acciones pueden encontrar un TwitterError y si lo hacen, queremos decirle al usuario que algo anda mal con Twitter. Aquí es donde rescatado de puede ser realmente útil:

clase TweetsController < ApplicationController respond_to :html rescue_from TwitterError, with: twitter_error private def twitter_error render :twitter_error end end

Ahora no tenemos que preocuparnos por manejar esto en nuestras acciones, se verán mucho más limpias y podemos / debemos - por supuesto - registrar nuestro error y / o notificar a nuestro servicio de monitoreo de errores dentro de twitter_error método. Si utiliza rescatado de correctamente, no solo puede ayudarlo a hacer su aplicación más robusta, sino que también puede hacer que su código de controlador sea más limpio. Esto hará que sea más fácil mantener y probar su código, haciendo que su aplicación sea un poco más resistente una vez más.


Uso de servicios externos en su aplicación

Es difícil escribir una aplicación importante en estos días sin usar una cantidad de servicios / API externos. En el caso de nuestro TweetsController, Twitter entró en juego a través de una gema de Ruby que envuelve la API de Twitter. Lo ideal sería que hiciéramos todas nuestras llamadas API externas de forma asíncrona, pero no estamos cubriendo el procesamiento asíncrono en este artículo y hay muchas aplicaciones que hacen al menos algunas llamadas de API / red en proceso.

Hacer llamadas de red es una tarea extremadamente propensa a errores y un buen manejo de excepciones es una necesidad. Puede obtener errores de autenticación, problemas de configuración y errores de conectividad. La biblioteca que utiliza puede producir cualquier número de errores de código y luego hay una cuestión de conexiones lentas. Me estoy refiriendo a este punto, pero es tan crucial, ya que no puede manejar conexiones lentas a través del manejo de excepciones. Debe configurar adecuadamente los tiempos de espera en la biblioteca de su red, o si está utilizando un envoltorio de API, asegúrese de que proporciona enlaces para configurar los tiempos de espera. No hay peor experiencia para un usuario que tener que estar sentado allí esperando sin que su aplicación le dé alguna indicación de lo que está sucediendo. Casi todos se olvidan de configurar los tiempos de espera de forma adecuada (lo sé), así que presta atención.

Si está utilizando un servicio externo en múltiples lugares dentro de su aplicación (por ejemplo, múltiples modelos), expone grandes partes de su aplicación a todo el panorama de errores que pueden producirse. Esta no es una buena situacion. Lo que queremos hacer es limitar nuestra exposición y una forma en que podemos hacerlo es poner todo el acceso a nuestros servicios externos detrás de una fachada, rescatar todos los errores allí y volver a generar un error semánticamente apropiado (elevar eso TwitterError de los que hablamos si se producen errores cuando intentamos acceder a la API de Twitter). Entonces podemos usar fácilmente técnicas como rescatado de para hacer frente a estos errores y no exponemos grandes partes de nuestra aplicación a un número desconocido de errores de fuentes externas.

Una idea aún mejor sería hacer de su fachada una API libre de errores. Devuelva todas las respuestas exitosas tal como están y devuelva objetos nulos o nulos cuando recupere cualquier tipo de error (todavía necesitamos registrar / notificarnos a nosotros mismos de los errores a través de algunos de los métodos que analizamos anteriormente). De esta manera, no necesitamos mezclar diferentes tipos de flujo de control (flujo de control de excepción frente a ... más), lo que puede hacer que el código sea mucho más limpio. Por ejemplo, vamos a envolver nuestro acceso a la API de Twitter en un TwitterClient objeto:

clase TwitterClient attr_reader: cliente def initialize @client = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token_acceso_acceso_acceso_acceso_acceso_es_es_es_es_es_es_configuración_asken_acceso_acceso_acceso_es_es_es_confensa_acceso_acceso_acceso_acceso_es_es_es_configuración_asken_acceso_acceso_acceso_acceso_acceso_es_es_es_es_configuración_acceso_acceso_acceso_acceso_acceso_acceso_acceso_acceso_es_es_es_es_es_configuración_acceso_acceso_contratos mapa | tweet | tweet.text rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" nil end end

Ahora podemos hacer esto: TwitterClient.new.latest_tweets ('yukihiro_matz'), En cualquier parte de nuestro código, sabemos que nunca producirá un error o, más bien, nunca propagará el error más allá de TwitterClient. Hemos aislado un sistema externo para asegurarnos de que las fallas en ese sistema no afecten nuestra aplicación principal.


Pero, ¿y si tengo una excelente cobertura de prueba??

Si tiene un código bien probado, lo felicito por su diligencia, le llevará un largo camino para tener una aplicación más robusta. Pero un buen conjunto de pruebas a menudo puede proporcionar una falsa sensación de seguridad. Las buenas pruebas pueden ayudarlo a refactorizar con confianza y protegerlo contra la regresión. Pero, solo puedes escribir pruebas para las cosas que esperas que ocurran. Los insectos son, por su propia naturaleza, inesperados. Para usar nuestro ejemplo de tweets, hasta que decidamos escribir una prueba para nuestro fetch_tweets método donde client.user_timeline (handle) plantea un error que nos obliga a envolver una rescate bloque alrededor del código, todas nuestras pruebas habrán sido verdes y nuestro código habría permanecido propenso a fallas.

Escribir pruebas, no nos exime de la responsabilidad de echar un vistazo crítico sobre nuestro código para descubrir cómo este código puede potencialmente romperse. Por otro lado, hacer este tipo de evaluación definitivamente puede ayudarnos a escribir conjuntos de pruebas mejores y más completos..


Conclusión

Los sistemas resistentes no surgen completamente formados de una sesión de hackeo de fin de semana. Hacer una aplicación robusta, es un proceso continuo. Descubres errores, los arreglas y escribes pruebas para asegurarte de que no vuelvan. Cuando su aplicación falla debido a una falla del sistema externo, usted aísla ese sistema para asegurarse de que la falla no pueda volver a aparecer. El manejo de excepciones es tu mejor amigo cuando se trata de hacer esto. Incluso la aplicación más propensa a fallar puede convertirse en una robusta si aplica buenas prácticas de manejo de excepciones de manera consistente, a lo largo del tiempo.

Por supuesto, el manejo de excepciones no es la única herramienta en su arsenal cuando se trata de hacer que las aplicaciones sean más resistentes. En artículos posteriores hablaremos sobre el procesamiento asíncrono, cómo y cuándo aplicarlo y lo que puede hacer para que su aplicación sea tolerante a fallos. También veremos algunos consejos de implementación e infraestructura que pueden tener un impacto significativo sin romper el banco en términos de dinero y tiempo. Estén atentos.