Mejores prácticas para devolver errores en ASP.NET API WEB


Me preocupa la forma en que devolvemos errores al cliente.

¿Devolvemos el error inmediatamente lanzando HttpResponseException cuando obtenemos un error:

public void Post(Customer customer)
{
    if (string.IsNullOrEmpty(customer.Name))
    {
        throw new HttpResponseException("Customer Name cannot be empty", HttpStatusCode.BadRequest) 
    }
    if (customer.Accounts.Count == 0)
    {
         throw new HttpResponseException("Customer does not have any account", HttpStatusCode.BadRequest) 
    }
}

O acumulamos todos los errores y luego los enviamos de vuelta al cliente:

public void Post(Customer customer)
{
    List<string> errors = new List<string>();
    if (string.IsNullOrEmpty(customer.Name))
    {
        errors.Add("Customer Name cannot be empty"); 
    }
    if (customer.Accounts.Count == 0)
    {
         errors.Add("Customer does not have any account"); 
    }
    var responseMessage = new HttpResponseMessage<List<string>>(errors, HttpStatusCode.BadRequest);
    throw new HttpResponseException(responseMessage);
}

Esto es solo un código de ejemplo, no importa los errores de validación o error del servidor, solo me gustaría conocer las mejores prácticas, los pros y los contras de cada enfoque.

Author: Guido Leenders, 2012-05-24

11 answers

Para mí generalmente envío de vuelta un HttpResponseException y establecer el código de estado en consecuencia dependiendo de la excepción lanzada y si la excepción es fatal o no determinará si envío de vuelta el HttpResponseException inmediatamente.

Al final del día es una API que envía respuestas y no vistas, así que creo que está bien enviar un mensaje con la excepción y el código de estado al consumidor. Actualmente no he necesitado acumular errores y enviarlos de vuelta, ya que la mayoría de las excepciones generalmente se deben a errores incorrectos parámetros o llamadas, etc.

Un ejemplo en mi aplicación es que a veces el cliente pedirá datos, pero no hay ningún dato disponible, así que lanzo un noDataAvailableException personalizado y lo dejo burbujear a la aplicación web api, donde luego en mi filtro personalizado que lo captura enviando un mensaje relevante junto con el código de estado correcto.

No estoy 100% seguro de cuál es la mejor práctica para esto, pero esto está funcionando para mí actualmente, así que eso es lo que im hacer.

Actualización :

Desde que respondí a esta pregunta, se han escrito algunos artículos en el blog sobre el tema:

Http://weblogs.asp.net/fredriknormen/archive/2012/06/11/asp-net-web-api-exception-handling.aspx

(este tiene algunas características nuevas en las compilaciones nocturnas) http://blogs.msdn.com/b/youssefm/archive/2012/06/28/error-handling-in-asp-net-webapi.aspx

Actualización 2

Actualización de nuestro proceso de manejo de errores, tenemos dos casos:

  1. Para errores generales como no encontrado, o parámetros no válidos que se pasan a una acción, devolvemos una HttpResponseException para detener el procesamiento inmediatamente. Además, para errores de modelo en nuestras acciones, entregaremos el diccionario de estado del modelo a la extensión Request.CreateErrorResponse y lo envolveremos en una HttpResponseException. Agregar el diccionario de estado del modelo da como resultado una lista de los errores del modelo enviados en el cuerpo de respuesta.

  2. Para los errores que se producen en mayor capas, errores del servidor, dejamos la burbuja de excepción a la aplicación Web API, aquí tenemos un filtro de excepción global que mira la excepción, la registra con elmah y trys para darle sentido al establecer el código de estado http correcto y un mensaje de error amigable relevante como el cuerpo nuevamente en una HttpResponseException. Para las excepciones que no esperamos que el cliente recibirá el error predeterminado del servidor interno 500, pero un mensaje genérico debido a razones de seguridad.

Actualizar 3

Recientemente, después de recoger Web API 2, para enviar errores generales ahora usamos la interfaz IHttpActionResult, específicamente las clases integradas para el Sistema.Web.Http.Espacio de nombres de resultados como NotFound, BadRequest cuando encajan, si no los extendemos, por ejemplo un resultado notfound con un mensaje de respuesta:

public class NotFoundWithMessageResult : IHttpActionResult
{
    private string message;

    public NotFoundWithMessageResult(string message)
    {
        this.message = message;
    }

    public Task<HttpResponseMessage> ExecuteAsync(CancellationToken cancellationToken)
    {
        var response = new HttpResponseMessage(HttpStatusCode.NotFound);
        response.Content = new StringContent(message);
        return Task.FromResult(response);
    }
}
 239
Author: gdp,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-07-10 16:44:47

