Как стать автором
Обновить

Как обрабатывать необработанные исключения в ASP.NET Core Web API

Время на прочтение6 мин
Количество просмотров8.4K

Я Антон Антонов, Full Stack разработчик. Расскажу вам о том, как я обрабатываю необработанные исключения в ASP.NET Core Web API. Существует множество статей на эту тему. Я попытаюсь описать свой подход, поделиться своим опытом и идеями, а также предоставлю вам примеры обработчиков ошибок, которые соответствуют наиболее распространенным требованиям.

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

[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 const string UnhandledExceptionMsg = "An unhandled exception has occurred while executing the request.";

    private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web)
    {
        Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
    };

    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 = "The request was aborted by the client.";
            _logger.LogDebug(exception, message);

            context.Response.Clear();
            context.Response.StatusCode = 499; //Client Closed Request
        }
        catch (Exception exception)
        {
            exception.AddErrorCode();
            _logger.LogError(exception, exception is YourAppException ? exception.Message : UnhandledExceptionMsg);

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

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

    private ProblemDetails CreateProblemDetails(in HttpContext context, in Exception exception)
    {
        var errorCode = exception.GetErrorCode();
        var statusCode = context.Response.StatusCode;
        var reasonPhrase = ReasonPhrases.GetReasonPhrase(statusCode);
        if (string.IsNullOrEmpty(reasonPhrase))
        {
            reasonPhrase = UnhandledExceptionMsg;
        }

        var problemDetails = new ProblemDetails
        {
            Status = statusCode,
            Title = reasonPhrase,
            Extensions =
            {
                [nameof(errorCode)] = errorCode
            }
        };

        if (!_env.IsDevelopmentOrQA())
        {
            return problemDetails;
        }

        problemDetails.Detail = exception.ToString();
        problemDetails.Extensions["traceId"] = context.TraceIdentifier;
        problemDetails.Extensions["data"] = exception.Data;

        return problemDetails;
    }

    private string ToJson(in ProblemDetails problemDetails)
    {
        try
        {
            return JsonSerializer.Serialize(problemDetails, SerializerOptions);
        }
        catch (Exception ex)
        {
            const string msg = "An exception has occurred while serializing error to JSON.";
            _logger.LogError(ex, msg);
        }

        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];
}

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

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

P.S.: В .NET Core 8.0 представлен новый интерфейс IExceptionHandler. Тем, кто уже обновился до .NET 8.0 или рассматривает возможность перехода, я настоятельно рекомендую использовать этот интерфейс. Для полного понимания изменений обратитесь к статье под названием "Обработка ошибок с помощью IExceptionHandler в ASP.NET Core 8.0".

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0+3
Комментарии12

Публикации

Истории

Работа

Ближайшие события