< Summary

Information
Class: Services.ApiServices.ZZapApi.ZZapHttpClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ZZapApi/ZZapHttpClient.cs
Line coverage
100%
Covered lines: 69
Uncovered lines: 0
Coverable lines: 69
Total lines: 153
Line coverage: 100%
Branch coverage
92%
Covered branches: 35
Total branches: 38
Branch coverage: 92.1%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
GetAsync(...)100%11100%
PostAsync(...)100%11100%
DeleteAsync(...)100%11100%
SendAsync()100%1010100%
WaitForSlotAsync()100%22100%
SendOnceAsync()100%66100%
EnsureSuccess(...)91.66%1212100%
ReadErrors(...)75%44100%
BuildEndpoint(...)75%44100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/ApiServices/ZZapApi/ZZapHttpClient.cs

#LineLine coverage
 1using System.Diagnostics;
 2using Newtonsoft.Json.Linq;
 3using Services.ApiServices.ApiSupplierRequestLog;
 4
 5namespace Services.ApiServices.ZZapApi;
 6
 7/// <summary>Транспорт ZZap.ru. Ключ уходит заголовком zzap-api-key и в журнал не попадает.
 8/// Клиент — singleton: через него проходит общий для процесса троттлинг, потому что ZZap
 9/// считает частоту по ключу, а не по соединению, и на превышение отвечает 429.</summary>
 10public sealed class ZZapHttpClient
 11{
 12    private const string Provider = "ZZapMoscow";
 13    private const string ClientName = "ZZapApi";
 14    private const int TooManyRequests = 429;
 15
 16    private readonly IHttpClientFactory _factory;
 17    private readonly ZZapConfig _config;
 18    private readonly ApiSupplierRequestLogStore? _logs;
 11719    private readonly SemaphoreSlim _gate = new(1, 1);
 11720    private DateTime _lastRequestStartedUtc = DateTime.MinValue;
 21
 11722    public ZZapHttpClient(IHttpClientFactory factory, ZZapConfig config, ApiSupplierRequestLogStore? logs = null)
 23    {
 11724        _factory = factory;
 11725        _config = config;
 11726        _logs = logs;
 11727    }
 28
 29    /// <summary>GET с разбором конверта {result, result_info, success, code, errors}. Возвращает весь конверт:
 30    /// консоли показывают его как есть, а data manager достаёт result сам.</summary>
 31    public Task<JObject> GetAsync(string path, IReadOnlyDictionary<string, string?>? query = null,
 7632        CancellationToken ct = default) => SendAsync(HttpMethod.Get, path, query, null, ct);
 33
 34    /// <summary>POST с JSON-телом. Тело уходит в журнал: секретов в нём нет, ключ только в заголовке.</summary>
 35    public Task<JObject> PostAsync(string path, JObject body, IReadOnlyDictionary<string, string?>? query = null,
 3036        CancellationToken ct = default) => SendAsync(HttpMethod.Post, path, query, body, ct);
 37
 38    public Task<JObject> DeleteAsync(string path, IReadOnlyDictionary<string, string?>? query = null,
 1039        CancellationToken ct = default) => SendAsync(HttpMethod.Delete, path, query, null, ct);
 40
 41    private async Task<JObject> SendAsync(HttpMethod method, string path, IReadOnlyDictionary<string, string?>? query,
 42        JObject? body, CancellationToken ct)
 43    {
 11644        if (string.IsNullOrWhiteSpace(_config.ApiKey))
 145            throw new ZZapApiException("Не настроен ZZap:ApiKey.", requestPath: path);
 46
 11547        var endpoint = BuildEndpoint(path, query);
 11548        var requestBody = body?.ToString(Newtonsoft.Json.Formatting.None);
 11549        await _gate.WaitAsync(ct);
 50        try
 51        {
 11552            await WaitForSlotAsync(ct);
 11553            var (payload, statusCode) = await SendOnceAsync(method, endpoint, requestBody, ct);
 54            // Повтор только для чтения: write-запрос ZZap мог уже выполниться, второй создал бы дубль.
 11055            if (statusCode == TooManyRequests && method == HttpMethod.Get)
 56            {
 57                // Лимит ZZap скользящий, и первый же запрос после полной паузы обычно проходит.
 58                // Второй 429 подряд отдаём наружу — значит, ключ забанен или капча.
 259                await WaitForSlotAsync(ct);
 260                (payload, statusCode) = await SendOnceAsync(method, endpoint, requestBody, ct);
 61            }
 62
 11063            EnsureSuccess(payload, statusCode, path);
 9564            return payload;
 65        }
 66        finally
 67        {
 11568            _gate.Release();
 69        }
 9570    }
 71
 72    private async Task WaitForSlotAsync(CancellationToken ct)
 73    {
 74        // Лимит ZZap считается между стартами запросов, поэтому паузу отмеряем от старта предыдущего,
 75        // а не от его завершения: время ответа (до нескольких секунд) не должно добавляться к интервалу.
 11776        var elapsed = DateTime.UtcNow - _lastRequestStartedUtc;
 11777        var wait = TimeSpan.FromMilliseconds(_config.MinRequestIntervalMs) - elapsed;
 11778        if (wait > TimeSpan.Zero)
 279            await Task.Delay(wait, ct);
 11780        _lastRequestStartedUtc = DateTime.UtcNow;
 11781    }
 82
 83    private async Task<(JObject Payload, int StatusCode)> SendOnceAsync(HttpMethod method, string endpoint,
 84        string? requestBody, CancellationToken ct)
 85    {
 11786        var timer = Stopwatch.StartNew();
 87        HttpResponseMessage response;
 88        try
 89        {
 11790            using var request = new HttpRequestMessage(method, $"{_config.BaseUrl.TrimEnd('/')}{endpoint}");
 11791            request.Headers.Add("zzap-api-key", _config.ApiKey);
 11792            request.Headers.Accept.ParseAdd("application/json");
 11793            if (requestBody is not null)
 3094                request.Content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json");
 11795            response = await _factory.CreateClient(ClientName).SendAsync(request, ct);
 11396        }
 497        catch (Exception ex) when (ex is not ZZapApiException)
 98        {
 499            _logs?.Add(Provider, method.Method, endpoint, null, timer, requestBody, ex.Message);
 4100            throw new ZZapApiException("ZZap недоступен.", requestPath: endpoint, innerException: ex);
 101        }
 102
 113103        using (response)
 104        {
 113105            var text = await response.Content.ReadAsStringAsync(ct);
 113106            var statusCode = (int)response.StatusCode;
 113107            _logs?.Add(Provider, method.Method, endpoint, statusCode, timer, requestBody, text);
 108
 109            JObject payload;
 225110            try { payload = JObject.Parse(text); }
 1111            catch (Exception ex)
 112            {
 1113                throw new ZZapApiException($"ZZap вернул невалидный JSON (HTTP {statusCode}).", statusCode, endpoint, ex
 114            }
 115
 112116            return (payload, statusCode);
 117        }
 112118    }
 119
 120    /// <summary>ZZap дублирует HTTP-статус в поле code и кладёт причину в errors — объект вида
 121    /// {"error": "…"}. success=false приходит и с HTTP 200, поэтому проверяем оба признака.</summary>
 122    private static void EnsureSuccess(JObject payload, int statusCode, string path)
 123    {
 110124        var success = payload.Value<bool?>("success") ?? statusCode is >= 200 and < 300;
 205125        if (success && statusCode is >= 200 and < 300) return;
 126
 15127        var code = payload.Value<int?>("code") ?? statusCode;
 15128        var message = ReadErrors(payload);
 15129        throw new ZZapApiException(
 15130            code == TooManyRequests
 15131                ? "ZZap: превышена частота запросов (не чаще 1 запроса в 3 секунды)."
 15132                : $"ZZap отклонил запрос (HTTP {code}). {message}".TrimEnd(),
 15133            code, path);
 134    }
 135
 136    private static string ReadErrors(JObject payload)
 137    {
 16138        if (payload["errors"] is not JObject errors) return string.Empty;
 14139        return string.Join(" ", errors.Properties()
 15140            .Select(x => x.Value.Type == JTokenType.String ? x.Value.Value<string>() : x.Value.ToString())
 29141            .Where(x => !string.IsNullOrWhiteSpace(x)));
 142    }
 143
 144    private static string BuildEndpoint(string path, IReadOnlyDictionary<string, string?>? query)
 145    {
 115146        var normalized = "/" + path.TrimStart('/');
 187147        if (query is null) return normalized;
 43148        var suffix = string.Join("&", query
 70149            .Where(x => x.Value is not null)
 113150            .Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}"));
 43151        return suffix.Length == 0 ? normalized : $"{normalized}?{suffix}";
 152    }
 153}