Skip to main content

Rate Limit

A token-bucket limiter: Permits executions per Window, with bursts and optional queueing.

See the exceptions reference for RateLimitExceededException and RetryAfter.

var fixedRate = Shield.RateLimit(100, perWindow: TimeSpan.FromSeconds(1)); // 100/s, burst = 100

var configuredRate = Shield.RateLimit(o =>
{
o.Permits = 100; // default 100
o.Window = TimeSpan.FromSeconds(1); // default 1s
o.Burst = 200; // default: same as Permits
o.QueueLimit = 20; // default 0
o.OnRejected = rejection =>
{
logger.LogWarning("Rate limited; retry after {RetryAfter}", rejection.RetryAfter);
return default;
};
});

System.Threading.RateLimiting adapters

Install Kevlar.Extensions.RateLimiting to reuse a framework limiter without adding that dependency to Kevlar core:

dotnet add package Kevlar.Extensions.RateLimiting
using Kevlar.Extensions.RateLimiting;
using System.Threading.RateLimiting;

public sealed class RateLimitedDependency : IDisposable
{
private readonly FixedWindowRateLimiter _limiter = new(new FixedWindowRateLimiterOptions
{
PermitLimit = 100,
Window = TimeSpan.FromSeconds(1),
QueueLimit = 20,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
});
private readonly Shield _shield;

public RateLimitedDependency()
{
_shield = Shield.Empty.UseRateLimiter(_limiter, options =>
{
options.PermitCount = 1;
options.OnRejected = rejection =>
{
Console.WriteLine(rejection.RetryAfter);
return default;
};
});
}

public ValueTask ExecuteAsync(CancellationToken cancellationToken = default) =>
_shield.ExecuteAsync(static _ => ValueTask.CompletedTask, cancellationToken);

public void Dispose() => _limiter.Dispose();
}

The caller owns the RateLimiter by default. Pass ownsLimiter: true when a shield registered in IKevlarRegistry should transfer limiter ownership to the registry; registry disposal then disposes the limiter exactly once. Every returned RateLimitLease is held until the protected execution completes and is then disposed exactly once. Rejected lease metadata is copied before disposal. MetadataName.RetryAfter becomes RateLimiterAdapterRejectedException.RetryAfter, and the complete immutable snapshot is available from RateLimiterAdapterRejectedEvent.Metadata.

Fixed-window, sliding-window, concurrency, chained, and custom limiters all use the same adapter. For a limiter owned behind another abstraction, supply asynchronous acquisition directly:

using Kevlar.Extensions.RateLimiting;

var shield = Shield.Empty.UseRateLimiter(
(permitCount, context) =>
AcquireTenantLeaseAsync(permitCount, context.CancellationToken));

Use PartitionedRateLimiter<KevlarContext> when partition selection depends on execution metadata:

using Kevlar.Extensions.RateLimiting;

public sealed class TenantLimitedDependency : IDisposable
{
private static readonly KevlarKey<string> _tenantKey = new("tenant");
private readonly PartitionedRateLimiter<KevlarContext> _limiter;
private readonly Shield _shield;

public TenantLimitedDependency()
{
_limiter = PartitionedRateLimiter.Create<KevlarContext, string>(context =>
RateLimitPartition.Get(
context.Properties.GetOrDefault(_tenantKey, "default"),
static _ => new ConcurrencyLimiter(new ConcurrencyLimiterOptions
{
PermitLimit = 10,
QueueLimit = 20,
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
})));
_shield = Shield.Empty.UseRateLimiter(_limiter);
}

public ValueTask ExecuteAsync(string tenant, CancellationToken cancellationToken = default) =>
_shield.ExecuteWithContextAsync(
tenant,
static (value, properties) => properties.Set(_tenantKey, value),
static (_, context) => new ValueTask(Task.Delay(1, context.CancellationToken)),
cancellationToken);

public void Dispose() => _limiter.Dispose();
}

The partition callback receives the live pooled KevlarContext; read it only during the callback and never retain it. One PartitionedRateLimiter<KevlarContext> instance shares partition state across every shield using it, including shields returned by Kevlar's PartitionedShield<TKey>. Partition retention follows the limiter implementation; keep attacker-controlled key cardinality bounded. The caller owns and disposes the partitioned limiter and its child limiters by default. The same ownsLimiter: true opt-in transfers ownership when registry disposal governs the shield lifetime. A Kevlar PartitionedShield<TKey> also disposes factory-returned strategies by default; set its OwnsStrategies option to false when the same strategy is used elsewhere. Kevlar always owns each returned lease.

The delegate must return a fresh acquired or rejected lease for each call. Rejection metrics and hooks follow the built-in contract: metric first, then awaited OnRejected. Hook failures are reported through KevlarDiagnostics.OnCallbackError, and RateLimiterAdapterRejectedException remains the outcome. Cancellation while queued is cancellation, not rejection, so hooks do not run.

Options

API reference: RateLimitOptions.

OptionDefaultWhat it does
Permits100Executions allowed per window
Window1sThe replenishment window
Burst= PermitsBucket capacity: how far above the steady rate a burst may spike
QueueLimit0How many executions may wait for a permit instead of being rejected immediately
OnRejectedAwaited notification for an actual rejection; return default when the work is synchronous

Invalid option values throw KevlarConfigurationException and identify the options type, property, and offending value.

Rejection vs queueing

With QueueLimit = 0, an execution that finds the bucket empty fails immediately with RateLimitExceededException. The exception carries RetryAfter — an estimate of when a permit will next be available — which pairs naturally with an outer retry's DelayGenerator.

With QueueLimit > 0, up to that many executions reserve a future permit and wait for it instead of failing. Beyond the queue limit, rejections resume.

For an actual rejection, Kevlar records rejection metrics, awaits OnRejected, then surfaces RateLimitExceededException. The event includes RetryAfter, the configured permit/window/burst/queue values, the strategy index, and KevlarContext. Under the shared callback-failure contract, failures are reported through KevlarDiagnostics.OnCallbackError, and RateLimitExceededException remains the rejection outcome. Queued cancellation is cancellation, not rejection, so it does not invoke the hook. A hook that completes synchronously works with synchronous Execute; one that yields throws NotSupportedException there and must run through ExecuteAsync.

Callback contexts are pooled. Do not retain RateLimitRejectedEvent.Context after the returned ValueTask completes. Hooks run outside limiter locks and may run concurrently or re-enter the same shield; captured state must be thread-safe.

Queueing is reservation-based, not FIFO

Queued executions each sleep until their reserved permit replenishes; there's no fairness ordering among waiters.

Placement and sharing

Rate limiting is proactive — it doesn't consult handling clauses; it acts on every execution that reaches it.

The bucket lives with the shield instance. Reuse one instance for everything hitting the limited dependency, or you'll have several independent buckets each allowing the full rate (state-sharing rule).

// Retry politely around the limiter: waits what the limiter suggests
var polite = Shield
.When<RateLimitExceededException>()
.Retry(o =>
{
o.MaxRetries = 3;
o.DelayGenerator = e => new((e.Exception as RateLimitExceededException)?.RetryAfter);
})
.RateLimit(100, perWindow: TimeSpan.FromSeconds(1));
Sync callers block

In synchronous Execute, queued waits block the calling thread. Prefer ExecuteAsync for queue-enabled limiters.