ASP.NET Web API 2 realmente lo simplificó. Por ejemplo, el siguiente código:

public HttpResponseMessage GetProduct(int id)
{
    Product item = repository.Get(id);
    if (item == null)
    {
        var message = string.Format("Product with id = {0} not found", id);
        HttpError err = new HttpError(message);
        return Request.CreateResponse(HttpStatusCode.NotFound, err);
    }
    else
    {
        return Request.CreateResponse(HttpStatusCode.OK, item);
    }
}

Devuelve el siguiente contenido al navegador cuando el elemento no se encuentra:

HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
Date: Thu, 09 Aug 2012 23:27:18 GMT
Content-Length: 51

{
  "Message": "Product with id = 12 not found"
}

Sugerencia: No arroje el error HTTP 500 a menos que haya un error catastrófico (por ejemplo, Excepción de Falla WCF). Elija un código de estado HTTP apropiado que represente el estado de sus datos. (Véase el apigee enlace de más abajo.)

Enlaces:

 150
Author: Manish Jain,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-09-22 21:56:01

Parece que estás teniendo más problemas con la validación que con errores/excepciones, así que diré un poco sobre ambos.

Validación

Las acciones del controlador generalmente deben tomar Modelos de entrada donde la validación se declara directamente en el modelo.

public class Customer
{ 
    [Require]
    public string Name { get; set; }
}

Luego puede usar un ActionFilter que automáticamente envía mensajes de valor al cliente.

public class ValidationActionFilter : ActionFilterAttribute
{
    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        var modelState = actionContext.ModelState;

        if (!modelState.IsValid) {
            actionContext.Response = actionContext.Request
                 .CreateErrorResponse(HttpStatusCode.BadRequest, modelState);
        }
    }
} 

Para obtener más información acerca de esta salida http://ben.onfabrik.com/posts/automatic-modelstate-validation-in-aspnet-mvc

Manejo de Errores

Es mejor devolver un mensaje al cliente que represente la excepción que ocurrió (con el código de estado relevante).

Fuera de la caja, debe usar Request.CreateErrorResponse(HttpStatusCode, message) si desea especificar un mensaje. Sin embargo, esto vincula el código al objeto Request, lo que no debería hacer.

Normalmente creo mi propio tipo de excepción "segura" que espere que el cliente sepa cómo manejar y envolver todos los demás con un error genérico de 500.

Usar un filtro de acción para manejar las excepciones se vería así:

public class ApiExceptionFilterAttribute : ExceptionFilterAttribute
{
    public override void OnException(HttpActionExecutedContext context)
    {
        var exception = context.Exception as ApiException;
        if (exception != null) {
            context.Response = context.Request.CreateErrorResponse(exception.StatusCode, exception.Message);
        }
    }
}

Entonces puedes registrarlo globalmente.

GlobalConfiguration.Configuration.Filters.Add(new ApiExceptionFilterAttribute());

Este es mi tipo de excepción personalizado.

using System;
using System.Net;

namespace WebApi
{
    public class ApiException : Exception
    {
        private readonly HttpStatusCode statusCode;

        public ApiException (HttpStatusCode statusCode, string message, Exception ex)
            : base(message, ex)
        {
            this.statusCode = statusCode;
        }

        public ApiException (HttpStatusCode statusCode, string message)
            : base(message)
        {
            this.statusCode = statusCode;
        }

        public ApiException (HttpStatusCode statusCode)
        {
            this.statusCode = statusCode;
        }

        public HttpStatusCode StatusCode
        {
            get { return this.statusCode; }
        }
    }
}

Una excepción de ejemplo que mi API puede lanzar.

public class NotAuthenticatedException : ApiException
{
    public NotAuthenticatedException()
        : base(HttpStatusCode.Forbidden)
    {
    }
}
 61
Author: Daniel Little,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-03-23 04:06:27

Puede lanzar una HttpResponseException

HttpResponseMessage response = 
    this.Request.CreateErrorResponse(HttpStatusCode.BadRequest, "your message");
throw new HttpResponseException(response);
 29
Author: tartakynov,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2014-03-04 05:15:18

Para Web API 2 mis métodos devuelven consistentemente IHttpActionResult así que los uso...

public IHttpActionResult Save(MyEntity entity)
{
  ....

    return ResponseMessage(
        Request.CreateResponse(
            HttpStatusCode.BadRequest, 
            validationErrors));
}
 14
Author: Mick,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-12-03 23:49:45

Puede usar ActionFilter personalizado en Web Api para validar el modelo

public class DRFValidationFilters : ActionFilterAttribute
{

