All articles
.NET & integrations · 8 min read ·

Rate limiting a temperamental external API: Polly + SemaphoreSlim

How to stay inside someone else’s rate limits without stalling your own pipeline — client-side throttling, retry ordering, Retry-After, and the multi-instance trap.

.NETPollyresilienceintegrationsHttpClient

When you integrate with a marketplace, a courier or a payment provider, you inherit a constraint you cannot negotiate: their rate limits. Exceed them and you do not simply get slower — you get 429s, then temporary bans, then, on some platforms, a conversation with someone about your integration’s behaviour.

The instinct is to add a retry policy and move on. That instinct is wrong, and it is worth being precise about why.

Why is retry-on-429 not rate limiting?

Because a 429 is the receipt. By the time it arrives, the request has already been sent, counted and rejected. Retrying it consumes another slot in the next window.

Worse, under load this behaviour is self-reinforcing. Every rejected call comes back and competes with the fresh calls that were already queued, so the queue grows faster than it drains. A retry policy without a limiter in front of it turns a temporary overload into a sustained one.

Retry is still necessary — networks fail, servers restart. But it handles a different problem. Rate limiting is preventive; retry is corrective. You need both, in that order.

What does client-side throttling actually look like?

The mechanism is a counter with a time-based release. SemaphoreSlim gives you exactly that, and it is async-friendly, which matters because you want to wait, not block a thread pool thread:

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);
        // The slot returns exactly one window after it was taken — not after
        // the request completes. A slow response must not shrink your budget.
        return new Releaser(_slots, _window);
    }
}

The comment is the part that matters. The naive implementation releases the slot when the HTTP call finishes, which couples your throughput to the other side’s latency: their bad afternoon becomes your backlog. Releasing on a timer decouples them.

Two details to get right:

  • the release timer must survive the request. If you schedule it with Task.Delay(...).ContinueWith(...) inside a scope that gets cancelled, slots leak and the limiter slowly strangles itself. Use a timer that is not tied to the request’s CancellationToken;
  • SemaphoreSlim is not fair. It does not guarantee FIFO ordering. In practice this is fine for background sync; if you have a latency-sensitive path sharing the same limiter, it can starve. That is an argument for separate buckets, which brings us to the next point.

Why two buckets rather than one?

Most real APIs do not publish a single number. They limit different endpoint families differently — order operations are usually tighter than catalogue reads, because they are more expensive on their side.

If you model that with one global limiter, you have to size it for the strictest limit, and your catalogue sync crawls for no reason. Two limiters, keyed by endpoint class, let each family run at its own ceiling:

private readonly SlidingWindowLimiter _orders   = new(perWindow: 12, TimeSpan.FromSeconds(1));
private readonly SlidingWindowLimiter _standard = new(perWindow: 3,  TimeSpan.FromSeconds(1));

The numbers come from their documentation, with margin. Configure them, do not hard-code them — published limits change, and you do not want a redeploy to be the only way to react.

Where should the limiter live?

In a DelegatingHandler on the HttpClient, not in your service classes.

This is the same argument as global query filters for tenant isolation: if staying within the limits depends on a developer remembering to call the limiter, it is not a guarantee, it is a hope. A handler sits underneath every call made through that client, including the ones added next year by someone who has never read this article.

services.AddHttpClient<IMarketplaceClient, MarketplaceClient>()
        .AddHttpMessageHandler<RateLimitingHandler>()
        .AddResilienceHandler("marketplace", ConfigurePipeline);

What order do the policies go in?

This is the question people get wrong most often, and Polly v8 makes the answer explicit: the first strategy added is the outermost one, and each subsequent strategy sits inside it.

Layer Position Why there
Fallback Outermost It must see the final outcome, after everything else has given up
Retry Outside the limiter So a retried attempt goes back through the throttle
Circuit breaker Inside retry Otherwise the retry keeps hammering a service you have already declared dead
Rate limiter Inside the breaker Every attempt that reaches the wire pays the toll
Timeout (per attempt) Innermost It should bound one HTTP call, not the whole retry sequence

