Toate articolele
.NET & integrări · 8 min citire ·

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.

.NETPollyreziliențăintegrăriHttpClient

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.

De ce retry pe 429 nu e rate limiting?

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.

Cum arată de fapt throttling-ul în client?

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:

  • timerul de eliberare trebuie să supraviețuiască cererii. Dacă îl programezi cu 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.

De ce două bucket-uri și nu unul?

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.

Unde trebuie să stea limitatorul?

Î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);

În ce ordine se pun politicile?

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.

Ce facem cu Retry-After?

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.

Ce se strică la scalare orizontală?

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.

Ce mai mușcă?

  • Reîncercarea cererilor neidempotente. Un 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.
  • Tratarea oricărui 4xx ca reîncercabil. Un 400 va eșua identic la infinit; nu faci decât să cheltui buget. Reîncearcă 429, 5xx și erorile de transport.
  • Paralelism nemărginit în amonte. Dacă consumatorul tău pornește 500 de task-uri concurente, limitatorul le ține pe toate ca așteptători și graficul de memorie spune povestea. Mărginește și producătorul.
  • Zero vizibilitate. Emite metrici pentru timpul de așteptare în limitator, numărul de 429-uri și starea breaker-ului. Fără ele, „integrarea e lentă azi“ e o afirmație la care nu poți răspunde.

Are asta ce căuta în codul tău?

.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.

Proiectul din spatele articolului Automatizare Marketplace eMAG Hub SaaS pentru selleri eMAG — comenzi, AWB, pricing automat și detecție de fraudă.

Ai un proiect similar?

Dacă lucrezi la ceva din zona asta, hai să vorbim 30 de minute.

Programează o discuție
Toate articolele