    public override void OnActionExecuting(HttpActionContext actionContext)
    {
        if (!actionContext.ModelState.IsValid)
        {
            actionContext.Response = actionContext.Request
                 .CreateErrorResponse(HttpStatusCode.BadRequest, actionContext.ModelState);

            //BadRequest(actionContext.ModelState);
        }
    }
    public override Task OnActionExecutingAsync(HttpActionContext actionContext, CancellationToken cancellationToken)
    {

        return Task.Factory.StartNew(() => {

            if (!actionContext.ModelState.IsValid)
            {
                actionContext.Response = actionContext.Request
                     .CreateErrorResponse(HttpStatusCode.BadRequest, actionContext.ModelState);                    
            }
        });

    }

public class AspirantModel
{
    public int AspirantId { get; set; }
    public string FirstName { get; set; }
    public string MiddleName { get; set; }        
    public string LastName { get; set; }
    public string AspirantType { get; set; }       
    [RegularExpression(@"^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$", ErrorMessage = "Not a valid Phone number")]
    public string MobileNumber { get; set; }
    public int StateId { get; set; }
    public int CityId { get; set; }
    public int CenterId { get; set; }

}

    [HttpPost]
    [Route("AspirantCreate")]
    [DRFValidationFilters]
    public IHttpActionResult Create(AspirantModel aspirant)
    {
            if (aspirant != null)
            {

            }
            else
            {
                return Conflict();
            }
          return Ok();

}

Registrar la clase CustomAttribute en WebApiConfig.cs config.Filtro.Add (new DRFValidationFilters());

 4
Author: LokeshChikkala,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-03-18 09:50:23

Construyendo sobre la respuesta de Manish Jain (que está destinada a Web API 2 que simplifica las cosas):

1) Utilice estructuras de validación para responder a tantos errores de validación como sea posible. Estas estructuras también se pueden utilizar para responder a solicitudes procedentes de formularios.

public class FieldError
{
    public String FieldName { get; set; }
    public String FieldMessage { get; set; }
}

// a result will be able to inform API client about some general error/information and details information (related to invalid parameter values etc.)
public class ValidationResult<T>
{
    public bool IsError { get; set; }

    /// <summary>
    /// validation message. It is used as a success message if IsError is false, otherwise it is an error message
    /// </summary>
    public string Message { get; set; } = string.Empty;

    public List<FieldError> FieldErrors { get; set; } = new List<FieldError>();

    public T Payload { get; set; }

    public void AddFieldError(string fieldName, string fieldMessage)
    {
        if (string.IsNullOrWhiteSpace(fieldName))
            throw new ArgumentException("Empty field name");

        if (string.IsNullOrWhiteSpace(fieldMessage))
            throw new ArgumentException("Empty field message");

        // appending error to existing one, if field already contains a message
        var existingFieldError = FieldErrors.FirstOrDefault(e => e.FieldName.Equals(fieldName));
        if (existingFieldError == null)
            FieldErrors.Add(new FieldError {FieldName = fieldName, FieldMessage = fieldMessage});
        else
            existingFieldError.FieldMessage = $"{existingFieldError.FieldMessage}. {fieldMessage}";

        IsError = true;
    }

    public void AddEmptyFieldError(string fieldName, string contextInfo = null)
    {
        AddFieldError(fieldName, $"No value provided for field. Context info: {contextInfo}");
    }
}

public class ValidationResult : ValidationResult<object>
{

}

2) Service layer devolverá ValidationResult s, independientemente de que la operación sea exitosa o no. Por ejemplo:

    public ValidationResult DoSomeAction(RequestFilters filters)
    {
        var ret = new ValidationResult();

        if (filters.SomeProp1 == null) ret.AddEmptyFieldError(nameof(filters.SomeProp1));
        if (filters.SomeOtherProp2 == null) ret.AddFieldError(nameof(filters.SomeOtherProp2 ), $"Failed to parse {filters.SomeOtherProp2} into integer list");

        if (filters.MinProp == null) ret.AddEmptyFieldError(nameof(filters.MinProp));
        if (filters.MaxProp == null) ret.AddEmptyFieldError(nameof(filters.MaxProp));


        // validation affecting multiple input parameters
        if (filters.MinProp > filters.MaxProp)
        {
            ret.AddFieldError(nameof(filters.MinProp, "Min prop cannot be greater than max prop"));
            ret.AddFieldError(nameof(filters.MaxProp, "Check"));
        }

        // also specify a global error message, if we have at least one error
        if (ret.IsError)
        {
            ret.Message = "Failed to perform DoSomeAction";
            return ret;
        }

        ret.Message = "Successfully performed DoSomeAction";
        return ret;
    }

3) API Controller construirá la respuesta basada en la función de servicio resultado

