< Summary

Information
Class: Services.ApiServices.OzonApi.OzonApiClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/OzonApiClient.cs
Line coverage
49%
Covered lines: 72
Uncovered lines: 74
Coverable lines: 146
Total lines: 296
Line coverage: 49.3%
Branch coverage
51%
Covered branches: 39
Total branches: 76
Branch coverage: 51.3%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%11100%
.ctor(...)50%22100%
SetRequestHeaders(...)100%210%
SetHttpClientHeaders(...)100%210%
MakeRequestPostAsync()75%8881.25%
MakeRequestPostForBitrixAsync()60%422062.06%
IsBitrixOzonTransientFailure(...)58.33%171266.66%
ThrottleStocksUpdateAsync()0%110100%
MakeStocksUpdateRequestAsync()100%210%
MakeFastRequestAsync()65%222083.87%
MakeFileRequestAsync()0%2040%
GetTestRequest()100%210%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/OzonApi/OzonApiClient.cs

#LineLine coverage
 1using System.Net;
 2using System.Net.Http;
 3using System.Text;
 4using Microsoft.Extensions.Http;
 5using Newtonsoft.Json;
 6using Newtonsoft.Json.Linq;
 7using Services.ApiServices;
 8using SlqStudio.Application.Services.EmailService;
 9
 10namespace Services.ApiServices.OzonApi
 11{
 12    public class OzonApiClient : BaseApiClient
 13    {
 14        private readonly string _clientId;
 15        private readonly string _apiKey;
 16
 17        // Простая защита от слишком частых быстрых запросов:
 18        // задержка включается только если в окне времени уже было много запросов.
 119        private static readonly object _fastLock = new object();
 120        private static readonly Queue<DateTime> _fastRequestTimes = new Queue<DateTime>();
 121        private static readonly TimeSpan _fastWindow = TimeSpan.FromSeconds(5);
 22        private const int _fastThreshold = 15; // максимум 15 быстрых запросов за 5 секунд
 23
 24        // POST /v2/products/stocks: Ozon ограничивает до 80 запросов/минуту НА ОДИН аккаунт продавца
 25        // (Client-Id) — отдельно от общего _fastLock выше, который делит бюджет между всеми аккаунтами
 26        // и эндпоинтами и не гарантирует этот лимит для конкретного клиента.
 127        private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, object> _stocksLocks = new();
 128        private static readonly System.Collections.Concurrent.ConcurrentDictionary<string, Queue<DateTime>> _stocksReque
 129        private static readonly TimeSpan _stocksWindow = TimeSpan.FromMinutes(1);
 30        private const int _stocksRateLimit = 80;
 131        private static readonly TimeSpan BitrixPostTimeout = TimeSpan.FromSeconds(90);
 32        private const string BitrixHttpClientName = "OzonApiBitrix";
 33
 34        public OzonApiClient(
 35            IHttpClientFactory httpClientFactory,
 36            IEmailService? emailService,
 37            string clientId,
 38            string apiKey)
 4439            : base(httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)), emailService, "OzonA
 40        {
 4441            _clientId = clientId;
 4442            _apiKey = apiKey;
 4443        }
 44
 45        protected override void SetRequestHeaders(HttpWebRequest request)
 46        {
 047            request.Headers.Add("Client-Id", _clientId);
 048            request.Headers.Add("Api-Key", _apiKey);
 049        }
 50
 51        protected override void SetHttpClientHeaders(HttpClient client)
 52        {
 053            client.DefaultRequestHeaders.Add("Client-Id", _clientId);
 054            client.DefaultRequestHeaders.Add("Api-Key", _apiKey);
 055        }
 56
 57        /// <summary>
 58        /// Асинхронный POST с задержкой 2 сек (rate limit). Использует IHttpClientFactory.
 59        /// </summary>
 60        public new async Task<JObject> MakeRequestPostAsync<T>(T requestModel, string requestUri) where T : IRequestMode
 61        {
 562            await Task.Delay(2000).ConfigureAwait(false);
 563            var client = _httpClientFactory!.CreateClient(_clientName);
 564            client.Timeout = _timeout;
 565            var json = JsonConvert.SerializeObject(requestModel);
 566            using var content = new StringContent(json, Encoding.UTF8, "application/json");
 567            var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = content };
 568            request.Headers.Add("Client-Id", _clientId);
 569            request.Headers.Add("Api-Key", _apiKey);
 570            using var response = await client.SendAsync(request).ConfigureAwait(false);
 571            var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
 572            if (!response.IsSuccessStatusCode)
 73            {
 074                await HandleErrorAsync($"HTTP {response.StatusCode} при запросе к {_clientName}",
 075                    new HttpRequestException($"Status: {response.StatusCode}, Body: {responseBody}"), requestUri).Config
 076                throw new HttpRequestException($"Ozon API вернул {(int)response.StatusCode} {response.StatusCode}. Ответ
 77            }
 578            return string.IsNullOrWhiteSpace(responseBody) ? new JObject() : JObject.Parse(responseBody);
 579        }
 80
 81        /// <summary>
 82        /// Асинхронный POST для Bitrix-сценариев с повтором при 429. Использует IHttpClientFactory.
 83        /// </summary>
 84        public async Task<JObject> MakeRequestPostForBitrixAsync<T>(T requestModel, string requestUri) where T : IReques
 85        {
 86            const int maxRetries = 3;
 2787            int delayMs = 500;
 5488            for (int attempt = 1; attempt <= maxRetries; attempt++)
 89            {
 2790                await Task.Delay(attempt == 1 ? 100 : 0).ConfigureAwait(false);
 2791                var client = _httpClientFactory!.CreateClient(BitrixHttpClientName);
 2792                client.Timeout = BitrixPostTimeout;
 2793                var json = JsonConvert.SerializeObject(requestModel);
 2794                using var content = new StringContent(json, Encoding.UTF8, "application/json");
 2795                var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = content };
 2796                request.Headers.Add("Client-Id", _clientId);
 2797                request.Headers.Add("Api-Key", _apiKey);
 98                try
 99                {
 27100                    using var response = await client.SendAsync(request).ConfigureAwait(false);
 27101                    var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
 102
 27103                    if (response.StatusCode == (HttpStatusCode)429)
 104                    {
 0105                        if (attempt == maxRetries)
 106                        {
 0107                            throw new Exception($"Request failed after retries. Status: 429, Body: {responseBody}");
 108                        }
 109
 0110                        await Task.Delay(delayMs).ConfigureAwait(false);
 0111                        delayMs *= 2;
 0112                        continue;
 113                    }
 114
 27115                    if (!response.IsSuccessStatusCode)
 116                    {
 4117                        throw new Exception($"Request failed. Status: {response.StatusCode}, Body: {responseBody}");
 118                    }
 119
 23120                    return string.IsNullOrWhiteSpace(responseBody) ? new JObject() : JObject.Parse(responseBody);
 121                }
 4122                catch (Exception ex) when (IsBitrixOzonTransientFailure(ex))
 123                {
 0124                    if (attempt == maxRetries)
 125                    {
 0126                        throw;
 127                    }
 128
 0129                    await Task.Delay(delayMs).ConfigureAwait(false);
 0130                    delayMs *= 2;
 131                }
 0132            }
 133
 0134            throw new Exception("Unknown error in retry loop.");
 23135        }
 136
 137        private static bool IsBitrixOzonTransientFailure(Exception ex)
 138        {
 16139            for (var current = ex; current != null; current = current.InnerException)
 140            {
 4141                if (current is TaskCanceledException or TimeoutException or HttpRequestException)
 142                {
 0143                    return true;
 144                }
 145
 4146                if (current.GetType().FullName == "Polly.CircuitBreaker.BrokenCircuitException")
 147                {
 0148                    return true;
 149                }
 150            }
 151
 4152            return false;
 153        }
 154
 155        /// <summary>
 156        /// Троттлинг specifically для POST /v2/products/stocks: не более 80 запросов/минуту
 157        /// на один Client-Id (скользящее окно), как того требует документация Ozon.
 158        /// </summary>
 159        private async Task ThrottleStocksUpdateAsync()
 160        {
 161            TimeSpan delay;
 0162            var lockObj = _stocksLocks.GetOrAdd(_clientId, _ => new object());
 0163            lock (lockObj)
 164            {
 0165                var queue = _stocksRequestTimes.GetOrAdd(_clientId, _ => new Queue<DateTime>());
 0166                var now = DateTime.UtcNow;
 0167                while (queue.Count > 0 && now - queue.Peek() > _stocksWindow)
 0168                    queue.Dequeue();
 169
 0170                delay = queue.Count >= _stocksRateLimit
 0171                    ? queue.Peek() + _stocksWindow - now
 0172                    : TimeSpan.Zero;
 0173                if (delay < TimeSpan.Zero)
 0174                    delay = TimeSpan.Zero;
 175
 0176                queue.Enqueue(now + delay);
 0177            }
 178
 0179            if (delay > TimeSpan.Zero)
 0180                await Task.Delay(delay).ConfigureAwait(false);
 0181        }
 182
 183        /// <summary>
 184        /// POST /v2/products/stocks с троттлингом до 80 запросов/минуту на аккаунт.
 185        /// </summary>
 186        public async Task<JObject> MakeStocksUpdateRequestAsync<T>(T requestModel, string requestUri) where T : IRequest
 187        {
 0188            await ThrottleStocksUpdateAsync().ConfigureAwait(false);
 0189            return await MakeFastRequestAsync(requestModel, requestUri).ConfigureAwait(false);
 0190        }
 191
 192        /// <summary>
 193        /// Быстрый асинхронный POST (задержка 500 мс). Использует IHttpClientFactory.
 194        /// </summary>
 195        /// <param name="clientNameOverride">Опционально: имя HttpClient (например "OzonApiReturns" без Circuit Breaker)
 196        public async Task<JObject> MakeFastRequestAsync<T>(T requestModel, string requestUri, string? clientNameOverride
 197        {
 198            // Задержка только если в последние _fastWindow было уже много быстрых запросов
 9199            bool needDelay = false;
 9200            lock (_fastLock)
 201            {
 9202                var now = DateTime.UtcNow;
 203
 16204                while (_fastRequestTimes.Count > 0 &&
 16205                       now - _fastRequestTimes.Peek() > _fastWindow)
 206                {
 7207                    _fastRequestTimes.Dequeue();
 208                }
 209
 9210                if (_fastRequestTimes.Count >= _fastThreshold)
 211                {
 0212                    needDelay = true;
 213                }
 214
 9215                _fastRequestTimes.Enqueue(now);
 9216            }
 217
 9218            if (needDelay)
 219            {
 0220                await Task.Delay(500).ConfigureAwait(false);
 221            }
 222
 9223            var clientName = clientNameOverride ?? _clientName;
 9224            var client = _httpClientFactory!.CreateClient(clientName);
 9225            client.Timeout = _timeout;
 9226            var json = JsonConvert.SerializeObject(requestModel);
 9227            using var content = new StringContent(json, Encoding.UTF8, "application/json");
 9228            var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = content };
 9229            request.Headers.Add("Client-Id", _clientId);
 9230            request.Headers.Add("Api-Key", _apiKey);
 9231            using var response = await client.SendAsync(request).ConfigureAwait(false);
 9232            var responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
 233
 9234            if (!response.IsSuccessStatusCode)
 235            {
 236                try
 237                {
 1238                    var doc = System.Text.Json.JsonDocument.Parse(responseBody);
 0239                    if (doc.RootElement.TryGetProperty("message", out var message))
 0240                        throw new Exception(message.GetString() ?? responseBody);
 0241                }
 2242                catch (System.Text.Json.JsonException) { }
 1243                throw new Exception($"Request failed ({(int)response.StatusCode} {response.ReasonPhrase})\n{responseBody
 244            }
 245
 8246            return string.IsNullOrWhiteSpace(responseBody) ? new JObject() : JObject.Parse(responseBody);
 8247        }
 248
 249        /// <summary>
 250        /// Асинхронный POST с возвратом файла (stream). Использует IHttpClientFactory.
 251        /// </summary>
 252        public async Task<MemoryStream> MakeFileRequestAsync<T>(T requestModel, string requestUri)
 253            where T : IRequestModel
 254        {
 0255            var client = _httpClientFactory!.CreateClient(_clientName);
 0256            client.Timeout = _timeout;
 0257            var json = JsonConvert.SerializeObject(requestModel);
 0258            using var content = new StringContent(json, Encoding.UTF8, "application/json");
 0259            var request = new HttpRequestMessage(HttpMethod.Post, requestUri) { Content = content };
 0260            request.Headers.Add("Client-Id", _clientId);
 0261            request.Headers.Add("Api-Key", _apiKey);
 0262            using var response = await client.SendAsync(request).ConfigureAwait(false);
 263
 0264            if (!response.IsSuccessStatusCode)
 265            {
 0266                var errorBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
 0267                throw new Exception($"Request failed. Status: {response.StatusCode}, Body: {errorBody}");
 268            }
 269
 0270            var memoryStream = new MemoryStream();
 0271            await response.Content.CopyToAsync(memoryStream).ConfigureAwait(false);
 0272            memoryStream.Seek(0, SeekOrigin.Begin);
 0273            return memoryStream;
 0274        }
 275
 276        public override async Task<bool> GetTestRequest(string clientId, string apiKey)
 277        {
 0278            var requestUri = "https://api-seller.ozon.ru/v1/warehouse/list";
 0279            var client = _httpClientFactory!.CreateClient(_clientName);
 0280            client.Timeout = _timeout;
 0281            var request = new HttpRequestMessage(HttpMethod.Post, requestUri);
 0282            request.Headers.Add("Client-Id", clientId);
 0283            request.Headers.Add("Api-Key", apiKey);
 0284            request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
 285            try
 286            {
 0287                var response = await client.SendAsync(request).ConfigureAwait(false);
 0288                return response.IsSuccessStatusCode;
 289            }
 0290            catch
 291            {
 0292                return false;
 293            }
 0294        }
 295    }
 296}