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

[HttpGet("{id:int}")]
public async Task<ActionResult<Order>> Get(int id, CancellationToken cancellationToken)
{
    var order = await _ordersService.Get(id, cancellationToken);
    if (order == null)
    {
        return NotFound();
    }

    return Ok(order);
}

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

Также избегайте такого состояния API, когда бросить исключение и отправить ошибку 500 — это единственный способ ответить на запрос. Такие ситуации должны стать поводом для рефакторинга дизайна вашего API и use cases. Отправляйте ошибку 500 только в исключительных необработанных случаях, таких как проблемы с базой данных, системные ошибки и т. п.

Существует несколько способов добавить обработку исключений в ASP.NET Core. Это Exception Filters, Exception handler lambda и Middleware. Я рекомендую последний. Middleware отлавливает ошибки из конструкторов контроллеров, фильтров и обработчиков, ошибки маршрутизации и т. п.

Реализуйте интерфейс IMiddleware и зарегистрируйте этот класс в Startup.cs, как в коде ниже. Обработчик ошибок должен быть первым в конвейере, чтобы ловить любые исключения при обработке запроса.

public class Startup
{
    public void ConfigureServices(IServiceCollection services)
    {
        // Middlewares
        services.AddTransient<ErrorHandlerMiddleware>();
        services.AddTransient<YourCustomMiddleware>();

        services.AddControllers();
    }

    public void Configure(IApplicationBuilder app)
    {
        app.UseMiddleware<ErrorHandlerMiddleware>(); // Should be always in the first place

        app.UseRouting();
        app.UseCors();

        app.UseAuthentication();
        app.UseAuthorization();

        app.UseMiddleware<YourCustomMiddleware>();
        app.UseEndpoints(endpoints => { endpoints.MapControllers(); });
    }
}

Если исключение не обработано, клиенты API получат Unknown Error. Простейший обработчик ошибок должен поймать исключение, залогировать его и отправить статус Internal Server Error. Код ниже добавляет C#-класс, который делает все перечисленное.

public class ErrorHandlerMiddleware : IMiddleware
{
    private readonly ILogger<ErrorHandlerMiddleware> _logger;

    public ErrorHandlerMiddleware(ILogger<ErrorHandlerMiddleware> logger)
    {
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception exception)
        {
            const string message = "An unhandled exception has occurred while executing the request.";
            _logger.LogError(exception, message);
            
            context.Response.Clear();
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
        }
    }
}

Я уверен, что нет необходимости проверять свойство context.Response.HasStarted. Его достаточно хорошо обрабатывает сам .NET, бросая InvalidOperationException с подробным сообщением. Как это выглядит в консоли в нашем случае:

Достаточно ли этого? У меня в проектах обычно немного больше требований к обработке ошибок. Вот они:

  1. Логируйте больше деталей об исключении. Не добавляйте эти сведения в сообщение исключения. (О том, как использовать свойство Exception.Data, чтобы логировать дополнительные сведения об исключениях, вы можете прочитать в моей предыдущей статье.)

  2. Не отправляйте секретную внутреннюю информацию клиентам Web API, такую как stack trace, exception data и т. п.

  3. Не обрабатывайте TaskCanceledException как внутреннюю ошибку сервера, когда причина исключения в отмене запроса клиентом, так что наиболее подходящий HTTP-ответ в этом случае — это 499.

  4. Используйте JSON как наиболее подходящий веб-формат для обработки ошибок на стороне клиента.

  5. Текст ошибок переводится на другие языки, поэтому лучше не показывать пользователю сообщение исключения. Это должно быть что-то, что можно легко перевести, например: «Ой! Что-то пошло не так». Также в сообщении должен быть какой-нибудь уникальный код, с которым пользователь может обратиться в службу поддержки вашего приложения.

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

Ниже пример более сложного обработчика ошибок, который соответствует этим требованиям.