Una opción es poner prácticamente todos los parámetros como opcionales y realizar una validación personalizada que devuelva una respuesta más significativa. Además, estoy teniendo cuidado de no permitir que ninguna excepción vaya más allá del límite de servicio.

    [Route("DoSomeAction")]
    [HttpPost]
    public HttpResponseMessage DoSomeAction(int? someProp1 = null, string someOtherProp2 = null, int? minProp = null, int? maxProp = null)
    {
        try
        {
            var filters = new RequestFilters 
            {
                SomeProp1 = someProp1 ,
                SomeOtherProp2 = someOtherProp2.TrySplitIntegerList() ,
                MinProp = minProp, 
                MaxProp = maxProp
            };

            var result = theService.DoSomeAction(filters);
            return !result.IsError ? Request.CreateResponse(HttpStatusCode.OK, result) : Request.CreateResponse(HttpStatusCode.BadRequest, result);
        }
        catch (Exception exc)
        {
            Logger.Log(LogLevel.Error, exc, "Failed to DoSomeAction");
            return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, new HttpError("Failed to DoSomeAction - internal error"));
        }
    }
 2
Author: Alexei,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-12-19 14:32:01

Utilice el método integrado "InternalServerError" (disponible en ApiController):

return InternalServerError();
//or...
return InternalServerError(new YourException("your message"));
 1
Author: Rusty,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-08-04 15:03:14

Si está utilizando ASP.NET Web API 2, la forma más fácil es usar el Método Corto ApiController. Esto dará lugar a un mal resultado.

return BadRequest("message");
 1
Author: Fabian von Ellerts,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2018-03-22 12:22:48

Solo para actualizar el estado actual de ASP.NET WebAPI. La interfaz ahora se llama IActionResult y la implementación no ha cambiado mucho:

[JsonObject(IsReference = true)]
public class DuplicateEntityException : IActionResult
{        
    public DuplicateEntityException(object duplicateEntity, object entityId)
    {
        this.EntityType = duplicateEntity.GetType().Name;
        this.EntityId = entityId;
    }

    /// <summary>
    ///     Id of the duplicate (new) entity
    /// </summary>
    public object EntityId { get; set; }

    /// <summary>
    ///     Type of the duplicate (new) entity
    /// </summary>
    public string EntityType { get; set; }

    public Task ExecuteResultAsync(ActionContext context)
    {
        var message = new StringContent($"{this.EntityType ?? "Entity"} with id {this.EntityId ?? "(no id)"} already exist in the database");

        var response = new HttpResponseMessage(HttpStatusCode.Ambiguous) { Content = message };

        return Task.FromResult(response);
    }

    #endregion
}
 0
Author: Thomas Hagström,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-05-10 07:51:29

Para aquellos errores donde modelstate.isvalid es false, generalmente envío el error ya que es lanzado por el código. Es fácil de entender para el desarrollador que está consumiendo mi servicio. Generalmente envío el resultado usando el siguiente código.

     if(!ModelState.IsValid) {
                List<string> errorlist=new List<string>();
                foreach (var value in ModelState.Values)
                {
                    foreach(var error in value.Errors)
                    errorlist.Add( error.Exception.ToString());
                    //errorlist.Add(value.Errors);
                }
                HttpResponseMessage response = Request.CreateResponse(HttpStatusCode.BadRequest,errorlist);}

Esto envía el error al cliente en el siguiente formato que es básicamente una lista de errores:

    [  
    "Newtonsoft.Json.JsonReaderException: **Could not convert string to integer: abc. Path 'Country',** line 6, position 16.\r\n   
at Newtonsoft.Json.JsonReader.ReadAsInt32Internal()\r\n   
at Newtonsoft.Json.JsonTextReader.ReadAsInt32()\r\n   
at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.ReadForType(JsonReader reader, JsonContract contract, Boolean hasConverter, Boolean inArray)\r\n   
at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.PopulateObject(Object newObject, JsonReader reader, JsonObjectContract contract, JsonProperty member, String id)",

       "Newtonsoft.Json.JsonReaderException: **Could not convert string to integer: ab. Path 'State'**, line 7, position 13.\r\n   
at Newtonsoft.Json.JsonReader.ReadAsInt32Internal()\r\n   
at Newtonsoft.Json.JsonTextReader.ReadAsInt32()\r\n   
at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.ReadForType(JsonReader reader, JsonContract contract, Boolean hasConverter, Boolean inArray)\r\n   
at Newtonsoft.Json.Serialization.JsonSerializerInternalReader.PopulateObject(Object newObject, JsonReader reader, JsonObjectContract contract, JsonProperty member, String id)"
    ]
 -2
Author: Ashish Sahu,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-09-19 23:53:04