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.
| Option | Default | What it does |
|---|---|---|
Permits | 100 | Executions allowed per window |
Window | 1s | The replenishment window |
Burst | = Permits | Bucket capacity: how far above the steady rate a burst may spike |
QueueLimit | 0 | How many executions may wait for a permit instead of being rejected immediately |
OnRejected | — | Awaited 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.
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));
In synchronous Execute, queued waits block the calling thread. Prefer ExecuteAsync for queue-enabled limiters.