Ввести доменные исключения и ресурсы текстов ошибок

DealException/NotFoundException/Validation/Conflict/ServiceUnavailable с кодом ошибки, тексты в ErrorMessages.resx. Общий HTTP-обработчик (DealExceptionHandler) и маппинг в gRPC-интерцепторе: доменные ошибки → статус, прочие → обобщённый текст без стектрейса. Правила закреплены в код-стайле §10.
This commit is contained in:
2026-09-13 14:32:20 +03:00
parent a2da86ced7
commit 7c43c40282
15 changed files with 485 additions and 11 deletions
@@ -0,0 +1,83 @@
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Resources;
using Deal.SharedKernel.Tenants.Abstractions;
using Microsoft.AspNetCore.Diagnostics;
namespace Deal.Api.Middleware;
public sealed class DealExceptionHandler(ILogger<DealExceptionHandler> logger) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
(int statusCode, string errorCode, string detail) = Resolve(exception);
LogFailure(httpContext, exception, statusCode, errorCode);
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(
new { detail, code = errorCode },
cancellationToken);
return true;
}
// Доменные ошибки отдаются по коду; прочие — обобщённый 500 без деталей и стектрейса.
private static (int StatusCode, string ErrorCode, string Detail) Resolve(Exception exception)
=> exception is DealException dealException
? (MapStatusCode(dealException.ErrorCode), dealException.ErrorCode, dealException.Message)
: (StatusCodes.Status500InternalServerError,
DealErrorCodes.Internal,
ErrorResources.Format(ErrorResourceKeys.UnexpectedError));
// Код ошибки Deal → статус HTTP.
private static int MapStatusCode(string errorCode) => errorCode switch
{
DealErrorCodes.NotFound => StatusCodes.Status404NotFound,
DealErrorCodes.Validation => StatusCodes.Status400BadRequest,
DealErrorCodes.Conflict => StatusCodes.Status409Conflict,
DealErrorCodes.Unavailable => StatusCodes.Status503ServiceUnavailable,
_ => StatusCodes.Status500InternalServerError,
};
// Доменные ошибки — Warning без стектрейса; непредвиденные — Error со стектрейсом (только в лог).
private void LogFailure(
HttpContext context,
Exception exception,
int statusCode,
string errorCode)
{
string method = context.Request.Method;
string path = context.Request.Path.Value ?? "/";
string tenantId = ResolveTenantId(context);
if (exception is DealException dealException)
{
logger.LogWarning(
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}: {Message}",
method,
path,
statusCode,
errorCode,
tenantId,
context.TraceIdentifier,
dealException.Message);
return;
}
logger.LogError(
exception,
"HTTP {Method} {Path} -> {StatusCode} {ErrorCode}; tenant={TenantId} trace={TraceId}",
method,
path,
statusCode,
errorCode,
tenantId,
context.TraceIdentifier);
}
// Идентификатор тенанта запроса; вне tenant-запроса — "-".
private static string ResolveTenantId(HttpContext context)
{
ITenantContext? tenantContext = context.RequestServices?.GetService<ITenantContext>();
return tenantContext?.TenantId?.Value ?? "-";
}
}
+3
View File
@@ -133,6 +133,8 @@ TokenLimitDefaults tenantLimitDefaults = new(
builder.Services.AddDealPersistence(tenantLimitDefaults);
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
builder.Services.AddExceptionHandler<DealExceptionHandler>();
builder.Services.AddProblemDetails();
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
builder.Services.AddSingleton(mlOptions);
@@ -326,6 +328,7 @@ if (forwardedHeadersConfig.Enabled)
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
}
app.UseExceptionHandler();
app.UseMiddleware<HttpAccessLogMiddleware>();
app.UseCors(corsPolicyName);
@@ -0,0 +1,32 @@
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Коды ошибок Deal для логов и ответов клиенту.
/// </summary>
public static class DealErrorCodes
{
/// <summary>
/// Запрошенный объект не найден.
/// </summary>
public const string NotFound = "not_found";
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public const string Validation = "validation_error";
/// <summary>
/// Конфликт состояния.
/// </summary>
public const string Conflict = "conflict";
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public const string Unavailable = "unavailable";
/// <summary>
/// Непредвиденная внутренняя ошибка.
/// </summary>
public const string Internal = "internal_error";
}
@@ -0,0 +1,35 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// База доменных исключений Deal: код ошибки и текст из ресурсов.
/// </summary>
public abstract class DealException : Exception
{
protected DealException(
string errorCode,
string messageKey,
params object?[] messageArgs)
: base(ErrorResources.Format(messageKey, messageArgs))
{
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
ErrorCode = errorCode;
}
protected DealException(
string errorCode,
Exception innerException,
string messageKey,
params object?[] messageArgs)
: base(ErrorResources.Format(messageKey, messageArgs), innerException)
{
ArgumentException.ThrowIfNullOrWhiteSpace(errorCode);
ErrorCode = errorCode;
}
/// <summary>
/// Код ошибки для логов и ответов клиенту.
/// </summary>
public string ErrorCode { get; }
}
@@ -0,0 +1,19 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Запрошенный объект не найден.
/// </summary>
public sealed class NotFoundException : DealException
{
public NotFoundException(string entityName)
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntity, entityName)
{
}
public NotFoundException(string entityName, string entityId)
: base(DealErrorCodes.NotFound, ErrorResourceKeys.NotFoundEntityWithId, entityName, entityId)
{
}
}
@@ -0,0 +1,19 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public sealed class ServiceUnavailableException : DealException
{
public ServiceUnavailableException(string serviceName)
: base(DealErrorCodes.Unavailable, ErrorResourceKeys.ServiceUnavailable, serviceName)
{
}
public ServiceUnavailableException(string serviceName, Exception innerException)
: base(DealErrorCodes.Unavailable, innerException, ErrorResourceKeys.ServiceUnavailable, serviceName)
{
}
}
@@ -0,0 +1,14 @@
using Deal.SharedKernel.Resources;
namespace Deal.SharedKernel.Errors;
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public sealed class ValidationException : DealException
{
public ValidationException(string messageKey, params object?[] messageArgs)
: base(DealErrorCodes.Validation, messageKey, messageArgs)
{
}
}
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="UnexpectedError" xml:space="preserve">
<value>Внутренняя ошибка сервиса. Обратитесь в поддержку.</value>
</data>
<data name="NotFoundEntity" xml:space="preserve">
<value>Объект не найден: {0}.</value>
</data>
<data name="NotFoundEntityWithId" xml:space="preserve">
<value>Объект не найден: {0} (id: {1}).</value>
</data>
<data name="ValidationFailed" xml:space="preserve">
<value>Некорректные данные запроса: {0}.</value>
</data>
<data name="ConflictState" xml:space="preserve">
<value>Конфликт состояния: {0}.</value>
</data>
<data name="ServiceUnavailable" xml:space="preserve">
<value>Сервис «{0}» временно недоступен.</value>
</data>
</root>
@@ -0,0 +1,37 @@
namespace Deal.SharedKernel.Resources;
/// <summary>
/// Ключи текстов ошибок Deal в ресурсах ErrorMessages.resx.
/// </summary>
public static class ErrorResourceKeys
{
/// <summary>
/// Общая непредвиденная внутренняя ошибка.
/// </summary>
public const string UnexpectedError = "UnexpectedError";
/// <summary>
/// Объект не найден (без идентификатора).
/// </summary>
public const string NotFoundEntity = "NotFoundEntity";
/// <summary>
/// Объект не найден (с идентификатором).
/// </summary>
public const string NotFoundEntityWithId = "NotFoundEntityWithId";
/// <summary>
/// Некорректные данные запроса.
/// </summary>
public const string ValidationFailed = "ValidationFailed";
/// <summary>
/// Конфликт состояния.
/// </summary>
public const string ConflictState = "ConflictState";
/// <summary>
/// Внешний сервис недоступен.
/// </summary>
public const string ServiceUnavailable = "ServiceUnavailable";
}
@@ -0,0 +1,34 @@
using System.Globalization;
using System.Resources;
namespace Deal.SharedKernel.Resources;
/// <summary>
/// Тексты ошибок Deal из ресурсов ErrorMessages.resx.
/// </summary>
public static class ErrorResources
{
private const string ResourceBaseName = "Deal.SharedKernel.Resources.ErrorMessages";
private static readonly ResourceManager Manager = new(ResourceBaseName, typeof(ErrorResources).Assembly);
/// <summary>
/// Форматированный текст по ключу ресурса с подстановкой аргументов.
/// </summary>
/// <param name="key">Ключ ресурса (см. <see cref="ErrorResourceKeys"/>).</param>
/// <param name="args">Аргументы шаблона.</param>
/// <returns>Текст ресурса; неизвестный ключ возвращается как есть.</returns>
public static string Format(string key, params object?[] args)
{
ArgumentException.ThrowIfNullOrWhiteSpace(key);
string? template = Manager.GetString(key, CultureInfo.CurrentUICulture);
if (string.IsNullOrEmpty(template))
{
return key;
}
return args.Length == 0
? template
: string.Format(CultureInfo.CurrentUICulture, template, args);
}
}
@@ -0,0 +1,83 @@
using System.Text.Json;
using Deal.Api.Middleware;
using Deal.SharedKernel.Errors;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты обработчика необработанных исключений HTTP.
/// </summary>
public sealed class DealExceptionHandlerTests
{
[Fact]
public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
bool handled = await handler.TryHandleAsync(
context,
new NotFoundException("Карточка", "c_1"),
CancellationToken.None);
Assert.True(handled);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
(string detail, string code) = await ReadBodyAsync(context);
Assert.Equal(DealErrorCodes.NotFound, code);
Assert.Contains("Карточка", detail);
}
[Fact]
public async Task Unavailable_MapsTo503()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
await handler.TryHandleAsync(
context,
new ServiceUnavailableException("ИИ"),
CancellationToken.None);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
}
[Fact]
public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
bool handled = await handler.TryHandleAsync(
context,
new InvalidOperationException("секретная внутренняя деталь"),
CancellationToken.None);
Assert.True(handled);
Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
(string detail, string code) = await ReadBodyAsync(context);
Assert.Equal(DealErrorCodes.Internal, code);
Assert.DoesNotContain("секретная внутренняя деталь", detail);
Assert.DoesNotContain("at ", detail);
}
private static DefaultHttpContext CreateContext()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
return context;
}
private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context)
{
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
string json = await reader.ReadToEndAsync();
using JsonDocument document = JsonDocument.Parse(json);
return (
document.RootElement.GetProperty("detail").GetString() ?? string.Empty,
document.RootElement.GetProperty("code").GetString() ?? string.Empty);
}
}
@@ -0,0 +1,34 @@
using Deal.SharedKernel.Resources;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты ресурсов текстов ошибок (ErrorMessages.resx).
/// </summary>
public sealed class ErrorResourcesTests
{
[Fact]
public void Format_KnownKey_ReturnsRussianText()
{
string text = ErrorResources.Format(ErrorResourceKeys.UnexpectedError);
Assert.Contains("Внутренняя ошибка", text);
}
[Fact]
public void Format_TemplateWithArgs_SubstitutesPlaceholders()
{
string text = ErrorResources.Format(ErrorResourceKeys.NotFoundEntityWithId, "Карточка", "c_1");
Assert.Contains("Карточка", text);
Assert.Contains("c_1", text);
}
[Fact]
public void Format_UnknownKey_ReturnsKey()
{
string text = ErrorResources.Format("NoSuchKey");
Assert.Equal("NoSuchKey", text);
}
}