Ввести доменные исключения и ресурсы текстов ошибок
DealException/NotFoundException/Validation/Conflict/ServiceUnavailable с кодом ошибки, тексты в ErrorMessages.resx. Общий HTTP-обработчик (DealExceptionHandler) и маппинг в gRPC-интерцепторе: доменные ошибки → статус, прочие → обобщённый текст без стектрейса. Правила закреплены в код-стайле §10.
This commit is contained in:
@@ -242,11 +242,23 @@
|
|||||||
|
|
||||||
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
- `try-catch` — только для непредвиденных ошибок, не для управления ходом программы.
|
||||||
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
- При пробрасывании выше — `throw;`, а **не** `throw ex;`.
|
||||||
- Свои исключения наследовать от `Exception`.
|
- **Свои доменные исключения наследовать от `DealException`** (`Deal.SharedKernel.Errors`) — базовый тип
|
||||||
|
хранит код ошибки (`ErrorCode`) и умеет брать текст из ресурсов. Состав: `NotFoundException`,
|
||||||
|
`ValidationException`, `ConflictException`, `ServiceUnavailableException`; новые — по тому же образцу.
|
||||||
|
- **Не возвращать `null` как штатный результат «не найдено»/ошибки.** Доменный сервис, у которого объект
|
||||||
|
не найден, бросает `NotFoundException` (эндпоинт отдаёт 404 через общий обработчик, а не проверкой
|
||||||
|
`is null` в каждом хендлере). `null` допустим только для **опциональных значений** — парсеры/извлечение
|
||||||
|
полей, выборки-запросы («нет строки» — нормальный результат), `Try*`-паттерн; такие методы должны быть
|
||||||
|
nullable-аннотированы и явно описаны в XML-doc.
|
||||||
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
- Исключение создавать всегда, когда функция не может быть выполнена (неверные параметры, нет доступа к
|
||||||
БД, неизвестные идентификаторы и т.п.).
|
БД, неизвестные идентификаторы и т.п.).
|
||||||
- Все исключения должны быть залогированы или показаны пользователю; пустые `catch` запрещены.
|
- Все исключения должны быть залогированы или показаны пользователю; **пустые `catch` запрещены**.
|
||||||
- В лог об ошибке, как правило, писать `StackTrace`.
|
- **Единый формат лога ошибки:** понятный русский текст + структурированный контекст (операция, `tenantId`,
|
||||||
|
id сущности, `traceId`). Стектрейс пишется **только в лог**; в ответ/сообщение клиенту он не попадает —
|
||||||
|
наружу отдаётся обобщённый текст и код (обработчики на границах: `DealExceptionHandler`, gRPC-интерцептор).
|
||||||
|
- **Тексты исключений/ошибок не хардкодить** — держать в ресурсах (`ErrorMessages.resx`, доступ через
|
||||||
|
`ErrorResources.Format(ErrorResourceKeys.*)` и шаблоны `DealException`), чтобы переводы добавлялись
|
||||||
|
отдельной культурой (`.resx`-спутник) без правок кода.
|
||||||
|
|
||||||
## 11. Интерфейсы
|
## 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.AddDealPersistence(tenantLimitDefaults);
|
||||||
|
|
||||||
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
|
builder.Services.AddDealSecurity(builder.Environment.ContentRootPath);
|
||||||
|
builder.Services.AddExceptionHandler<DealExceptionHandler>();
|
||||||
|
builder.Services.AddProblemDetails();
|
||||||
|
|
||||||
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
|
MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get<MlServiceOptions>() ?? new MlServiceOptions();
|
||||||
builder.Services.AddSingleton(mlOptions);
|
builder.Services.AddSingleton(mlOptions);
|
||||||
@@ -326,6 +328,7 @@ if (forwardedHeadersConfig.Enabled)
|
|||||||
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
|
app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.UseExceptionHandler();
|
||||||
app.UseMiddleware<HttpAccessLogMiddleware>();
|
app.UseMiddleware<HttpAccessLogMiddleware>();
|
||||||
|
|
||||||
app.UseCors(corsPolicyName);
|
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" />
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<!-- Общие доменные исключения и тексты ошибок (Deal.SharedKernel.Errors/Resources). -->
|
||||||
|
<ProjectReference Include="..\..\core\Deal.SharedKernel\Deal.SharedKernel.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). -->
|
<!-- gRPC-сервер ASP.NET Core (Interceptor/AddGrpc) и стандартный gRPC-health (Ruling 12). -->
|
||||||
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
|
<PackageReference Include="Grpc.AspNetCore" Version="2.83.0" />
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using System.Diagnostics;
|
|||||||
using Deal.Grpc.Hosting.Models;
|
using Deal.Grpc.Hosting.Models;
|
||||||
using Deal.Grpc.Hosting.Options;
|
using Deal.Grpc.Hosting.Options;
|
||||||
using Deal.Grpc.Hosting.Services;
|
using Deal.Grpc.Hosting.Services;
|
||||||
|
using Deal.SharedKernel.Errors;
|
||||||
|
using Deal.SharedKernel.Resources;
|
||||||
using Grpc.Core;
|
using Grpc.Core;
|
||||||
using Grpc.Core.Interceptors;
|
using Grpc.Core.Interceptors;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -16,9 +18,6 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
|
|||||||
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
|
// Префикс методов стандартного gRPC-health — не логируется (инфраструктурный liveness).
|
||||||
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
|
private const string HealthMethodPrefix = "/grpc.health.v1.Health/";
|
||||||
|
|
||||||
// Деталь RpcException для сбоя реализации (фиксированный текст; детали ошибки не наружу).
|
|
||||||
private const string UnknownFailureDetail = "Внутренняя ошибка сервиса";
|
|
||||||
|
|
||||||
private readonly ILogger<RpcCallLoggingInterceptor> _logger;
|
private readonly ILogger<RpcCallLoggingInterceptor> _logger;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -101,14 +100,46 @@ public sealed class RpcCallLoggingInterceptor : Interceptor
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
// «Прочие» сбои реализации gRPC показал бы клиенту как UNKNOWN мимо access-лога: логируем
|
RpcException mapped = MapFailure(exception, context.Method);
|
||||||
// строку со статусом Unknown, пишем детали сбоя и переводим в RpcException (текст фиксирован).
|
LogCall(context, startedAt, mapped.Status.StatusCode);
|
||||||
_logger.LogError(exception, "gRPC {RpcMethod}: необработанный сбой реализации", context.Method);
|
throw mapped;
|
||||||
LogCall(context, startedAt, StatusCode.Unknown);
|
|
||||||
throw new RpcException(new Status(StatusCode.Unknown, UnknownFailureDetail));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Переводит сбой реализации в 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/дуплексный).
|
// Обёртка для handler-ов, возвращающих Task (server-streaming/дуплексный).
|
||||||
// context: Контекст вызова (метод — context.Method).
|
// context: Контекст вызова (метод — context.Method).
|
||||||
// invoke: Вызов нижестоящего обработчика.
|
// invoke: Вызов нижестоящего обработчика.
|
||||||
|
|||||||
Reference in New Issue
Block a user