The critical one is retry sitting outside the limiter. Invert those two and your retries bypass the throttle entirely — which is precisely the failure mode you built the limiter to prevent.

If you also want an overall deadline across all attempts, that is a second timeout at the very outside, not a replacement for the per-attempt one.

What about Retry-After?

If the server tells you how long to wait, that number beats anything you compute. It is authoritative; your backoff curve is a guess.

Polly v8 lets you read it in the delay generator:

DelayGenerator = args =>
{
    var retryAfter = args.Outcome.Result?.Headers.RetryAfter;
    var delay = retryAfter?.Delta
        ?? (retryAfter?.Date - DateTimeOffset.UtcNow);
    return ValueTask.FromResult(delay);   // null → fall back to the configured backoff
}

Note that Retry-After comes in two forms — a delay in seconds and an absolute HTTP date — and providers use both. Handling only Delta means silently ignoring half of them.

Otherwise: exponential backoff with jitter (UseJitter = true). Without jitter, every instance that got throttled at the same moment retries at the same moment, and you have rebuilt the spike you were trying to smooth.

What breaks when you scale out?

This is the honest limitation, and it deserves stating plainly: SemaphoreSlim is per-process. Run three instances of your service and you are making three times the configured rate against an API that counts you once.

The options, in ascending order of effort:

Approach Cost When it fits
Partition the budget — each instance gets limit / N Trivial Fixed instance count; wastes capacity when instances are idle
Pin the integration to a single worker Small Background sync that does not need to scale horizontally
Distributed limiter (Redis token bucket) Real Autoscaling, or the limit is tight enough that waste is expensive

I have deliberately started with the first two on more than one project. An in-process limiter plus a single-writer topology is simpler to reason about than a distributed one, and if the integration is a background pipeline rather than a user-facing path, horizontal scaling was never the point. Take the Redis dependency when you actually need it — but know which of the three you are on, because “we scaled and started getting banned” is a bad way to find out.

What else bites?

  • Retrying non-idempotent requests. A POST that creates a shipment may well have succeeded before the timeout. Retry it only if the provider supports an idempotency key, or if you can check-then-act.
  • Treating every 4xx as retryable. A 400 will fail identically forever; you are only spending budget. Retry 429, 5xx and transport errors.
  • Unbounded parallelism upstream. If your consumer starts 500 concurrent tasks, the limiter holds them all as pending waiters and your memory graph tells the story. Bound the producer too.
  • No visibility. Emit metrics for wait time in the limiter, 429 count and circuit state. Without them, “the integration is slow today” is unanswerable.

Does this belong in your code at all?

.NET 7 added System.Threading.RateLimiting, with FixedWindowRateLimiter, SlidingWindowRateLimiter, TokenBucketRateLimiter and ConcurrencyLimiter — and Polly v8 has a rate-limiter strategy built on it. For most cases, reach for those first.

One practical note, because it catches people: those types ship in the ASP.NET Core shared framework but not in the base one. In a console app or a worker service you need the System.Threading.RateLimiting package explicitly, and the compiler error you get first (“the namespace does not exist”) reads like a version problem rather than a missing reference.

The hand-rolled version earns its place when your constraint does not match the standard shapes: a limit expressed per endpoint family, a budget shared between a real-time path and a batch path with different priorities, or a provider whose effective limit is discovered empirically rather than documented. That was the situation on the marketplace integration — two endpoint families with different ceilings, one of them stricter than the published figure. Start with the built-in limiter; write your own when you can name the reason.

If you are integrating with an API that fights back and want a second opinion on the resilience layer before it reaches production, that is a conversation I am glad to have.

The project behind this article eMAG Marketplace Automation A SaaS hub for eMAG sellers — orders, AWB, automated pricing and fraud detection.

Working on something similar?

If you are building in this space, let us talk for 30 minutes.

Book a call
All articles