lunes, 22 de febrero de 2010

.Net MVC + jQuery: manejo de excepciones. III: Atrapar errores del lado del servidor y comunicarlos controladamente al cliente.

[Continuación de .Net MVC + jQuery: manejo de excepciones. II: El problema.]


works-on-my-machine Espero que se haya entendido el planteo desarrollado en los posts anteriores de esta serie (preguntar es gratis, de cualquier manera)… lo que sin duda ha quedado claro es que el problema es retorcido.

Lo que sigue ahora es mi resolución del tema, más que perfectible por cierto (si alguien tiene sugerencias…), pero lo suficientemente funcional y probada como para ostentar orgullosamente el sello que he aplicado a esta entrada.


El primer paso es poder diferenciar, del lado del cliente, si el error o excepción se produjo durante la ejecución del código que procesa una respuesta o si la comunicación en sí fue fallida.

La importancia de lo anterior radica en que en el primer caso manejaremos la excepción del lado del servidor y podemos devolver al cliente información detallada y en un formato conveniente sobre cómo proceder:

  • si es un error de sistema o una excepción de negocio o un error de validación, si mostrar información detallada o no, si bloquear la aplicación y obligar al usuario a reiniciar (o a joderse), etc.
  • si es un error en la comunicación el cliente deberá tomar sus propias decisiones, de acuerdo con la información que posea en ese momento, pero ya contamos con un dato fundamental: la operación no llegó al servidor, por lo que podemos estar seguros de que nada ha sucedido “del otro lado”.

Recordemos que de forma predeterminada el servidor devolverá un error 500 y el código HTML de la pantalla de error asignada a ese código (por defecto aquella tan bonita de .Net con las letras en rojo catástrofe y el cuadro amarillo con detalles, más las inútiles indicaciones que todos conocemos). Esto no es precisamente un formato fácil de interpretar desde el código a la hora de tomar decisiones en el front-end.

Necesitamos interpretar desde el código la excepción porque tenemos que tener en cuenta que muchos tipos de errores “de aplicación” diferentes. Cada situación puede requerir diferentes propiedades en el objeto Exception para transmitir la información relevante del caso. Consideremos por ejemplo:

public class ShowCaseException : ApplicationException
{
   private List<string> reasons = new List<string>();

   public ShowCaseException()
      : base() { }

   public ShowCaseException(string message)
      : base(message) { }

   public ShowCaseException(string message, Exception innerException)
      : base(message, innerException) { }

   public List<string> Reasons
   {
      get { return reasons; }
   }
}

La excepción anterior contiene una propiedad extra (“Reasons”) con una lista de mensajes para presentar al usuario. Esto es algo que suelo utilizar bastante, ya que si durante una operación se producen varios problemas de validación es bueno presentárselos al usuario todos juntos y no solamente el primero y obligarlo a resolverlo antes de probar de vuelta y encontrarse con otro y así, multiplicando la cantidad de intentos.

Pero no podemos tratar con cada tipo de excepción en particular, viendo en cada caso qué información devolver y cómo. Una solución más práctica es devolver la excepción serializada en formato JSON, más un flag en el header de la respuesta que la diferencie de las respuestas “normales”:

internal static class ExceptionHandlingHelper
{
 public enum ResponseStatusEnum
 {
  Normal,
  ApplicationException,
  UnexpectedException
 } 

 internal static void ApplicationInstance_Error(object sender, EventArgs e)
 {
  Exception exception = HttpContext.Current.Server.GetLastError();
  if (exception is ApplicationException)
   ResponseHeadersHelper.CurrentHeaders.Add("RESPONSE_STATUS", ResponseStatusEnum.ApplicationException.ToString());
  else
  {
   Log.WriteException(exception);
   ResponseHeadersHelper.CurrentHeaders.Add("RESPONSE_STATUS", ResponseStatusEnum.UnexpectedException.ToString());
   if (![DEBUG_MODE])
    HttpContext.Current.Session.Abandon();
  }

  HttpContext.Current.Response.Clear();
  HttpContext.Current.Response.Write(ExceptionHandlingHelper.SerializeException(exception));
  HttpContext.Current.Server.ClearError();
 }
 //.... etc ....
}