public class ErrorHandlerMiddleware : IMiddleware
{
    private static readonly JsonSerializerOptions SerializerOptions = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
        WriteIndented = true
    };

    private readonly IWebHostEnvironment _env;
    private readonly ILogger<ErrorHandlerMiddleware> _logger;

    public ErrorHandlerMiddleware(IWebHostEnvironment env, ILogger<ErrorHandlerMiddleware> logger)
    {
        _env = env;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context, RequestDelegate next)
    {
        try
        {
            await next(context);
        }
        catch (Exception exception) when (context.RequestAborted.IsCancellationRequested)
        {
            const string message = "Request was cancelled";
            _logger.LogInformation(message);
            _logger.LogDebug(exception, message);

            context.Response.Clear();
            context.Response.StatusCode = 499; //Client Closed Request
        }
        catch (Exception exception)
        {
            exception.AddErrorCode();
            const string message = "An unhandled exception has occurred while executing the request.";
            _logger.LogError(exception, exception is YourAppException ? exception.Message : message);

            const string contentType = "application/json";
            context.Response.Clear();
            context.Response.StatusCode = StatusCodes.Status500InternalServerError;
            context.Response.ContentType = contentType;

            var json = ToJson(exception);
            await context.Response.WriteAsync(json);
        }
    }

    private string ToJson(in Exception exception)
    {
        var message = exception.Message;
        var code = exception.GetErrorCode();
        if (!_env.IsDevelopmentOrQA())
        {
            return JsonSerializer.Serialize(new { message, code }, SerializerOptions);
        }

        try
        {
            var info = exception.ToString();
            var data = exception.Data;
            var error = new { message, code, info, data };
            return JsonSerializer.Serialize(error, SerializerOptions);
        }
        catch (Exception ex)
        {
            const string mes = "An exception has occurred while serializing error to JSON";
            _logger.LogError(ex, mes);
        }

        return string.Empty;
    }
}

Я предлагаю использовать хэш-код исключения как код ошибки, чтобы отправлять пользователям один и тот же код на схожие проблемы. Чтобы создать короткий код, подойдет любой хэш-алгоритм. Я применяю наиболее доступный SHA-1, затем обрезаю результат до длины, достаточной для того, чтобы сохранить уникальность кода ошибки. Расширение для класса Exception, создающее короткий код ошибки, добавляется с помощью кода ниже.

private const string ErrorCodeKey = "errorCode";

public static Exception AddErrorCode(this Exception exception)
{
    using var sha1 = SHA1.Create();
    var hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(exception.ToString()));
    var errorCode = string.Concat(hash[..5].Select(b => b.ToString("x")));
    exception.Data[ErrorCodeKey] = errorCode;
    return exception;
}

public static string GetErrorCode(this Exception exception)
{
    return (string)exception.Data[ErrorCodeKey];
}

Простой пример всплывающего окна об ошибке на стороне клиента.

Я надеюсь, этот подход поможет вам в поддержке приложений. Буду благодарен за вопросы и комментарии к статье :)

