Rate limiting pentru un API extern capricios: Polly + SemaphoreSlim
Cum rămâi în limitele altcuiva fără să-ți blochezi propriul flux — throttling în client, ordinea politicilor, Retry-After și capcana scalării pe mai multe instanțe.
Cum rămâi în limitele altcuiva fără să-ți blochezi propriul flux — throttling în client, ordinea politicilor, Retry-After și capcana scalării pe mai multe instanțe.
Când te integrezi cu un marketplace, un curier sau un procesator de plăți, moștenești o constrângere pe care nu o poți negocia: limitele lor de rată. Le depășești și nu devii pur și simplu mai lent — primești 429-uri, apoi blocări temporare, apoi, pe unele platforme, o discuție cu cineva despre comportamentul integrării tale.
Instinctul e să adaugi o politică de retry și să treci mai departe. Instinctul ăsta e greșit și merită să fim preciși de ce.
Pentru că 429 e chitanța. Când ajunge, cererea a fost deja trimisă, numărată și respinsă. Reîncercarea consumă încă un slot din fereastra următoare.
Mai rău, sub încărcare comportamentul se autoîntreține. Fiecare apel respins se întoarce și concurează cu apelurile proaspete deja puse la coadă, deci coada crește mai repede decât se golește. O politică de retry fără limitator în fața ei transformă o supraîncărcare temporară într-una susținută.
Retry rămâne necesar — rețelele cad, serverele repornesc. Dar rezolvă o altă problemă. Rate limiting-ul e preventiv; retry-ul e corectiv. Ai nevoie de amândouă, în ordinea asta.
Mecanismul e un contor cu eliberare pe timp. SemaphoreSlim îți dă exact asta și e prietenos cu async, ceea ce contează, pentru că vrei să aștepți, nu să blochezi un fir din thread pool:
public sealed class SlidingWindowLimiter : IDisposable
{
private readonly SemaphoreSlim _slots;
private readonly TimeSpan _window;
public SlidingWindowLimiter(int permitsPerWindow, TimeSpan window)
{
_slots = new SemaphoreSlim(permitsPerWindow, permitsPerWindow);
_window = window;
}
public async Task<IDisposable> AcquireAsync(CancellationToken ct)
{
await _slots.WaitAsync(ct);
// Slotul se întoarce exact la o fereastră după ce a fost luat — nu după
// ce se termină cererea. Un răspuns lent nu are voie să-ți taie bugetul.
return new Releaser(_slots, _window);
}
}
Comentariul e partea care contează. Implementarea naivă eliberează slotul când se termină apelul HTTP, ceea ce îți leagă debitul de latența celuilalt: după-amiaza lor proastă devine coada ta. Eliberarea pe timer le decuplează.
Două detalii de nimerit:
Task.Delay(...).ContinueWith(...) într-un scope care se anulează, sloturile se pierd și limitatorul se sugrumă singur, încet. Folosește un timer care nu e legat de CancellationToken-ul cererii;SemaphoreSlim nu e echitabil. Nu garantează ordinea FIFO. În practică e în regulă pentru sincronizare în fundal; dacă ai o cale sensibilă la latență care împarte același limitator, poate fi înfometată. E un argument pentru bucket-uri separate, ceea ce ne duce la punctul următor.Majoritatea API-urilor reale nu publică un singur număr. Limitează diferit familii diferite de endpoint-uri — operațiile pe comenzi sunt de obicei mai strânse decât citirile de catalog, pentru că sunt mai scumpe la ei.
Dacă modelezi asta cu un singur limitator global, trebuie să-l dimensionezi pentru limita cea mai strictă, iar sincronizarea de catalog se târăște degeaba. Două limitatoare, indexate după clasa de endpoint, lasă fiecare familie să ruleze la plafonul ei:
private readonly SlidingWindowLimiter _orders = new(perWindow: 12, TimeSpan.FromSeconds(1));
private readonly SlidingWindowLimiter _standard = new(perWindow: 3, TimeSpan.FromSeconds(1));
Numerele vin din documentația lor, cu marjă. Configurează-le, nu le hard-coda — limitele publicate se schimbă și nu vrei ca un redeploy să fie singurul mod de a reacționa.
Într-un DelegatingHandler pe HttpClient, nu în clasele tale de serviciu.
E același argument ca la global query filters pentru izolarea tenanților: dacă respectarea limitelor depinde de faptul că un dezvoltator își amintește să apeleze limitatorul, nu e o garanție, e o speranță. Un handler stă sub fiecare apel făcut prin acel client, inclusiv sub cele adăugate anul viitor de cineva care n-a citit articolul ăsta.
services.AddHttpClient<IMarketplaceClient, MarketplaceClient>()
.AddHttpMessageHandler<RateLimitingHandler>()
.AddResilienceHandler("marketplace", ConfigurePipeline);
Asta e întrebarea la care lumea greșește cel mai des, iar Polly v8 face răspunsul explicit: prima strategie adăugată e cea mai exterioară, iar fiecare strategie următoare stă în interiorul ei.
| Strat | Poziție | De ce acolo |
|---|---|---|
| Fallback | Cel mai exterior | Trebuie să vadă rezultatul final, după ce toate celelalte au renunțat |
| Retry | În afara limitatorului | Ca o reîncercare să treacă din nou prin throttle |
| Circuit breaker | În interiorul retry-ului | Altfel retry-ul continuă să lovească un serviciu pe care l-ai declarat deja mort |
| Rate limiter | În interiorul breaker-ului | Fiecare încercare care ajunge la fir plătește taxa |
| Timeout (per încercare) | Cel mai interior | Trebuie să mărginească un apel HTTP, nu toată secvența de retry |
Cea critică e retry-ul aflat în afara limitatorului. Inversează-le și reîncercările ocolesc complet throttle-ul — exact modul de eșec pentru care ai construit limitatorul.
Dacă vrei și un termen limită global peste toate încercările, ăla e un al doilea timeout, chiar la exterior, nu un înlocuitor pentru cel per încercare.
Dacă serverul îți spune cât să aștepți, numărul lui bate orice calculezi tu. E autoritativ; curba ta de backoff e o presupunere.
Polly v8 îți permite să-l citești în generatorul de întârziere:
DelayGenerator = args =>
{
var retryAfter = args.Outcome.Result?.Headers.RetryAfter;
var delay = retryAfter?.Delta
?? (retryAfter?.Date - DateTimeOffset.UtcNow);
return ValueTask.FromResult(delay); // null → cade pe backoff-ul configurat
}
Atenție: Retry-After vine în două forme — o întârziere în secunde și o dată HTTP absolută — iar furnizorii le folosesc pe amândouă. Dacă tratezi doar Delta, ignori tăcut jumătate dintre ele.
În rest: backoff exponențial cu jitter (UseJitter = true). Fără jitter, toate instanțele care au fost limitate în același moment reîncearcă în același moment și ai reconstruit exact vârful pe care voiai să-l netezești.
Asta e limitarea onestă și merită spusă direct: SemaphoreSlim e per proces. Rulează trei instanțe ale serviciului și faci de trei ori rata configurată către un API care te numără o singură dată.
Opțiunile, în ordinea crescătoare a efortului:
| Abordare | Cost | Când se potrivește |
|---|---|---|
Împarți bugetul — fiecare instanță primește limită / N |
Banal | Număr fix de instanțe; irosești capacitate când unele stau |
| Fixezi integrarea pe un singur worker | Mic | Sincronizare în fundal care nu are nevoie să scaleze orizontal |
| Limitator distribuit (token bucket în Redis) | Real | Autoscaling, sau limita e destul de strânsă încât risipa să coste |
Am pornit deliberat de la primele două pe mai mult de un proiect. Un limitator în proces plus o topologie cu un singur scriitor e mai ușor de raționat decât unul distribuit, iar dacă integrarea e un flux de fundal și nu o cale către utilizator, scalarea orizontală n-a fost niciodată scopul. Ia dependența de Redis când chiar ai nevoie de ea — dar știi pe care dintre cele trei ești, pentru că „am scalat și am început să fim blocați“ e un mod prost de a afla.
POST care creează un AWB s-ar putea foarte bine să fi reușit înainte de timeout. Reîncearcă-l doar dacă furnizorul acceptă o cheie de idempotență sau dacă poți verifica înainte de a acționa..NET 7 a adăugat System.Threading.RateLimiting, cu FixedWindowRateLimiter, SlidingWindowRateLimiter, TokenBucketRateLimiter și ConcurrencyLimiter — iar Polly v8 are o strategie de rate limiting construită peste el. Pentru majoritatea cazurilor, întinde-te întâi după astea.
O notă practică, pentru că prinde lumea pe picior greșit: tipurile astea vin în shared framework-ul ASP.NET Core, dar nu în cel de bază. Într-o aplicație de consolă sau într-un worker service ai nevoie explicit de pachetul System.Threading.RateLimiting, iar prima eroare de compilare pe care o primești („namespace-ul nu există“) se citește ca o problemă de versiune, nu ca o referință lipsă.
Versiunea scrisă de mână își merită locul când constrângerea ta nu se potrivește cu formele standard: o limită exprimată per familie de endpoint-uri, un buget împărțit între o cale în timp real și una batch cu priorități diferite, sau un furnizor a cărui limită efectivă e descoperită empiric, nu documentată. Asta a fost situația la integrarea cu marketplace-ul — două familii de endpoint-uri cu plafoane diferite, una dintre ele mai strictă decât cifra publicată. Începe cu limitatorul din platformă; scrie-l pe al tău când poți numi motivul.
Dacă te integrezi cu un API care se apără și vrei o a doua opinie pe stratul de reziliență înainte să intre în producție, e o discuție pe care o port cu plăcere.
Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.
Programează o discuție