| | | 1 | | using System.Diagnostics; |
| | | 2 | | using Newtonsoft.Json.Linq; |
| | | 3 | | using Services.ApiServices.ApiSupplierRequestLog; |
| | | 4 | | |
| | | 5 | | namespace Services.ApiServices.ZZapApi; |
| | | 6 | | |
| | | 7 | | /// <summary>Транспорт ZZap.ru. Ключ уходит заголовком zzap-api-key и в журнал не попадает. |
| | | 8 | | /// Клиент — singleton: через него проходит общий для процесса троттлинг, потому что ZZap |
| | | 9 | | /// считает частоту по ключу, а не по соединению, и на превышение отвечает 429.</summary> |
| | | 10 | | public 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; |
| | 117 | 19 | | private readonly SemaphoreSlim _gate = new(1, 1); |
| | 117 | 20 | | private DateTime _lastRequestStartedUtc = DateTime.MinValue; |
| | | 21 | | |
| | 117 | 22 | | public ZZapHttpClient(IHttpClientFactory factory, ZZapConfig config, ApiSupplierRequestLogStore? logs = null) |
| | | 23 | | { |
| | 117 | 24 | | _factory = factory; |
| | 117 | 25 | | _config = config; |
| | 117 | 26 | | _logs = logs; |
| | 117 | 27 | | } |
| | | 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, |
| | 76 | 32 | | 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, |
| | 30 | 36 | | CancellationToken ct = default) => SendAsync(HttpMethod.Post, path, query, body, ct); |
| | | 37 | | |
| | | 38 | | public Task<JObject> DeleteAsync(string path, IReadOnlyDictionary<string, string?>? query = null, |
| | 10 | 39 | | 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 | | { |
| | 116 | 44 | | if (string.IsNullOrWhiteSpace(_config.ApiKey)) |
| | 1 | 45 | | throw new ZZapApiException("Не настроен ZZap:ApiKey.", requestPath: path); |
| | | 46 | | |
| | 115 | 47 | | var endpoint = BuildEndpoint(path, query); |
| | 115 | 48 | | var requestBody = body?.ToString(Newtonsoft.Json.Formatting.None); |
| | 115 | 49 | | await _gate.WaitAsync(ct); |
| | | 50 | | try |
| | | 51 | | { |
| | 115 | 52 | | await WaitForSlotAsync(ct); |
| | 115 | 53 | | var (payload, statusCode) = await SendOnceAsync(method, endpoint, requestBody, ct); |
| | | 54 | | // Повтор только для чтения: write-запрос ZZap мог уже выполниться, второй создал бы дубль. |
| | 110 | 55 | | if (statusCode == TooManyRequests && method == HttpMethod.Get) |
| | | 56 | | { |
| | | 57 | | // Лимит ZZap скользящий, и первый же запрос после полной паузы обычно проходит. |
| | | 58 | | // Второй 429 подряд отдаём наружу — значит, ключ забанен или капча. |
| | 2 | 59 | | await WaitForSlotAsync(ct); |
| | 2 | 60 | | (payload, statusCode) = await SendOnceAsync(method, endpoint, requestBody, ct); |
| | | 61 | | } |
| | | 62 | | |
| | 110 | 63 | | EnsureSuccess(payload, statusCode, path); |
| | 95 | 64 | | return payload; |
| | | 65 | | } |
| | | 66 | | finally |
| | | 67 | | { |
| | 115 | 68 | | _gate.Release(); |
| | | 69 | | } |
| | 95 | 70 | | } |
| | | 71 | | |
| | | 72 | | private async Task WaitForSlotAsync(CancellationToken ct) |
| | | 73 | | { |
| | | 74 | | // Лимит ZZap считается между стартами запросов, поэтому паузу отмеряем от старта предыдущего, |
| | | 75 | | // а не от его завершения: время ответа (до нескольких секунд) не должно добавляться к интервалу. |
| | 117 | 76 | | var elapsed = DateTime.UtcNow - _lastRequestStartedUtc; |
| | 117 | 77 | | var wait = TimeSpan.FromMilliseconds(_config.MinRequestIntervalMs) - elapsed; |
| | 117 | 78 | | if (wait > TimeSpan.Zero) |
| | 2 | 79 | | await Task.Delay(wait, ct); |
| | 117 | 80 | | _lastRequestStartedUtc = DateTime.UtcNow; |
| | 117 | 81 | | } |
| | | 82 | | |
| | | 83 | | private async Task<(JObject Payload, int StatusCode)> SendOnceAsync(HttpMethod method, string endpoint, |
| | | 84 | | string? requestBody, CancellationToken ct) |
| | | 85 | | { |
| | 117 | 86 | | var timer = Stopwatch.StartNew(); |
| | | 87 | | HttpResponseMessage response; |
| | | 88 | | try |
| | | 89 | | { |
| | 117 | 90 | | using var request = new HttpRequestMessage(method, $"{_config.BaseUrl.TrimEnd('/')}{endpoint}"); |
| | 117 | 91 | | request.Headers.Add("zzap-api-key", _config.ApiKey); |
| | 117 | 92 | | request.Headers.Accept.ParseAdd("application/json"); |
| | 117 | 93 | | if (requestBody is not null) |
| | 30 | 94 | | request.Content = new StringContent(requestBody, System.Text.Encoding.UTF8, "application/json"); |
| | 117 | 95 | | response = await _factory.CreateClient(ClientName).SendAsync(request, ct); |
| | 113 | 96 | | } |
| | 4 | 97 | | catch (Exception ex) when (ex is not ZZapApiException) |
| | | 98 | | { |
| | 4 | 99 | | _logs?.Add(Provider, method.Method, endpoint, null, timer, requestBody, ex.Message); |
| | 4 | 100 | | throw new ZZapApiException("ZZap недоступен.", requestPath: endpoint, innerException: ex); |
| | | 101 | | } |
| | | 102 | | |
| | 113 | 103 | | using (response) |
| | | 104 | | { |
| | 113 | 105 | | var text = await response.Content.ReadAsStringAsync(ct); |
| | 113 | 106 | | var statusCode = (int)response.StatusCode; |
| | 113 | 107 | | _logs?.Add(Provider, method.Method, endpoint, statusCode, timer, requestBody, text); |
| | | 108 | | |
| | | 109 | | JObject payload; |
| | 225 | 110 | | try { payload = JObject.Parse(text); } |
| | 1 | 111 | | catch (Exception ex) |
| | | 112 | | { |
| | 1 | 113 | | throw new ZZapApiException($"ZZap вернул невалидный JSON (HTTP {statusCode}).", statusCode, endpoint, ex |
| | | 114 | | } |
| | | 115 | | |
| | 112 | 116 | | return (payload, statusCode); |
| | | 117 | | } |
| | 112 | 118 | | } |
| | | 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 | | { |
| | 110 | 124 | | var success = payload.Value<bool?>("success") ?? statusCode is >= 200 and < 300; |
| | 205 | 125 | | if (success && statusCode is >= 200 and < 300) return; |
| | | 126 | | |
| | 15 | 127 | | var code = payload.Value<int?>("code") ?? statusCode; |
| | 15 | 128 | | var message = ReadErrors(payload); |
| | 15 | 129 | | throw new ZZapApiException( |
| | 15 | 130 | | code == TooManyRequests |
| | 15 | 131 | | ? "ZZap: превышена частота запросов (не чаще 1 запроса в 3 секунды)." |
| | 15 | 132 | | : $"ZZap отклонил запрос (HTTP {code}). {message}".TrimEnd(), |
| | 15 | 133 | | code, path); |
| | | 134 | | } |
| | | 135 | | |
| | | 136 | | private static string ReadErrors(JObject payload) |
| | | 137 | | { |
| | 16 | 138 | | if (payload["errors"] is not JObject errors) return string.Empty; |
| | 14 | 139 | | return string.Join(" ", errors.Properties() |
| | 15 | 140 | | .Select(x => x.Value.Type == JTokenType.String ? x.Value.Value<string>() : x.Value.ToString()) |
| | 29 | 141 | | .Where(x => !string.IsNullOrWhiteSpace(x))); |
| | | 142 | | } |
| | | 143 | | |
| | | 144 | | private static string BuildEndpoint(string path, IReadOnlyDictionary<string, string?>? query) |
| | | 145 | | { |
| | 115 | 146 | | var normalized = "/" + path.TrimStart('/'); |
| | 187 | 147 | | if (query is null) return normalized; |
| | 43 | 148 | | var suffix = string.Join("&", query |
| | 70 | 149 | | .Where(x => x.Value is not null) |
| | 113 | 150 | | .Select(x => $"{Uri.EscapeDataString(x.Key)}={Uri.EscapeDataString(x.Value!)}")); |
| | 43 | 151 | | return suffix.Length == 0 ? normalized : $"{normalized}?{suffix}"; |
| | | 152 | | } |
| | | 153 | | } |