| | | 1 | | namespace Services.ApiServices.MlAutoApi; |
| | | 2 | | |
| | | 3 | | /// <summary> |
| | | 4 | | /// Скользящее окно «не более N запросов в минуту» на один логин ML-Auto. Поставщик сам режет запросы сверх лимита |
| | | 5 | | /// ответом HTTP 200 со STATUS != 200, поэтому без ограничителя фоновый опрос статусов упирается в лимит и ломает |
| | | 6 | | /// заодно интерактивные вызовы. После ответа «лимит исчерпан» окно закрывается на <see cref="PenaltyDuration"/>. |
| | | 7 | | /// </summary> |
| | | 8 | | public sealed class MlAutoRateLimiter |
| | | 9 | | { |
| | 1 | 10 | | public static readonly TimeSpan Window = TimeSpan.FromMinutes(1); |
| | 1 | 11 | | public static readonly TimeSpan PenaltyDuration = TimeSpan.FromSeconds(60); |
| | | 12 | | /// <summary>Дольше этого вызов не ждёт очереди: интерактивный запрос лучше упасть, чем висеть минутами.</summary> |
| | 1 | 13 | | public static readonly TimeSpan MaxWait = TimeSpan.FromSeconds(30); |
| | | 14 | | |
| | | 15 | | private readonly int _limit; |
| | 114 | 16 | | private readonly Queue<DateTime> _hits = new(); |
| | 114 | 17 | | private readonly object _gate = new(); |
| | 114 | 18 | | private DateTime _blockedUntilUtc = DateTime.MinValue; |
| | | 19 | | |
| | 228 | 20 | | public MlAutoRateLimiter(int requestsPerMinute) => _limit = requestsPerMinute; |
| | | 21 | | |
| | | 22 | | /// <summary>Лимитер выключен (0 и меньше): пропускает всё.</summary> |
| | 1111 | 23 | | public bool Enabled => _limit > 0; |
| | | 24 | | |
| | | 25 | | public async Task WaitAsync(CancellationToken ct) |
| | | 26 | | { |
| | 2111 | 27 | | if (!Enabled) return; |
| | 111 | 28 | | var waited = TimeSpan.Zero; |
| | 0 | 29 | | while (true) |
| | | 30 | | { |
| | 111 | 31 | | var delay = TryAcquire(DateTime.UtcNow); |
| | 221 | 32 | | if (delay == TimeSpan.Zero) return; |
| | 1 | 33 | | if (waited + delay > MaxWait) |
| | 1 | 34 | | throw new MlAutoApiException("Превышен локальный лимит запросов к ML-Auto, повторите позже.") { IsRateLi |
| | 0 | 35 | | await Task.Delay(delay, ct); |
| | 0 | 36 | | waited += delay; |
| | | 37 | | } |
| | 1110 | 38 | | } |
| | | 39 | | |
| | | 40 | | /// <summary>Поставщик ответил «лимит исчерпан»: до конца паузы новые запросы не уходят.</summary> |
| | | 41 | | public void Penalize() |
| | | 42 | | { |
| | 1 | 43 | | lock (_gate) |
| | 1 | 44 | | _blockedUntilUtc = DateTime.UtcNow + PenaltyDuration; |
| | 1 | 45 | | } |
| | | 46 | | |
| | | 47 | | /// <summary>Занимает слот и возвращает ноль либо сколько ждать до освобождения.</summary> |
| | | 48 | | internal TimeSpan TryAcquire(DateTime nowUtc) |
| | | 49 | | { |
| | 115 | 50 | | lock (_gate) |
| | | 51 | | { |
| | 116 | 52 | | if (_blockedUntilUtc > nowUtc) return _blockedUntilUtc - nowUtc; |
| | 118 | 53 | | while (_hits.Count > 0 && _hits.Peek() <= nowUtc - Window) _hits.Dequeue(); |
| | 114 | 54 | | if (_hits.Count < _limit) |
| | | 55 | | { |
| | 113 | 56 | | _hits.Enqueue(nowUtc); |
| | 113 | 57 | | return TimeSpan.Zero; |
| | | 58 | | } |
| | 1 | 59 | | return _hits.Peek() + Window - nowUtc + TimeSpan.FromMilliseconds(10); |
| | | 60 | | } |
| | 115 | 61 | | } |
| | | 62 | | } |