< Summary

Information
Class: OzonOrdersWeb.Observability.RequestObservabilityMiddleware
Assembly: OzonOrdersWeb
File(s): /home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/RequestObservabilityMiddleware.cs
Line coverage
96%
Covered lines: 226
Uncovered lines: 8
Coverable lines: 234
Total lines: 382
Line coverage: 96.5%
Branch coverage
91%
Covered branches: 218
Total branches: 238
Branch coverage: 91.5%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
InvokeAsync()83.33%121294.28%
IsErrorReExecution(...)100%44100%
WriteRequestLog(...)91.66%20420498.74%
.cctor()100%11100%
IsSignalRHubPath(...)100%11100%
IsSignalRNegotiate(...)50%22100%
ReadRequestPayloadAsync()100%161690.47%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/OzonOrdersWeb/Observability/RequestObservabilityMiddleware.cs

#LineLine coverage
 1using System.Diagnostics;
 2using System.Security.Claims;
 3using Microsoft.AspNetCore.Diagnostics;
 4using Microsoft.Extensions.Options;
 5using OzonOrdersWeb.Build;
 6
 7namespace OzonOrdersWeb.Observability;
 8
 409public sealed class RequestObservabilityMiddleware(
 4010    RequestDelegate next,
 4011    ILogger<RequestObservabilityMiddleware> logger,
 4012    SensitiveDataRedactor redactor,
 4013    IOptions<ObservabilityOptions> options)
 14{
 15    private const string ReportedItemKey = "__studio2_problem_reported";
 4016    private readonly ObservabilityOptions _options = options.Value;
 17
 18    public async Task InvokeAsync(HttpContext context)
 19    {
 4020        var timer = Stopwatch.StartNew();
 4021        var requestPayload = "[PAYLOAD UNAVAILABLE]";
 4022        context.Response.OnStarting(() =>
 4023        {
 024            context.Response.Headers["X-Trace-Id"] = context.TraceIdentifier;
 025            return Task.CompletedTask;
 4026        });
 27
 28        try
 29        {
 4030            requestPayload = await ReadRequestPayloadAsync(context);
 4031            await next(context);
 3732        }
 133        catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
 34        {
 135            if (!context.Response.HasStarted)
 36            {
 137                var statusCodePages = context.Features.Get<IStatusCodePagesFeature>();
 138                if (statusCodePages is not null)
 39                {
 040                    statusCodePages.Enabled = false;
 41                }
 142                context.Response.StatusCode = 499;
 43            }
 44
 145            context.Items[ReportedItemKey] = true;
 146            WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, null);
 147        }
 148        catch (BadHttpRequestException badRequestException)
 49        {
 150            var statusCode = badRequestException.StatusCode;
 151            if (!context.Response.HasStarted)
 52            {
 153                var statusCodePages = context.Features.Get<IStatusCodePagesFeature>();
 154                if (statusCodePages is not null)
 55                {
 056                    statusCodePages.Enabled = false;
 57                }
 158                context.Response.StatusCode = statusCode;
 59            }
 60
 161            context.Items[ReportedItemKey] = true;
 162            WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, null, badRequestException);
 163            throw;
 64        }
 165        catch (Exception exception)
 66        {
 167            context.Items[ReportedItemKey] = true;
 168            WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, exception);
 169            throw;
 70        }
 71
 3872        if (!context.Items.ContainsKey(ReportedItemKey) && !IsErrorReExecution(context))
 73        {
 3574            context.Items[ReportedItemKey] = true;
 3575            WriteRequestLog(context, timer.ElapsedMilliseconds, requestPayload, null);
 76        }
 3877    }
 78
 79    internal static bool IsErrorReExecution(HttpContext context)
 3780        => context.Features.Get<IStatusCodeReExecuteFeature>() is not null
 3781           || context.Features.Get<IExceptionHandlerPathFeature>() is not null
 3782           || context.Features.Get<IExceptionHandlerFeature>() is not null;
 83
 84    private void WriteRequestLog(
 85        HttpContext context,
 86        long elapsedMilliseconds,
 87        string requestPayload,
 88        Exception? exception,
 89        BadHttpRequestException? badRequestException = null)
 90    {
 3891        var statusCode = badRequestException?.StatusCode ?? (exception is null ? context.Response.StatusCode : 500);
 3892        var endpoint = context.GetEndpoint();
 3893        var routePattern = (endpoint as RouteEndpoint)?.RoutePattern.RawText;
 3894        var routeData = context.GetRouteData();
 3895        var controller = routeData.Values["controller"]?.ToString();
 3896        var action = routeData.Values["action"]?.ToString();
 3897        var page = routeData.Values["page"]?.ToString();
 3898        var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
 99        // Хабы SignalR держат соединение открытым часами: WebSocket отвечает 101 и живёт до
 100        // закрытия вкладки, ServerSentEvents и LongPolling висят до истечения таймаута.
 101        // Длительность такого запроса — норма, а не деградация, поэтому SlowHttpRequest
 102        // по ним не выставляем.
 38103        var isSignalRHub = IsSignalRHubPath(context.Request.Path);
 38104        var isPersistentConnection = context.WebSockets.IsWebSocketRequest
 38105                                     || statusCode == StatusCodes.Status101SwitchingProtocols
 38106                                     || (isSignalRHub && !IsSignalRNegotiate(context.Request.Path));
 38107        var isStreaming = isPersistentConnection
 38108                          || context.Response.ContentType?.Contains("text/event-stream", StringComparison.OrdinalIgnoreC
 38109                          || context.Request.Headers.Accept.ToString().Contains("text/event-stream", StringComparison.Or
 38110                          || context.Request.Path.Value?.Contains("progress", StringComparison.OrdinalIgnoreCase) == tru
 38111        var longOperation = isStreaming
 38112                            || context.Request.Path.Value?.Contains("upload", StringComparison.OrdinalIgnoreCase) == tru
 38113                            || context.Request.Path.Value?.Contains("export", StringComparison.OrdinalIgnoreCase) == tru
 38114                            || context.Request.Path.Value?.Contains("job", StringComparison.OrdinalIgnoreCase) == true
 38115                            || context.Request.Path.Value?.Contains("download", StringComparison.OrdinalIgnoreCase) == t
 38116        var slowThreshold = longOperation
 38117            ? _options.SlowLongOperationMilliseconds
 38118            : _options.SlowRequestMilliseconds;
 38119        var isSlow = !isStreaming && elapsedMilliseconds >= slowThreshold;
 38120        var methodChangesState = HttpMethods.IsPost(context.Request.Method)
 38121                                 || HttpMethods.IsPut(context.Request.Method)
 38122                                 || HttpMethods.IsPatch(context.Request.Method)
 38123                                 || HttpMethods.IsDelete(context.Request.Method);
 38124        var payload = statusCode >= 400 || methodChangesState ? requestPayload : string.Empty;
 125        // 404 на путь, который не сматчился ни на один маршрут, да ещё и от анонимного
 126        // клиента — это не отказ приложения, а обращение к несуществующему адресу. Так
 127        // выглядят сканеры, перебирающие чужие CMS, VPN-порталы и файлы секретов: за
 128        // сутки они дают сотни уникальных путей, и каждый заводил GitHub-инцидент.
 129        // 404 из контроллера (endpoint сматчен) и 404 у аутентифицированного пользователя
 130        // остаются Warning — это настоящие битые ссылки приложения.
 38131        var isUnroutedAnonymousNotFound = statusCode == StatusCodes.Status404NotFound
 38132                                          && endpoint is null
 38133                                          && context.User.Identity?.IsAuthenticated != true;
 134        // При перезапуске контейнера вкладки переподключаются со старым connectionId, и
 135        // SignalR штатно отвечает 404 «connection not found»: клиент после этого делает
 136        // новый negotiate. Это часть протокола переподключения, а не отказ приложения.
 38137        var isStaleSignalRConnection = statusCode == StatusCodes.Status404NotFound && isSignalRHub;
 138        // Прямое обращение сканера или браузера к URL хаба без параметров подключения SignalR
 139        // штатно возвращает 400 Bad Request (требуется negotiate/connectionId) или 405 Method Not Allowed.
 140        // Регрессия #2241: при переподключении long polling второй poll на тот же connectionId
 141        // SignalR отклоняет 409 Conflict — клиент повторит запрос сам, это не отказ приложения.
 38142        var isInvalidSignalRProtocolRequest = (statusCode == StatusCodes.Status400BadRequest
 38143                                               || statusCode == StatusCodes.Status405MethodNotAllowed
 38144                                               || statusCode == StatusCodes.Status409Conflict) && isSignalRHub;
 145        // 405 Method Not Allowed от анонимного клиента (например, бот шлёт GET на POST-only endpoint)
 146        // является обращением с неподдерживаемым HTTP-методом, а не отказом бэкенда.
 38147        var isAnonymousMethodNotAllowed = statusCode == StatusCodes.Status405MethodNotAllowed
 38148                                          && context.User.Identity?.IsAuthenticated != true;
 149        // Страница ошибки (/Home/Error): отдача клиенту страницы ошибки со статусом 4xx/5xx не
 150        // является отказом эндпоинта ошибки, если только сам рендеринг не завершился необработанным исключением.
 38151        var isErrorPage = context.Request.Path.StartsWithSegments("/Home/Error", StringComparison.OrdinalIgnoreCase)
 38152                          || (string.Equals(controller, "Home", StringComparison.OrdinalIgnoreCase)
 38153                              && string.Equals(action, "Error", StringComparison.OrdinalIgnoreCase));
 154        // Регрессия #2152, #2153: обращение к API импорта выдачи Ozon без валидного X-Api-Key
 155        // возвращает 401/403. Отклонение неавторизованного внешнего запроса — штатное поведение безопасности.
 38156        var isHandoverUnauthorized = (statusCode == StatusCodes.Status401Unauthorized || statusCode == StatusCodes.Statu
 38157                                     && context.Request.Path.StartsWithSegments("/api/ozon-handover", StringComparison.O
 158        // Регрессия #2128: некорректный JSON/формат документа импорта выдачи Ozon возвращает 400 Bad Request.
 159        // Это ошибка внешнего клиента/пользователя, а не деградация бэкенда.
 38160        var isHandoverBadRequest = statusCode == StatusCodes.Status400BadRequest
 38161                                   && context.Request.Path.StartsWithSegments("/api/ozon-handover", StringComparison.Ord
 162        // Регрессия #2159: отсутствие медиа-файла на диске или в БД (фото акта выдачи Ozon, миниатюра этикетки)
 163        // возвращает 404 Not Found. Это отсутствие конкретного файла ресурса, а не отказ доступности сервиса.
 38164        var isMediaResourceNotFound = statusCode == StatusCodes.Status404NotFound
 38165                                      && (context.Request.Path.StartsWithSegments("/OrderHistory/GetHandoverPhoto", Stri
 38166                                          || context.Request.Path.StartsWithSegments("/Studio2/OrderHistory/GetHandoverP
 38167                                          || context.Request.Path.StartsWithSegments("/Labels/GetThumbnail", StringCompa
 38168                                          || context.Request.Path.StartsWithSegments("/Studio2/Labels/GetThumbnail", Str
 169        // Регрессия #1218: 400 Bad Request на POST /Identity/Account/Login возникает при несовпадении/отсутствии
 170        // Antiforgery CSRF-токена (__RequestVerificationToken) фреймворком ASP.NET Core (вкладка открыта до
 171        // перезапуска сервиса, сканеры или устаревшая сессия). Запрос отсекается до вызова страницы OnPostAsync,
 172        // и отказ валидации CSRF — штатная функция безопасности, а не сбой приложения.
 38173        var isAuthAntiforgeryBadRequest = statusCode == StatusCodes.Status400BadRequest
 38174                                          && HttpMethods.IsPost(context.Request.Method)
 38175                                          && context.Request.Path.StartsWithSegments("/Identity/Account/Login", StringCo
 176        // Регрессия #2424: 400 Bad Request на POST /Studio2/OrdersGrid/GetChanges возникает при несовпадении/отсутствии
 177        // Antiforgery CSRF-токена фреймворком ASP.NET Core на фоновом автообновлении открытой вкладки после перезапуска
 178        // сервиса или истечения сессии. Запрос отсекается до вызова контроллера, и отказ CSRF — штатная функция безопас
 38179        var isGetChangesAntiforgeryBadRequest = statusCode == StatusCodes.Status400BadRequest
 38180                                                && HttpMethods.IsPost(context.Request.Method)
 38181                                                && context.Request.Path.StartsWithSegments("/Studio2/OrdersGrid/GetChang
 182        // Регрессия #2359, #2367, #2368: внешний запрос к вебхуку Ozon с некорректным телом (400) или невалидным IP/ток
 183        // является штатным отклонением некорректного или неавторизованного внешнего вызова, а не деградацией сервиса.
 38184        var isOzonWebhookRejection = (statusCode == StatusCodes.Status400BadRequest || statusCode == StatusCodes.Status4
 38185                                     && context.Request.Path.StartsWithSegments("/Studio2/OzonWebhook/Receive", StringCo
 186        // Регрессия #2434, #2435, #2436: внешний запрос к эндпоинтам системных уведомлений деплоя без валидного X-Api-K
 187        // возвращает 401 Unauthorized / 403 Forbidden. Отклонение неавторизованного внешнего запроса — штатное поведени
 38188        var isSystemNotificationUnauthorized = (statusCode == StatusCodes.Status401Unauthorized || statusCode == StatusC
 38189                                              && context.Request.Path.StartsWithSegments("/Studio2/SystemNotification", 
 190        // Регрессия #2402: 429 Too Many Requests на /Identity/Account/Login (или /Account/Login) возникает при срабатыв
 191        // политики AuthRateLimiterPolicy для защиты от перебора паролей. Ограничение частоты запросов — штатная функция
 192        // безопасности приложения, а не ошибка веб-сервера.
 38193        var isAuthRateLimit = statusCode == StatusCodes.Status429TooManyRequests
 38194                              && (context.Request.Path.StartsWithSegments("/Identity/Account/Login", StringComparison.Or
 38195                                  || context.Request.Path.StartsWithSegments("/Account/Login", StringComparison.OrdinalI
 38196        var isExpectedNonFailure = isHandoverUnauthorized || isHandoverBadRequest || isOzonWebhookRejection || isMediaRe
 38197        var eventKind = exception is not null
 38198            ? "UnhandledException"
 38199            : statusCode == 499
 38200                ? "HttpRequestAborted"
 38201                : isExpectedNonFailure
 38202                    ? "HttpRequest"
 38203                    : statusCode >= 400 && !isErrorPage
 38204                        ? "HttpRequestFailed"
 38205                        : isSlow ? "SlowHttpRequest" : "HttpRequest";
 38206        var category = context.Request.Path.StartsWithSegments("/Identity") || context.Request.Path.StartsWithSegments("
 38207            ? "auth"
 38208            : isHandoverUnauthorized || isSystemNotificationUnauthorized || (isOzonWebhookRejection && statusCode == Sta
 38209                ? "auth"
 38210                : !isErrorPage && !isMediaResourceNotFound && (statusCode >= 500 || statusCode == StatusCodes.Status404N
 38211                    ? "availability"
 38212                    : "backend";
 213
 38214        using var scope = logger.BeginScope(new Dictionary<string, object?>
 38215        {
 38216            ["EventKind"] = eventKind,
 38217            ["EventCategory"] = category,
 38218            ["HttpMethod"] = context.Request.Method,
 38219            ["RequestPath"] = context.Request.Path.Value,
 38220            ["QueryString"] = redactor.Redact(context.Request.QueryString.Value, _options.MaxPayloadCharacters),
 38221            ["StatusCode"] = statusCode,
 38222            ["ElapsedMilliseconds"] = elapsedMilliseconds,
 38223            ["TraceId"] = context.TraceIdentifier,
 38224            ["Controller"] = controller,
 38225            ["Action"] = action,
 38226            ["Page"] = page,
 38227            ["Endpoint"] = endpoint?.DisplayName,
 38228            ["RoutePattern"] = routePattern,
 38229            ["UserId"] = userId,
 38230            ["UserName"] = context.User.Identity?.Name,
 38231            ["RemoteIp"] = context.Connection.RemoteIpAddress?.ToString(),
 38232            ["RequestPayload"] = payload,
 38233            ["ApplicationVersion"] = AppBuildInfo.CommitHash,
 38234            ["ApplicationBuildDate"] = AppBuildInfo.CommitDate,
 38235            ["HostName"] = Environment.MachineName
 38236        });
 237
 38238        if (exception is not null)
 239        {
 1240            logger.LogError(exception, "HTTP {Method} {Path} завершился необработанной ошибкой за {ElapsedMilliseconds} 
 1241                context.Request.Method, context.Request.Path, elapsedMilliseconds);
 242        }
 37243        else if (badRequestException is not null)
 244        {
 1245            logger.LogWarning(badRequestException, "HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс
 1246                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 247        }
 36248        else if (statusCode >= 500 && !isErrorPage)
 249        {
 2250            logger.LogError("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс",
 2251                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 252        }
 34253        else if (statusCode == 499)
 254        {
 2255            logger.LogInformation("HTTP {Method} {Path} отменен клиентом (499) за {ElapsedMilliseconds} мс",
 2256                context.Request.Method, context.Request.Path, elapsedMilliseconds);
 257        }
 32258        else if (isUnroutedAnonymousNotFound && !isSlow)
 259        {
 1260            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (маршрут не найд
 1261                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 262        }
 31263        else if (isStaleSignalRConnection && !isSlow)
 264        {
 1265            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (устаревшее соед
 1266                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 267        }
 30268        else if (isInvalidSignalRProtocolRequest && !isSlow)
 269        {
 5270            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (некорректный за
 5271                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 272        }
 25273        else if (isAnonymousMethodNotAllowed && !isSlow)
 274        {
 1275            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (метод не поддер
 1276                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 277        }
 24278        else if (isErrorPage && !isSlow)
 279        {
 2280            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (страница ошибки
 2281                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 282        }
 22283        else if (isHandoverUnauthorized && !isSlow)
 284        {
 2285            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (доступ к импорт
 2286                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 287        }
 20288        else if (isHandoverBadRequest && !isSlow)
 289        {
 1290            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (некорректный за
 1291                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 292        }
 19293        else if (isMediaResourceNotFound && !isSlow)
 294        {
 4295            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (медиа-файл не н
 4296                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 297        }
 15298        else if ((isAuthAntiforgeryBadRequest || isGetChangesAntiforgeryBadRequest) && !isSlow)
 299        {
 1300            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (отказ валидации
 1301                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 302        }
 14303        else if (isOzonWebhookRejection && !isSlow)
 304        {
 0305            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (запрос вебхука 
 0306                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 307        }
 14308        else if (isSystemNotificationUnauthorized && !isSlow)
 309        {
 2310            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (доступ к систем
 2311                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 312        }
 12313        else if (isAuthRateLimit && !isSlow)
 314        {
 2315            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс (срабатывание ли
 2316                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 317        }
 10318        else if (statusCode >= 400 || isSlow)
 319        {
 6320            context.Items[ReportedItemKey] = true;
 6321            logger.LogWarning("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс",
 6322                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 323        }
 324        else
 325        {
 4326            logger.LogInformation("HTTP {Method} {Path} вернул {StatusCode} за {ElapsedMilliseconds} мс",
 4327                context.Request.Method, context.Request.Path, statusCode, elapsedMilliseconds);
 328        }
 42329    }
 330
 331    // Пути хабов регистрируются в Program.cs через MapHub; держим их здесь одним списком,
 332    // чтобы наблюдаемость знала, какие запросы являются долгоживущими соединениями.
 1333    private static readonly string[] SignalRHubPaths =
 1334    [
 1335        "/notificationHub",
 1336        "/orderHistoryHub",
 1337        "/jobProgressHub"
 1338    ];
 339
 340    internal static bool IsSignalRHubPath(PathString path)
 142341        => SignalRHubPaths.Any(hub => path.StartsWithSegments(hub, StringComparison.OrdinalIgnoreCase));
 342
 343    internal static bool IsSignalRNegotiate(PathString path)
 8344        => path.Value?.EndsWith("/negotiate", StringComparison.OrdinalIgnoreCase) == true;
 345
 346    private async Task<string> ReadRequestPayloadAsync(HttpContext context)
 347    {
 40348        if (!(HttpMethods.IsPost(context.Request.Method)
 40349              || HttpMethods.IsPut(context.Request.Method)
 40350              || HttpMethods.IsPatch(context.Request.Method)
 40351              || HttpMethods.IsDelete(context.Request.Method)))
 352        {
 26353            return string.Empty;
 354        }
 355
 14356        var contentType = context.Request.ContentType ?? string.Empty;
 14357        if (contentType.Contains("multipart/", StringComparison.OrdinalIgnoreCase)
 14358            || !(contentType.Contains("json", StringComparison.OrdinalIgnoreCase)
 14359                 || contentType.Contains("x-www-form-urlencoded", StringComparison.OrdinalIgnoreCase)))
 360        {
 12361            return "[PAYLOAD NOT CAPTURED]";
 362        }
 363
 364        try
 365        {
 2366            context.Request.EnableBuffering();
 2367            using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
 2368            var buffer = new char[_options.MaxPayloadCharacters + 1];
 2369            var count = await reader.ReadBlockAsync(buffer, 0, buffer.Length);
 1370            context.Request.Body.Position = 0;
 1371            return redactor.Redact(new string(buffer, 0, count), _options.MaxPayloadCharacters);
 372        }
 1373        catch (BadHttpRequestException ex)
 374        {
 1375            return $"[BAD REQUEST PAYLOAD: {ex.Message}]";
 376        }
 0377        catch (Exception ex) when (ex is IOException or OperationCanceledException)
 378        {
 0379            return $"[PAYLOAD READ FAILED: {ex.Message}]";
 380        }
 40381    }
 382}