Ввести доменные исключения и ресурсы текстов ошибок
DealException/NotFoundException/Validation/Conflict/ServiceUnavailable с кодом ошибки, тексты в ErrorMessages.resx. Общий HTTP-обработчик (DealExceptionHandler) и маппинг в gRPC-интерцепторе: доменные ошибки → статус, прочие → обобщённый текст без стектрейса. Правила закреплены в код-стайле §10.
This commit is contained in:
@@ -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 ?? "-";
|
||||
}
|
||||
}
|
||||
@@ -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: Вызов нижестоящего обработчика.
|
||||
|
||||
Reference in New Issue
Block a user