Tenemos entonces tres estados posibles para una respuesta:

  • Error de negocio: ResponseStatusEnum.ApplicationException, son las excepciones lanzadas mediante un throw en nuestro código, y que deben derivar, por convención, de ApplicationException.
  • Error inesperado: ResponseStatusEnum.UnexpectedException, son todas las demás, originadas en throws por validaciones de consistencia en nuestro código o directamente desde el framework.
  • Normal: todo bien.

Así que lo primero que hacemos en nuestra rutina de manejo de excepciones es conseguir la excepción y determinar de qué tipo es, agregando un header en la respuesta (“RESPONSE_STATUS”) para que el cliente javascript pueda diferenciarlas fácilmente de las respuestas normales. Si es un error inesperado podemos, adicionalmente, registrarlo en el log y limpiar la sesión del usuario.

Luego se modifica la respuesta que por defecto enviaría el servidor, utilizando Response.Clear(), Server.ClearError() y escribiendo en el objeto Response la excepción serializada en JSON.

La serialización de la excepción es un poco molesta dado que el uso común de JavascriptSerializer arroja una excepción por culpa de la propiedad TargetSite, que se vuelve recursiva en cuanto al tipo. Pero, por otro lado, habíamos establecido que en un ambiente de producción no es conveniente enviar toda la información de la excepción ya que puede exponer datos internos a un ocasional atacante. Así que tenemos que implementar nuestra propia serialización para esta clase.

Una forma sencilla es recorrer el primer nivel de propiedades del objeto Exception y dejar que JavascriptSerializer se encargue del resto. El siguiente ejemplo es una prueba de concepto –no pretende ser una guía de lo que es o no seguro enviar al cliente-, solamente omite en la serialización las propiedades “InnerException”, “StackTrace” y “Source”. Tal vez habría que agregar más restricciones, probando siempre qué información estamos enviando en cada caso.

private static string SerializeException(Exception ex)
        {
            JavaScriptSerializer serializer = new JavaScriptSerializer();

            StringBuilder exSerialized = new StringBuilder("{");
            PropertyInfo[] exProperties = ex.GetType().GetProperties(BindingFlags.Public | BindingFlags.FlattenHierarchy | BindingFlags.Instance);
            foreach (PropertyInfo property in exProperties.Where(p => p.Name != "TargetSite"))
            {
                if (![DEBUG_MODE] &&                    
   (property.Name == "InnerException" || property.Name == "Source" || property.Name == "StackTrace"))
                    continue;

                exSerialized.AppendFormat("\"{0}\":", property.Name);
                serializer.Serialize(property.GetValue(ex, null), exSerialized);
                exSerialized.Append(", ");
            }

            if ([DEBUG_MODE]) 
            {
                exSerialized.AppendFormat("\"ToString\":");
                serializer.Serialize(ex.ToString(), exSerialized);
                exSerialized.Append(", ");
            }

            exSerialized.Remove(exSerialized.Length - 2, 2);
            exSerialized.Append("}");
            return exSerialized.ToString();
        }

La información así serializada es fácil de acceder desde el cliente ya que se convierte automáticamente en un objeto similar a Exception, pero en javascript.

Notarán que en el ejemplo incluí, para entornos de desarrollo, una propiedad extra que contiene la cadena devuelta por el método ToString de Exception, que es lo que se suele mostrar por pantalla en caso de errores. Tendremos, en resumen, disponibles en javascript casi todas las propiedades del objeto Exception más una propiedad “ToString” que contiene una cadena con toda esa información en un formato legible para el usuario/desarrollador.

Volvamos a javascript para ver cómo manejamos la respuesta ahora. Partimos del ejemplo del post anterior completando algunos de los “placeholders” que habíamos dejado, ya que ahora sabemos exactamente cómo llega la información desde el servidor:

function Save( data, callback )
{
 var async;
 if( typeof callback == "undefined")
  async=false;
 else
  async=true;
 
 var returnValue = null;
 
 $.ajax({
  url: "Comments/Save",
  data: data,
  async: async,
  success: function (response, status, xhr) {

   //Exception handling.
   var responseStatus = xhr.getResponseHeader("RESPONSE_STATUS");
   var ex = null;
   if (responseStatus == "ApplicationException") 
   {
      var netEx = JSON.parse(response);
      ex = new ProgramException(netEx.Message, netEx);
      ex.Reasons = netEx.Reasons;
   }
   else if (responseStatus == "UnexpectedException") 
   {
      var netEx = JSON.parse(response);
      ex = new FatalException(netEx.Message, netEx);
   }

   if(ex!=null)
   {
      if (async)
         ShowException(ex)
      else
         throw ex;     
   }

   //Normal response handling.
   if (async)
    callback(response)    
   else
    returnValue = response;
  }
  error: function(XMLHttpRequest, textStatus, errorThrown) {
   var ex = [GET_EXCEPTION_INFO];
   if(async)
    ShowException(ex)
   else
    throw ex;
  }
 });

 //async => returnValue == null;
 //sync  => returnValue == response;
 return returnValue;
}

La parte modificada es la de la sección “success” -previa al “Normal response handling”-. Verán que ahora, para determinar si hubo una excepción se consulta el encabezado de la respuesta que incluimos en el manejo de excepciones del lado del servidor -xhr.getResponseHeader("RESPONSE_STATUS")-. No importa qué excepción de negocio se haya producido, este header aparecerá siempre que ésta derive de ApplicationException. En la serialización JSON se devolverán todas las propiedades del tipo específico –en un entorno de desarrollo, recordémoslo siempre- ya que utilizamos JsonSerializer.

Por ejemplo, si la excepción lanzada es aquella ShowCaseException de la que hablamos al principio, sabremos por el header que deriva de ApplicationException y al mismo tiempo conservaremos la propiedad “Reasons”, específica del tipo, al serializar. Si prueban verán que aparece en el objeto netEx cuando se ejecuta var netEx = JSON.parse(response);

Cuando deserializamos la excepción la encapsulamos en un objeto conocido para el cliente. Esto es para reflejar el hecho de que para el cliente de javascript sólo existen dos objetos derivados de Exception relevantes: los de negocio (excepciones “de programa” y los de sistema (“fatales”). Para ello creamos sus clases correspondientes, inspiradas un poco en las de .Net (una excepción base y dos derivadas), pero en estilo javascript. Esto no es necesario, pero me resultó muy cómodo (veremos luego):

//Exception base
ExceptionBase = function (type, message) {
    this.Type = type;
    this.Message = message;
};

//Program Exception
ProgramException = function (message, netException) {

    this.Reasons = [];
    this.NetException = netException;

    if (typeof message != "undefined" && message != null)
        this.Message = message;

};
ProgramException.prototype = new ExceptionBase("ProgramException", "Operation Error");

//Fatal Exception
FatalException = function (message, netException) {

    this.NetException = netException;

    if (typeof message != "undefined" && message != null)
        this.Message = message;

}

FatalException.prototype = new ExceptionBase("FatalException", "Unexpected Error");

Son estas dos clases las que nos permiten codificar luego este tipo de instrucciones:

var netEx = JSON.parse(response);
ex = new ProgramException(netEx.Message, netEx);
ex.Reasons = netEx.Reasons;

Hasta aquí, un paso más en la batalla. Nos resta:

  • Determinar cómo tratar los errores en la comunicación.
  • Determinar cómo mostrar toda esta información (hasta ahora no hicimos nada con el método “ShowException”).

Hasta la próxima, nos leemos. (Actualización: sigue en .Net MVC + jQuery: manejo de excepciones. IV: el lado del cliente.)

No hay comentarios.: