| | | 1 | | using System.Diagnostics; |
| | | 2 | | using Microsoft.Extensions.Options; |
| | | 3 | | using Polly.CircuitBreaker; |
| | | 4 | | using Services.ApiServices.ArmtekApi; |
| | | 5 | | |
| | | 6 | | namespace OzonOrdersWeb.Observability; |
| | | 7 | | |
| | 72 | 8 | | public sealed class OutboundRequestLoggingHandler( |
| | 72 | 9 | | ILogger<OutboundRequestLoggingHandler> logger, |
| | 72 | 10 | | SensitiveDataRedactor redactor, |
| | 72 | 11 | | IOptions<ObservabilityOptions> options) : DelegatingHandler |
| | | 12 | | { |
| | 72 | 13 | | private readonly ObservabilityOptions _options = options.Value; |
| | | 14 | | |
| | | 15 | | protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellat |
| | | 16 | | { |
| | 72 | 17 | | var timer = Stopwatch.StartNew(); |
| | | 18 | | try |
| | | 19 | | { |
| | 72 | 20 | | var response = await base.SendAsync(request, cancellationToken); |
| | 54 | 21 | | var elapsed = timer.ElapsedMilliseconds; |
| | 54 | 22 | | var isSlow = elapsed >= _options.SlowDependencyMilliseconds; |
| | 54 | 23 | | var isExpectedHeadNotFound = request.Method == HttpMethod.Head && response.StatusCode == System.Net.HttpStat |
| | 54 | 24 | | var isExpectedMotexSearchNotFound = IsExpectedMotexSearchNotFound(request, response); |
| | 54 | 25 | | var isExpectedMlAutoNotFound = IsExpectedMlAutoNotFound(request, response); |
| | 54 | 26 | | var isExpectedOzonProductAttributesNotFound = IsExpectedOzonProductAttributesNotFound(request, response); |
| | 54 | 27 | | var isExpectedOzonPostingNotFound = IsExpectedOzonPostingNotFound(request, response); |
| | 54 | 28 | | var isArmtekDailyQuotaExceeded = await IsArmtekDailyQuotaExceededAsync(request, response, cancellationToken) |
| | 54 | 29 | | var isTransientOzonS3ReportError = IsTransientOzonS3ReportError(request, response); |
| | 54 | 30 | | var isTransientOzonChatError = IsTransientOzonChatError(request, response); |
| | 54 | 31 | | var isTransientOzonWebhookRateLimit = IsTransientOzonWebhookRateLimit(request, response); |
| | 54 | 32 | | var isExpectedBusinessRejection = IsExpectedBusinessRejection(request, response); |
| | 54 | 33 | | var isExpectedOneCDocumentPostingRejection = IsExpectedOneCDocumentPostingRejection(request, response); |
| | 54 | 34 | | var isTransientSupplierServiceError = IsTransientSupplierServiceError(request, response); |
| | 54 | 35 | | var isExpectedOzonMethodNotAllowed = IsExpectedOzonMethodNotAllowed(request, response); |
| | 54 | 36 | | var isOpenRouterAuthError = IsOpenRouterAuthError(request, response); |
| | | 37 | | |
| | 54 | 38 | | if ((!response.IsSuccessStatusCode && !isExpectedHeadNotFound) || isSlow) |
| | | 39 | | { |
| | 52 | 40 | | using var scope = BeginScope(request, elapsed, (int)response.StatusCode); |
| | 52 | 41 | | if (isExpectedMotexSearchNotFound) |
| | | 42 | | { |
| | 1 | 43 | | logger.LogInformation("МоТехС не нашёл предложение: HTTP {Method} {Uri} вернул 404 за {ElapsedMillis |
| | 1 | 44 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), elapsed); |
| | | 45 | | } |
| | 51 | 46 | | else if (isExpectedOneCDocumentPostingRejection) |
| | | 47 | | { |
| | 3 | 48 | | logger.LogInformation("1С OData отклонила проведение документа (ожидаемый бизнес-ответ, документ ост |
| | 3 | 49 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 3 | 50 | | (int)response.StatusCode, elapsed); |
| | | 51 | | } |
| | 48 | 52 | | else if (isExpectedMlAutoNotFound) |
| | | 53 | | { |
| | 10 | 54 | | logger.LogInformation("ML-Auto не нашёл заказ или предложение: HTTP {Method} {Uri} вернул 404 за {El |
| | 10 | 55 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), elapsed); |
| | | 56 | | } |
| | 38 | 57 | | else if (isExpectedOzonProductAttributesNotFound) |
| | | 58 | | { |
| | 1 | 59 | | logger.LogInformation("Товар Ozon не найден в кабинете: HTTP {Method} {Uri} вернул 404 за {ElapsedMi |
| | 1 | 60 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), elapsed); |
| | | 61 | | } |
| | 37 | 62 | | else if (isExpectedOzonPostingNotFound) |
| | | 63 | | { |
| | 1 | 64 | | logger.LogInformation("Отправление Ozon не найдено в кабинете: HTTP {Method} {Uri} вернул 404 за {El |
| | 1 | 65 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), elapsed); |
| | | 66 | | } |
| | 36 | 67 | | else if (isArmtekDailyQuotaExceeded) |
| | | 68 | | { |
| | 1 | 69 | | logger.LogInformation("Armtek исчерпал суточную квоту: HTTP {Method} {Uri} вернул {StatusCode} за {E |
| | 1 | 70 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 1 | 71 | | (int)response.StatusCode, elapsed); |
| | | 72 | | } |
| | 35 | 73 | | else if (isTransientOzonS3ReportError) |
| | | 74 | | { |
| | 3 | 75 | | logger.LogInformation("Временный ответ Ozon S3: HTTP {Method} {Uri} вернул {StatusCode} за {ElapsedM |
| | 3 | 76 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 3 | 77 | | (int)response.StatusCode, elapsed); |
| | | 78 | | } |
| | 32 | 79 | | else if (isTransientSupplierServiceError) |
| | | 80 | | { |
| | 7 | 81 | | logger.LogInformation("Временный сбой сервиса поставщика (HTTP {StatusCode}): {Method} {Uri} за {Ela |
| | 7 | 82 | | (int)response.StatusCode, request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPay |
| | 7 | 83 | | elapsed); |
| | | 84 | | } |
| | 25 | 85 | | else if (isTransientOzonChatError) |
| | | 86 | | { |
| | 4 | 87 | | logger.LogInformation("Временный сбой Ozon Chat API: HTTP {Method} {Uri} вернул {StatusCode} за {Ela |
| | 4 | 88 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 4 | 89 | | (int)response.StatusCode, elapsed); |
| | | 90 | | } |
| | 21 | 91 | | else if (isTransientOzonWebhookRateLimit) |
| | | 92 | | { |
| | | 93 | | // OzonWebhookAvailabilityService повторяет этот безопасный запрос с паузами 1/2/4 с. |
| | | 94 | | // Предыдущая неудачная попытка не является инцидентом; после исчерпания повторов |
| | | 95 | | // сервис сам зафиксирует Warning с контекстом кабинета. |
| | 1 | 96 | | logger.LogInformation("Ozon ограничил частоту проверки webhook: HTTP {Method} {Uri} вернул 429 за {E |
| | 1 | 97 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), elapsed); |
| | | 98 | | } |
| | 20 | 99 | | else if (isExpectedBusinessRejection) |
| | | 100 | | { |
| | 9 | 101 | | logger.LogInformation("Внешний сервис отклонил операцию (ожидаемый бизнес-ответ): HTTP {Method} {Uri |
| | 9 | 102 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 9 | 103 | | (int)response.StatusCode, elapsed); |
| | | 104 | | } |
| | 11 | 105 | | else if (isExpectedOzonMethodNotAllowed) |
| | | 106 | | { |
| | 4 | 107 | | logger.LogInformation("Ozon API отклонил HTTP-метод (405 Method Not Allowed): {Method} {Uri} за {Ela |
| | 4 | 108 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 4 | 109 | | elapsed); |
| | | 110 | | } |
| | 7 | 111 | | else if (isOpenRouterAuthError) |
| | | 112 | | { |
| | 0 | 113 | | logger.LogInformation("OpenRouter отклонил запрос (401 Unauthorized, не настроен или недействителен |
| | 0 | 114 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 0 | 115 | | elapsed); |
| | | 116 | | } |
| | | 117 | | else |
| | | 118 | | { |
| | 7 | 119 | | logger.LogWarning("Внешний HTTP {Method} {Uri} вернул {StatusCode} за {ElapsedMilliseconds} мс", |
| | 7 | 120 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 7 | 121 | | (int)response.StatusCode, elapsed); |
| | | 122 | | } |
| | | 123 | | } |
| | 2 | 124 | | else if (isExpectedHeadNotFound) |
| | | 125 | | { |
| | 1 | 126 | | using var scope = BeginScope(request, elapsed, (int)response.StatusCode); |
| | 1 | 127 | | logger.LogDebug("Внешний HTTP HEAD {Uri} вернул 404 (ресурс не найден) за {ElapsedMilliseconds} мс", |
| | 1 | 128 | | redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), elapsed); |
| | | 129 | | } |
| | | 130 | | |
| | 54 | 131 | | return response; |
| | | 132 | | } |
| | 2 | 133 | | catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) |
| | | 134 | | { |
| | 1 | 135 | | using var scope = BeginScope(request, timer.ElapsedMilliseconds, 499); |
| | 1 | 136 | | logger.LogInformation("Внешний HTTP {Method} {Uri} отменен вызывающей стороной через {ElapsedMilliseconds} м |
| | 1 | 137 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), timer.ElapsedMill |
| | 1 | 138 | | throw; |
| | | 139 | | } |
| | | 140 | | // Внешний сервис не ответил за отведённое время. Это отказ третьей стороны, а не Studio2: |
| | | 141 | | // тот же поставщик, вернувший 504, логируется Warning'ом веткой выше. Исключение |
| | | 142 | | // пробрасывается дальше — если отказ важен для сценария, вызывающий код сообщит о нём |
| | | 143 | | // с бизнес-контекстом. Уровень Information: Warning и выше попадают в errors-*.log, |
| | | 144 | | // и наблюдатель заводит issue на каждый таймаут чужого API. В Seq запись остаётся, |
| | | 145 | | // код 504 в scope делает её отбираемой. |
| | 1 | 146 | | catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) |
| | | 147 | | { |
| | 1 | 148 | | using var scope = BeginScope(request, timer.ElapsedMilliseconds, 504); |
| | 1 | 149 | | logger.LogInformation("Timeout внешнего HTTP {Method} {Uri} через {ElapsedMilliseconds} мс", |
| | 1 | 150 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), timer.ElapsedMill |
| | 1 | 151 | | throw; |
| | | 152 | | } |
| | | 153 | | // Сработал Circuit Breaker (Polly): контур разомкнут из-за серии сбоев внешнего сервиса. |
| | | 154 | | // Это защита от перегрузки третьей стороны, а не ошибка Studio2. |
| | | 155 | | // Исключение пробрасывается дальше, уровень Information и код 503 в scope |
| | | 156 | | // сохраняют запись в Seq без засорения errors-*.log (#2012, #2013). |
| | 2 | 157 | | catch (BrokenCircuitException) |
| | | 158 | | { |
| | 2 | 159 | | using var scope = BeginScope(request, timer.ElapsedMilliseconds, 503); |
| | 2 | 160 | | logger.LogInformation("Внешний HTTP {Method} {Uri} отклонён: Circuit Breaker разомкнут через {ElapsedMillise |
| | 2 | 161 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), timer.ElapsedMill |
| | 2 | 162 | | throw; |
| | | 163 | | } |
| | | 164 | | // Обрыв соединения внешним сервисом (TCP reset, сбой SSL/TLS рукопожатия, преждевременно закрытый ответ). |
| | | 165 | | // Это отказ сетевой инфраструктуры или третьей стороны, а не ошибка Studio2. |
| | | 166 | | // Исключение пробрасывается дальше, уровень Information и код 502 в scope |
| | | 167 | | // сохраняют запись в Seq без засорения errors-*.log (#1994). |
| | | 168 | | // DNS-ошибки и connection refused обрабатываются следующим блоком — только для сторонних хостов. |
| | 13 | 169 | | catch (HttpRequestException exception) when (OutboundTransportFailureClassifier.IsTransientTransportFailure(exce |
| | | 170 | | { |
| | 4 | 171 | | using var scope = BeginScope(request, timer.ElapsedMilliseconds, 502); |
| | 4 | 172 | | logger.LogInformation(exception, "Сетевой сбой внешнего HTTP {Method} {Uri} через {ElapsedMilliseconds} мс", |
| | 4 | 173 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), timer.ElapsedMill |
| | 4 | 174 | | throw; |
| | | 175 | | } |
| | | 176 | | // DNS, connection refused, сеть недостижима — у стороннего сервиса. Код 503 в scope |
| | | 177 | | // оставляет запись отбираемой в Seq; свои сервисы остаются Error (см. классификатор). |
| | 9 | 178 | | catch (HttpRequestException exception) when (OutboundTransportFailureClassifier.IsThirdPartyUnavailable(exceptio |
| | | 179 | | { |
| | 3 | 180 | | using var scope = BeginScope(request, timer.ElapsedMilliseconds, 503); |
| | 3 | 181 | | logger.LogInformation("Внешний сервис недоступен: HTTP {Method} {Uri} через {ElapsedMilliseconds} мс — {Reas |
| | 3 | 182 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), timer.ElapsedMill |
| | 3 | 183 | | exception.Message); |
| | 3 | 184 | | throw; |
| | | 185 | | } |
| | 7 | 186 | | catch (Exception exception) |
| | | 187 | | { |
| | 7 | 188 | | using var scope = BeginScope(request, timer.ElapsedMilliseconds, null); |
| | 7 | 189 | | logger.LogError(exception, "Ошибка внешнего HTTP {Method} {Uri} через {ElapsedMilliseconds} мс", |
| | 7 | 190 | | request.Method, redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), timer.ElapsedMill |
| | 7 | 191 | | throw; |
| | | 192 | | } |
| | 54 | 193 | | } |
| | | 194 | | |
| | | 195 | | private IDisposable? BeginScope(HttpRequestMessage request, long elapsed, int? statusCode) => |
| | 71 | 196 | | logger.BeginScope(new Dictionary<string, object?> |
| | 71 | 197 | | { |
| | 71 | 198 | | ["EventKind"] = "OutboundHttp", |
| | 71 | 199 | | ["EventCategory"] = "external-api", |
| | 71 | 200 | | ["DependencyHost"] = request.RequestUri?.Host, |
| | 71 | 201 | | ["HttpMethod"] = request.Method.Method, |
| | 71 | 202 | | ["RequestUri"] = redactor.RedactUri(request.RequestUri, _options.MaxPayloadCharacters), |
| | 71 | 203 | | ["StatusCode"] = statusCode, |
| | 71 | 204 | | ["ElapsedMilliseconds"] = elapsed |
| | 71 | 205 | | }); |
| | | 206 | | |
| | | 207 | | private static bool IsExpectedMotexSearchNotFound(HttpRequestMessage request, HttpResponseMessage response) => |
| | 54 | 208 | | request.Method == HttpMethod.Get && |
| | 54 | 209 | | response.StatusCode == System.Net.HttpStatusCode.NotFound && |
| | 54 | 210 | | string.Equals(request.RequestUri?.Host, "api.motexc.by", StringComparison.OrdinalIgnoreCase) && |
| | 54 | 211 | | string.Equals(request.RequestUri?.AbsolutePath, "/api/v1/Articles/Search", StringComparison.OrdinalIgnoreCase); |
| | | 212 | | |
| | | 213 | | private static bool IsExpectedMlAutoNotFound(HttpRequestMessage request, HttpResponseMessage response) |
| | | 214 | | { |
| | 54 | 215 | | if (request.Method != HttpMethod.Post || response.StatusCode != System.Net.HttpStatusCode.NotFound) |
| | 40 | 216 | | return false; |
| | | 217 | | |
| | 14 | 218 | | var host = request.RequestUri?.Host; |
| | 14 | 219 | | if (host == null || |
| | 14 | 220 | | (!string.Equals(host, "ml-auto.ru", StringComparison.OrdinalIgnoreCase) && |
| | 14 | 221 | | !string.Equals(host, "www.ml-auto.ru", StringComparison.OrdinalIgnoreCase) && |
| | 14 | 222 | | !string.Equals(host, "ml-auto.by", StringComparison.OrdinalIgnoreCase) && |
| | 14 | 223 | | !string.Equals(host, "www.ml-auto.by", StringComparison.OrdinalIgnoreCase))) |
| | | 224 | | { |
| | 3 | 225 | | return false; |
| | | 226 | | } |
| | | 227 | | |
| | 11 | 228 | | var path = request.RequestUri?.AbsolutePath; |
| | 11 | 229 | | if (path == null) |
| | 0 | 230 | | return false; |
| | | 231 | | |
| | 11 | 232 | | var trimmed = path.TrimEnd('/'); |
| | 11 | 233 | | return trimmed.EndsWith("/GetOrderByNumber", StringComparison.OrdinalIgnoreCase) || |
| | 11 | 234 | | trimmed.EndsWith("/Search", StringComparison.OrdinalIgnoreCase); |
| | | 235 | | } |
| | | 236 | | |
| | | 237 | | private static async Task<bool> IsArmtekDailyQuotaExceededAsync(HttpRequestMessage request, HttpResponseMessage resp |
| | | 238 | | CancellationToken ct) |
| | | 239 | | { |
| | 54 | 240 | | if (response.StatusCode != System.Net.HttpStatusCode.Unauthorized || |
| | 54 | 241 | | !string.Equals(request.RequestUri?.Host, "ws.armtek.ru", StringComparison.OrdinalIgnoreCase)) |
| | 53 | 242 | | return false; |
| | | 243 | | |
| | 1 | 244 | | var body = await response.Content.ReadAsStringAsync(ct); |
| | 1 | 245 | | return ArmtekDailyQuotaGuard.IsExceeded(body); |
| | 54 | 246 | | } |
| | | 247 | | |
| | | 248 | | private static bool IsTransientOzonS3ReportError(HttpRequestMessage request, HttpResponseMessage response) |
| | | 249 | | { |
| | 54 | 250 | | var uri = request.RequestUri; |
| | 54 | 251 | | if (uri == null) |
| | 0 | 252 | | return false; |
| | | 253 | | |
| | 54 | 254 | | var host = uri.Host; |
| | 54 | 255 | | var isOzonHost = host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 256 | | host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 257 | | host.EndsWith(".ozonusercontent.com", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 258 | | string.Equals(host, "ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 259 | | string.Equals(host, "ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 260 | | string.Equals(host, "ozonusercontent.com", StringComparison.OrdinalIgnoreCase); |
| | | 261 | | |
| | 54 | 262 | | if (!isOzonHost || !uri.AbsolutePath.StartsWith("/s3/", StringComparison.OrdinalIgnoreCase)) |
| | 51 | 263 | | return false; |
| | | 264 | | |
| | 3 | 265 | | var code = (int)response.StatusCode; |
| | 3 | 266 | | return code == 403 || code == 404 || code >= 500; |
| | | 267 | | } |
| | | 268 | | |
| | | 269 | | private static bool IsTransientSupplierServiceError(HttpRequestMessage request, HttpResponseMessage response) |
| | | 270 | | { |
| | 54 | 271 | | var code = (int)response.StatusCode; |
| | 54 | 272 | | if (code is not (500 or 502 or 503 or 504)) |
| | 38 | 273 | | return false; |
| | | 274 | | |
| | 16 | 275 | | var host = request.RequestUri?.Host; |
| | 16 | 276 | | if (string.IsNullOrWhiteSpace(host)) |
| | 0 | 277 | | return false; |
| | | 278 | | |
| | 16 | 279 | | return host.EndsWith("armtek.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 280 | | host.EndsWith("armtek.by", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 281 | | host.EndsWith("forum-auto.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 282 | | host.EndsWith("ml-auto.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 283 | | host.EndsWith("ml-auto.by", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 284 | | host.EndsWith("motexc.by", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 285 | | host.EndsWith("profit-liga.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 286 | | host.EndsWith("favorit-parts.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 287 | | host.EndsWith("moskvorechie.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 288 | | host.EndsWith("berg.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 289 | | host.EndsWith("shate-m.by", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 290 | | host.EndsWith("japarts.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 291 | | host.EndsWith("nikei.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 292 | | host.EndsWith("avdmotors.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 293 | | host.EndsWith("mikado-parts.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 294 | | host.EndsWith("zzap.ru", StringComparison.OrdinalIgnoreCase) || |
| | 16 | 295 | | host.EndsWith("zzap.pro", StringComparison.OrdinalIgnoreCase); |
| | | 296 | | } |
| | | 297 | | |
| | | 298 | | private static bool IsOpenRouterAuthError(HttpRequestMessage request, HttpResponseMessage response) |
| | | 299 | | { |
| | 54 | 300 | | return response.StatusCode == System.Net.HttpStatusCode.Unauthorized && |
| | 54 | 301 | | request.RequestUri?.Host.EndsWith("openrouter.ai", StringComparison.OrdinalIgnoreCase) == true; |
| | | 302 | | } |
| | | 303 | | |
| | | 304 | | private static bool IsExpectedOzonProductAttributesNotFound(HttpRequestMessage request, HttpResponseMessage response |
| | | 305 | | { |
| | 54 | 306 | | if (response.StatusCode != System.Net.HttpStatusCode.NotFound || request.Method != HttpMethod.Post) |
| | 40 | 307 | | return false; |
| | | 308 | | |
| | 14 | 309 | | var uri = request.RequestUri; |
| | 14 | 310 | | if (uri == null) |
| | 0 | 311 | | return false; |
| | | 312 | | |
| | 14 | 313 | | return string.Equals(uri.Host, "api-seller.ozon.ru", StringComparison.OrdinalIgnoreCase) && |
| | 14 | 314 | | string.Equals(uri.AbsolutePath, "/v4/product/info/attributes", StringComparison.OrdinalIgnoreCase); |
| | | 315 | | } |
| | | 316 | | |
| | | 317 | | private static bool IsExpectedOzonPostingNotFound(HttpRequestMessage request, HttpResponseMessage response) |
| | | 318 | | { |
| | 54 | 319 | | if (response.StatusCode != System.Net.HttpStatusCode.NotFound || request.Method != HttpMethod.Post) |
| | 40 | 320 | | return false; |
| | | 321 | | |
| | 14 | 322 | | var uri = request.RequestUri; |
| | 14 | 323 | | if (uri == null) |
| | 0 | 324 | | return false; |
| | | 325 | | |
| | 14 | 326 | | var host = uri.Host; |
| | 14 | 327 | | var isOzonHost = host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 14 | 328 | | host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 14 | 329 | | string.Equals(host, "ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 14 | 330 | | string.Equals(host, "ozon.ru", StringComparison.OrdinalIgnoreCase); |
| | | 331 | | |
| | 14 | 332 | | if (!isOzonHost) |
| | 11 | 333 | | return false; |
| | | 334 | | |
| | 3 | 335 | | return string.Equals(uri.AbsolutePath, "/v3/posting/fbs/get", StringComparison.OrdinalIgnoreCase); |
| | | 336 | | } |
| | | 337 | | |
| | | 338 | | private static bool IsTransientOzonChatError(HttpRequestMessage request, HttpResponseMessage response) |
| | | 339 | | { |
| | 54 | 340 | | var uri = request.RequestUri; |
| | 54 | 341 | | if (uri == null) |
| | 0 | 342 | | return false; |
| | | 343 | | |
| | 54 | 344 | | var host = uri.Host; |
| | 54 | 345 | | var isOzonHost = host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 346 | | host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 347 | | string.Equals(host, "ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 54 | 348 | | string.Equals(host, "ozon.ru", StringComparison.OrdinalIgnoreCase); |
| | | 349 | | |
| | 54 | 350 | | if (!isOzonHost || !uri.AbsolutePath.Contains("/chat/", StringComparison.OrdinalIgnoreCase)) |
| | 50 | 351 | | return false; |
| | | 352 | | |
| | 4 | 353 | | var code = (int)response.StatusCode; |
| | 4 | 354 | | return code is 500 or 502 or 503 or 504; |
| | | 355 | | } |
| | | 356 | | |
| | | 357 | | private static bool IsTransientOzonWebhookRateLimit(HttpRequestMessage request, HttpResponseMessage response) |
| | | 358 | | { |
| | 54 | 359 | | if (request.Method != HttpMethod.Post || response.StatusCode != System.Net.HttpStatusCode.TooManyRequests) |
| | 52 | 360 | | return false; |
| | | 361 | | |
| | 2 | 362 | | var uri = request.RequestUri; |
| | 2 | 363 | | return uri != null && |
| | 2 | 364 | | string.Equals(uri.Host, "api-seller.ozon.ru", StringComparison.OrdinalIgnoreCase) && |
| | 2 | 365 | | string.Equals(uri.AbsolutePath, "/v1/notification/list", StringComparison.OrdinalIgnoreCase); |
| | | 366 | | } |
| | | 367 | | |
| | | 368 | | private static bool IsExpectedBusinessRejection(HttpRequestMessage request, HttpResponseMessage response) |
| | | 369 | | { |
| | 54 | 370 | | if (response.StatusCode != System.Net.HttpStatusCode.BadRequest) |
| | 44 | 371 | | return false; |
| | | 372 | | |
| | 10 | 373 | | var uri = request.RequestUri; |
| | 10 | 374 | | if (uri == null) |
| | 0 | 375 | | return false; |
| | | 376 | | |
| | 10 | 377 | | var host = uri.Host; |
| | 10 | 378 | | var isOzonHost = host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 10 | 379 | | host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 10 | 380 | | string.Equals(host, "ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 10 | 381 | | string.Equals(host, "ozon.ru", StringComparison.OrdinalIgnoreCase); |
| | | 382 | | |
| | 10 | 383 | | if (isOzonHost) |
| | | 384 | | { |
| | 7 | 385 | | var path = uri.AbsolutePath; |
| | | 386 | | // Штатные отказы Ozon API при отмене, отгрузке или печати завершённых/отменённых отправлений (#2090, #1815, |
| | | 387 | | // create-or-get экземпляров (#1499) вызывается при проверке перед печатью; для отменённых/ушедших отправлен |
| | | 388 | | // Ozon отвечает 400, а OzonExemplarService.GetRequirementsAsync это штатно пропускает |
| | 7 | 389 | | if (path.Equals("/v6/fbs/posting/product/exemplar/create-or-get", StringComparison.OrdinalIgnoreCase) || |
| | 7 | 390 | | path.Equals("/v2/posting/fbs/cancel", StringComparison.OrdinalIgnoreCase) || |
| | 7 | 391 | | path.Equals("/v4/posting/fbs/ship", StringComparison.OrdinalIgnoreCase) || |
| | 7 | 392 | | path.Equals("/v2/posting/fbs/package-label/create", StringComparison.OrdinalIgnoreCase) || |
| | 7 | 393 | | path.Equals("/v3/posting/fbs/package-label/create", StringComparison.OrdinalIgnoreCase) || |
| | 7 | 394 | | path.Equals("/v2/posting/fbs/package-label/get", StringComparison.OrdinalIgnoreCase) || |
| | 7 | 395 | | path.Equals("/v2/posting/fbs/package-label", StringComparison.OrdinalIgnoreCase)) |
| | | 396 | | { |
| | 7 | 397 | | return true; |
| | | 398 | | } |
| | | 399 | | } |
| | | 400 | | |
| | 3 | 401 | | var isShateMHost = host.EndsWith("shate-m.by", StringComparison.OrdinalIgnoreCase) || |
| | 3 | 402 | | host.EndsWith("shate-m.ru", StringComparison.OrdinalIgnoreCase); |
| | | 403 | | |
| | 3 | 404 | | if (isShateMHost) |
| | | 405 | | { |
| | 2 | 406 | | var path = uri.AbsolutePath; |
| | | 407 | | // Штатные отказы поставщика при заказе позиций (нет остатка, изменилась цена) (#2084) |
| | 2 | 408 | | if (path.StartsWith("/api/v1/orders/byPriceItems", StringComparison.OrdinalIgnoreCase) || |
| | 2 | 409 | | path.StartsWith("/api/v1/orders/byCartItems", StringComparison.OrdinalIgnoreCase)) |
| | | 410 | | { |
| | 2 | 411 | | return true; |
| | | 412 | | } |
| | | 413 | | } |
| | | 414 | | |
| | 1 | 415 | | return false; |
| | | 416 | | } |
| | | 417 | | |
| | | 418 | | private static bool IsExpectedOzonMethodNotAllowed(HttpRequestMessage request, HttpResponseMessage response) |
| | | 419 | | { |
| | 54 | 420 | | if (response.StatusCode != System.Net.HttpStatusCode.MethodNotAllowed) |
| | 49 | 421 | | return false; |
| | | 422 | | |
| | 5 | 423 | | var uri = request.RequestUri; |
| | 5 | 424 | | if (uri == null) |
| | 0 | 425 | | return false; |
| | | 426 | | |
| | 5 | 427 | | var host = uri.Host; |
| | 5 | 428 | | return host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 5 | 429 | | host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 5 | 430 | | string.Equals(host, "ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 5 | 431 | | string.Equals(host, "ozon.ru", StringComparison.OrdinalIgnoreCase); |
| | | 432 | | } |
| | | 433 | | |
| | | 434 | | private static bool IsExpectedOneCDocumentPostingRejection(HttpRequestMessage request, HttpResponseMessage response) |
| | | 435 | | { |
| | | 436 | | // Регрессия #654, #1995: 1С OData возвращает HTTP 500 (Internal Server Error) при штатном отказе проведения |
| | | 437 | | // документа (недостаточно остатка, не списано по партиям, блокировки и т.д.). |
| | | 438 | | // Это ожидаемый бизнес-исход: документ остаётся черновиком, а менеджеры 1С (OneCTransferManager, |
| | | 439 | | // OneCExpenseManager, OneCReceiptManager) штатно перехватывают OneCDocumentPostingException и логируют его как |
| | 54 | 440 | | if (response.StatusCode != System.Net.HttpStatusCode.InternalServerError && |
| | 54 | 441 | | response.StatusCode != System.Net.HttpStatusCode.BadRequest) |
| | | 442 | | { |
| | 39 | 443 | | return false; |
| | | 444 | | } |
| | | 445 | | |
| | 15 | 446 | | if (request.Method != HttpMethod.Post) |
| | 1 | 447 | | return false; |
| | | 448 | | |
| | 14 | 449 | | var uri = request.RequestUri; |
| | 14 | 450 | | if (uri == null) |
| | 0 | 451 | | return false; |
| | | 452 | | |
| | 14 | 453 | | var path = uri.AbsolutePath; |
| | 14 | 454 | | var isOneCDocumentPost = path.Contains("/odata/standard.odata/Document_", StringComparison.OrdinalIgnoreCase) |
| | 14 | 455 | | && path.EndsWith("/Post", StringComparison.OrdinalIgnoreCase); |
| | | 456 | | |
| | 14 | 457 | | return isOneCDocumentPost; |
| | | 458 | | } |
| | | 459 | | } |