Комментарии (11)


  1. TimeCoder
    16.03.2022 13:43
    +1

    Хэш, как код ошибки (часть системы), брать от сообщения (представление, которое может меняться со временем, а также быть на разных языках) - идея прям не очень.


  1. lair
    16.03.2022 14:48
    +1

    ASP.NET Web API

    Так ASP.NET или ASP.NET Core?


    Не обрабатывайте TaskCanceledException как внутреннюю ошибку сервера, потому что таковой она не является: причина исключения в отмене запроса клиентом, так что наиболее подходящий HTTP-ответ — это 499.

    Совершенно не обязательно причиной Task Canceled является отмена клиентом.


    Я применяю наиболее доступный SHA-1, затем обрезаю результат до длины, достаточной для того, чтобы сохранить уникальность кода ошибки.

    Эээ, а как вы гарантируете, что ваши 5 байт — уникальны?


    1. AntonAntonov88 Автор
      16.03.2022 16:00

      Так ASP.NET или ASP.NET Core?

      Имеется ввиду ASP.NET Core, так как meddlewares появились с ASP.NET Core, но так как официально Microsoft с выходом .NET 5 перестал добавлять в название технологий слово Core, а так же если вы откроете сайт ASP.NET, то вы не увидете упоминание слова Core, поэтому я также решил его не добавлять, не усложняя тем самым и без того длинное название ASP.NET Web Api.

      Совершенно не обязательно причиной Task Canceled является отмена клиентом.

      Да вы правы, но в обработчике обрабатывается именно случай отмены запроса пользователем, о чем говорит проверка свойста context.RequestAborted.IsCancellationRequested

      Эээ, а как вы гарантируете, что ваши 5 байт — уникальны?

      В примере длина кода 10 символов, соответственно это 10 байт, с достаточной вероятность код будет уникальный. exception.ToString() в базовой реализации возвращает текстовое представление включающее текст сообщения, тип исключения и stack trace. Я рекомендую не добавлять деталей связанных с конкретным запросом в текст сообщения, о чем написано в статье, соответственно если пользователи столкнутся с одной и той же проблемой то stack trace, тип ошибки и текст сообщения будут одинаковыми и хэш соответственно тоже. Но вы можете использовать для формирования кода ошибки любой другой предпочтительный для вас способ, я лишь предложил свою идею. Спасибо.


      1. lair
        16.03.2022 16:03
        +1

        Да вы правы, но в обработчике обрабатывается именно случай отмены запроса пользователем, о чем говорит проверка свойста context.RequestAborted.IsCancellationRequested

        … и не проверяется, что эксепшн — TaskCanceled. У вас код противоречит тексту.


        В примере длина кода 10 символов, соответственно это 10 байт

        Пять байт. Ваш же код:


        hash[..5].Select(b => b.ToString("x"))

        с достаточной вероятность код будет уникальный.

        Достаточной — это какой? Сколько коллизий на тысячу ошибок?


        1. AntonAntonov88 Автор
          16.03.2022 21:01

          … и не проверяется, что эксепшн — TaskCanceled. У вас код противоречит тексту.

          Внес уточнение в текст.

          Пять байт. Ваш же код:

          Да код мой, извините действительно 5 байт, поторопился с ответом.

          Достаточной — это какой? Сколько коллизий на тысячу ошибок?

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


          1. lair
            16.03.2022 21:07
            +1

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

            Ну как, берете 100500 разных ошибок и смотрите, сколько разных кодов вы получите.


            Но простая математика говорит нам, что даже при идеальном распределении (которого не будет) у вас не может быть больше, чем 256^5 ошибок. Это, казалось бы, много, но рекомендую помнить, что два разных стектрейса дадут разные ошибки, поэтому это пространство будет выбито раньше, чем вы думаете.


      1. shai_hulud
        16.03.2022 16:05
        +1

        Имеется ввиду ASP.NET Core, так как meddlewares появились с ASP.NET Core, но так как официально Microsoft с выходом .NET 5 перестал добавлять в название технологий слово Core, а так же если вы откроете сайт ASP.NET, то вы не увидете упоминание слова Core, поэтому я также решил его не добавлять, не усложняя тем самым и без того длинное название ASP.NET Web Api.


        Если не добавлять Core, то получается вы ссылаетесь на ASP.NET Web API другую технологию.

        У вас ASP.NET Core.


        1. AntonAntonov88 Автор
          16.03.2022 20:54

          Технология на которую вы ссылаетесь почти 3 года назад была полностью заменена ASP.NET Core, я добавлю Core в заголовок, спасибо.


          1. lair
            16.03.2022 21:04

            Технология на которую вы ссылаетесь почти 3 года назад была полностью заменена ASP.NET Core

            То-то я до сих пор работаю с этой "полностью замененной технологией", ага.


  1. mvv-rus
    17.03.2022 01:00


    public void Configure(IApplicationBuilder app)
        {
            app.UseMiddleware<ErrorHandlerMiddleware>(); // Should be always in the first 
         ...
    

    Может быть, лучше добавление такой инфраструктурной вещи сделать через IStartupFilter (подробности об этом механизме см., например, здесь)? Тогда ваш компонент ErrorHandlerMiddleware гарантированно будет добавлен в конвейер раньше всего, что добавляется через метод Configure Startup-класса, и не потребуется писать комментарий и надеяться, что его таки прочтут ;-).


    1. AntonAntonov88 Автор
      17.03.2022 12:09

      Спасибо за совет, использование IStartupFilter выходит за рамки этой статьи. Целью кода о котором вы говорите было как можно более наглядно и просто показать регистрацию middleware в начале конвейера обработки запроса.