| | | 1 | | using System; |
| | | 2 | | using System.Net; |
| | | 3 | | using System.Net.Http; |
| | | 4 | | using System.Threading; |
| | | 5 | | using System.Threading.Tasks; |
| | | 6 | | using Microsoft.Extensions.Logging; |
| | | 7 | | |
| | | 8 | | namespace Services.ApiServices.OzonApi; |
| | | 9 | | |
| | | 10 | | /// <summary> |
| | | 11 | | /// Помощник безопасного скачивания отчётов Ozon из хранилища S3 (ir-*.ozone.ru/s3/...). |
| | | 12 | | /// |
| | | 13 | | /// После генерации отчёта Ozon API возвращает статус "success" и подписанную ссылку S3, |
| | | 14 | | /// однако на узлах S3 Ozon наблюдается eventual consistency (задержка репликации метаданных |
| | | 15 | | /// и проверки подписи SigV4). В первые миллисекунды после создания запросы могут возвращать |
| | | 16 | | /// 403 Forbidden, 404 Not Found или 502/503/504 Bad Gateway/Service Unavailable. |
| | | 17 | | /// Данный класс выполняет повторные попытки с нарастающей задержкой (backoff). |
| | | 18 | | /// </summary> |
| | | 19 | | public static class OzonReportDownloadHelper |
| | | 20 | | { |
| | 1 | 21 | | private static readonly int[] RetryDelaysMs = [500, 1000, 2000, 3000]; |
| | | 22 | | |
| | | 23 | | /// <summary> |
| | | 24 | | /// Проверяет, указывает ли URI на Ozon S3 хранилище отчётов (*.ozone.ru/s3/... или *.ozon.ru/s3/...). |
| | | 25 | | /// </summary> |
| | | 26 | | public static bool IsOzonS3Uri(Uri? uri) |
| | | 27 | | { |
| | 11 | 28 | | if (uri == null) |
| | 1 | 29 | | return false; |
| | | 30 | | |
| | 10 | 31 | | var host = uri.Host; |
| | 10 | 32 | | var isOzonHost = host.EndsWith(".ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 10 | 33 | | host.EndsWith(".ozon.ru", StringComparison.OrdinalIgnoreCase) || |
| | 10 | 34 | | string.Equals(host, "ozone.ru", StringComparison.OrdinalIgnoreCase) || |
| | 10 | 35 | | string.Equals(host, "ozon.ru", StringComparison.OrdinalIgnoreCase); |
| | | 36 | | |
| | 10 | 37 | | return isOzonHost && uri.AbsolutePath.StartsWith("/s3/", StringComparison.OrdinalIgnoreCase); |
| | | 38 | | } |
| | | 39 | | |
| | | 40 | | /// <summary> |
| | | 41 | | /// Проверяет, является ли статус-код временным сбоем/задержкой репликации Ozon S3. |
| | | 42 | | /// </summary> |
| | | 43 | | public static bool IsTransientStatusCode(HttpStatusCode statusCode) |
| | | 44 | | { |
| | 12 | 45 | | var code = (int)statusCode; |
| | 12 | 46 | | return statusCode == HttpStatusCode.Forbidden || |
| | 12 | 47 | | statusCode == HttpStatusCode.NotFound || |
| | 12 | 48 | | code >= 500; |
| | | 49 | | } |
| | | 50 | | |
| | | 51 | | /// <summary> |
| | | 52 | | /// Выполняет GET-запрос к файлу отчёта с автоматическим повтором при временных ответах Ozon S3. |
| | | 53 | | /// </summary> |
| | | 54 | | public static async Task<HttpResponseMessage> GetWithRetryAsync( |
| | | 55 | | HttpClient httpClient, |
| | | 56 | | string fileUrl, |
| | | 57 | | ILogger? logger = null, |
| | | 58 | | CancellationToken cancellationToken = default) |
| | | 59 | | { |
| | 3 | 60 | | ArgumentException.ThrowIfNullOrWhiteSpace(fileUrl); |
| | | 61 | | |
| | 3 | 62 | | if (!Uri.TryCreate(fileUrl, UriKind.Absolute, out var uri)) |
| | 0 | 63 | | throw new ArgumentException($"Некорректный URL файла отчета: '{fileUrl}'", nameof(fileUrl)); |
| | | 64 | | |
| | 3 | 65 | | var isS3 = IsOzonS3Uri(uri); |
| | 3 | 66 | | var maxAttempts = isS3 ? RetryDelaysMs.Length + 1 : 1; |
| | | 67 | | |
| | 12 | 68 | | for (var attempt = 1; attempt <= maxAttempts; attempt++) |
| | | 69 | | { |
| | 6 | 70 | | cancellationToken.ThrowIfCancellationRequested(); |
| | | 71 | | |
| | | 72 | | HttpResponseMessage response; |
| | | 73 | | try |
| | | 74 | | { |
| | 6 | 75 | | response = await httpClient.GetAsync(uri, HttpCompletionOption.ResponseHeadersRead, cancellationToken) |
| | 6 | 76 | | .ConfigureAwait(false); |
| | 6 | 77 | | } |
| | 0 | 78 | | catch (HttpRequestException ex) when (isS3 && attempt < maxAttempts) |
| | | 79 | | { |
| | 0 | 80 | | var delayMs = RetryDelaysMs[attempt - 1]; |
| | 0 | 81 | | logger?.LogInformation(ex, |
| | 0 | 82 | | "Ошибка соединения при скачивании Ozon S3 отчёта (попытка {Attempt}/{MaxAttempts}), повтор через {De |
| | 0 | 83 | | attempt, maxAttempts, delayMs); |
| | 0 | 84 | | await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); |
| | 0 | 85 | | continue; |
| | | 86 | | } |
| | | 87 | | |
| | 6 | 88 | | if (response.IsSuccessStatusCode) |
| | 2 | 89 | | return response; |
| | | 90 | | |
| | 4 | 91 | | if (isS3 && IsTransientStatusCode(response.StatusCode) && attempt < maxAttempts) |
| | | 92 | | { |
| | 3 | 93 | | var delayMs = RetryDelaysMs[attempt - 1]; |
| | 3 | 94 | | logger?.LogInformation( |
| | 3 | 95 | | "Ozon S3 отчёт вернул HTTP {StatusCode} на попытке {Attempt}/{MaxAttempts}. Ожидание {DelayMs} мс пе |
| | 3 | 96 | | (int)response.StatusCode, attempt, maxAttempts, delayMs); |
| | 3 | 97 | | response.Dispose(); |
| | 3 | 98 | | await Task.Delay(delayMs, cancellationToken).ConfigureAwait(false); |
| | 3 | 99 | | continue; |
| | | 100 | | } |
| | | 101 | | |
| | 1 | 102 | | return response; |
| | | 103 | | } |
| | | 104 | | |
| | 0 | 105 | | throw new InvalidOperationException("Unreachable code in OzonReportDownloadHelper.GetWithRetryAsync"); |
| | 3 | 106 | | } |
| | | 107 | | |
| | | 108 | | /// <summary> |
| | | 109 | | /// Скачивает содержимое отчёта в виде байтового массива с автоматическим повтором при временных сбоях Ozon S3. |
| | | 110 | | /// </summary> |
| | | 111 | | public static async Task<byte[]> DownloadBytesWithRetryAsync( |
| | | 112 | | HttpClient httpClient, |
| | | 113 | | string fileUrl, |
| | | 114 | | ILogger? logger = null, |
| | | 115 | | CancellationToken cancellationToken = default) |
| | | 116 | | { |
| | 3 | 117 | | using var response = await GetWithRetryAsync(httpClient, fileUrl, logger, cancellationToken).ConfigureAwait(fals |
| | 3 | 118 | | response.EnsureSuccessStatusCode(); |
| | 2 | 119 | | return await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false); |
| | 2 | 120 | | } |
| | | 121 | | } |