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

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
+15 -3
View File
@@ -242,11 +242,23 @@
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
- Свои исключения наследовать от `Exception`.
- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
nullable-аннотированы и явно описаны в XML-doc.
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
БД, неизвестные идентификаторы и т.п.).
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
- В лог об ошибке, как правило, писать `StackTrace`.
- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
отдельной культурой (`.resx`-спутник) без правок кода.
## 11. Интерфейсы
@@ -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);
}
}
@@ -30,6 +30,11 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<!-- Общие доменные исключения и тексты ошибок (Deal.SharedKernel.Errors/Resources). -->
<ProjectReference Include="..\..\core\Deal.SharedKernel\Deal.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup>
<!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). -->
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
@@ -2,6 +2,8 @@ using System.Diagnostics;
using Deal.Grpc.Hosting.Models;
using Deal.Grpc.Hosting.Options;
using Deal.Grpc.Hosting.Services;
using Deal.SharedKernel.Errors;
using Deal.SharedKernel.Resources;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Microsoft.Extensions.Logging;
@@ -16,9 +18,6 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
private readonly ILogger<RpcCallLoggingInterceptor> _logger;
/// <summary>
@@ -101,14 +100,46 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
}
catch (Exception exception)
{
// «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
// строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
LogCall(context, startedAt, StatusCode.Unknown);
throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
RpcException mapped = MapFailure(exception, context.Method);
LogCall(context, startedAt, mapped.Status.StatusCode);
throw mapped;
}
}
// Переводит сбой реализации в RpcException: доменные ошибки — по коду, прочие — Unknown
// с фиксированным текстом (детали и стектрейс остаются только в логе).
// exception: Сбой обработчика.
// rpcMethod: Полное имя RPC-метода (для лога).
// Возвращает: RpcException для клиента.
private RpcException MapFailure(Exception exception, string rpcMethod)
{
if (exception is DealException dealException)
{
_logger.LogWarning(
"gRPC {RpcMethod}: доменная ошибка {ErrorCode}: {Message}",
rpcMethod,
dealException.ErrorCode,
dealException.Message);
return new RpcException(new Status(MapErrorCode(dealException.ErrorCode), dealException.Message));
}
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", rpcMethod);
return new RpcException(
new Status(StatusCode.Unknown, ErrorResources.Format(ErrorResourceKeys.UnexpectedError)));
}
// Код ошибки Deal → статус gRPC.
// errorCode: Код из DealException.ErrorCode.
// Возвращает: Статус gRPC для клиента.
private static StatusCode MapErrorCode(string errorCode) => errorCode switch
{
DealErrorCodes.NotFound => StatusCode.NotFound,
DealErrorCodes.Validation => StatusCode.InvalidArgument,
DealErrorCodes.Conflict => StatusCode.FailedPrecondition,
DealErrorCodes.Unavailable => StatusCode.Unavailable,
_ => StatusCode.Internal,
};
// Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
// context: Контекст вызова (метод — context.Method).
// invoke: Вызов нижестоящего обработчика.