Skip to main content

Rate Limit

Throttle jobs sharing a key to N starts per window. When the bucket is full, the surplus is either dropped (Skip) or rescheduled (Wait). Style picks between a wall-clock floor (Fixed), a rolling tail (Sliding), and a steady-rate TokenBucket that paces instead of caps.

Opt-in addon — register with opt.AddRateLimit() on the builder.

Quick start

builder.Services.AddWarpServer<AppDbContext>(opt =>
{
opt.UsePostgreSql();
opt.AddConcurrency(); // optional — register BEFORE AddRateLimit if both apply
opt.AddRateLimit();
});

// On the job/request type — never the handler (default: Mode = Skip, Style = Fixed)
[RateLimit("sendgrid", count: 10, perSeconds: 60)]
public class SendEmail : IJob { }

// Per-publish extension (wins over the attribute)
await publisher.Enqueue(
new SendEmail(),
new JobParameters().WithRateLimit("sendgrid", 10, TimeSpan.FromSeconds(60)));

Modes

RateLimitMode controls what happens when the bucket is full:

ModeOutcomeUse when
Skip (default)Job ends Deleted with a RateLimited log entry."Drop the duplicate" — telemetry pings, opportunistic refreshes.
WaitJob is rescheduled via JobOutcome.RescheduledState for the next available window slot. Lock contention adds 100–500 ms of jitter."Don't drop — defer" — customer-visible work that must eventually run.
[RateLimit("crm-sync", count: 100, perSeconds: 60, Mode = RateLimitMode.Wait)]
public class SyncCrm : IJob { }

Styles

RateLimitStyle controls window shape:

StyleBehaviourStorage
Fixed (default)Wall-clock window floor-aligned to global UTC ticks. Bucket resets at the boundary. Cheap, predictable boundary bursts (up to 2 × count across two adjacent windows).One row per (key, windowStart).
SlidingRolling window over the last N start timestamps within perSeconds. Defensively trimmed each check. Smoother distribution; no boundary burst.Slightly more churn — one row per (key, start) within the window.
TokenBucketPaces rather than caps: tokens refill continuously at count / perSeconds per second up to a burst capacity of count; each start consumes one. A fresh key starts full. When empty, a Wait-mode start is rescheduled to the moment the next token refills — so a backlog trickles out at the steady refill rate instead of releasing a full window at once.One row per key holding the fractional token count + last-refill instant (written only on accept).
[RateLimit("partner-api", count: 5, perSeconds: 1, Style = RateLimitStyle.Sliding)]
public class CallPartnerApi : IJob { }

// Steady pacing: ~4 requests/sec (240/min), bursts of up to 240 smoothed to the refill rate.
[RateLimit("metered-api", count: 240, perSeconds: 60, Style = RateLimitStyle.TokenBucket, Mode = RateLimitMode.Wait)]
public class CallMeteredApi : IJob { }

Use Fixed/Sliding to enforce a ceiling (reject/defer once the count is hit); use TokenBucket to enforce a steady rate against a downstream that wants smooth traffic. TokenBucket reschedules Wait-mode starts to sub-window token-refill instants, so — like the other styles — those reschedules ride ScheduledJobActivation (§ DB push does not accelerate Wait).

perSeconds is capped at 7 days (RateLimitAttribute.MaxWindowSeconds). Inputs past the cap throw at construction.

Precedence

The key — which bucket a job contends on — is resolved from the declaration rungs, most specific first:

explicit metadata at publish (WithRateLimit / IRateLimitMetadata)
→ the handler class ([RateLimit])
→ the contract type ([RateLimit])

Resolution happens at the job's first execution, and the winner is written onto the row (see Contract or handler? below) — not at publish, which is where it happened before 6.0.

An admin override (IRateLimitOverrideManager) sits above all of them, but only for the size of the bucket: it replaces count and perSeconds for a key on every check, and never changes which key a job uses. Overrides are read on each acquire attempt with no caching at the limit boundary, so raising or lowering N takes effect on the next one — including for jobs whose row was stamped long ago.

Contract or handler?

[RateLimit] can sit on the job/message type (the default; on a message every child that declares nothing resolves it, so those handlers share the budget), on a job/message handler class (that handler's children only — the natural home when the handler is what calls the throttled dependency), or on both, in which case the handler wins. The resolved limit is written onto the job row at its first execution, and recurring-job firings honour a contract-declared limit. See Where do I declare the policy?.

What the pipeline holds

The distributed lock is held only for the brief check-and-increment — never during handler execution (unlike [Mutex] / [Semaphore], where the lock spans the whole handler). That keeps rate limits friendly to long-running jobs: a single 10-minute job doesn't block other tokens for the duration of its run.

Live state lives in RateLimitBucket; the entity is contributed only when AddRateLimit() is registered.

Composition with concurrency control

When a job carries both [Mutex] / [Semaphore] and [RateLimit], register AddConcurrency() before AddRateLimit():

opt.AddConcurrency(); // outer — runs first
opt.AddRateLimit(); // inner — runs only if the mutex was acquired

DI insertion order is outer → inner. With the mutex outer, a rejected mutex acquisition short-circuits before the rate-limit token is consumed. Reversing the order leaks a token per mutex rejection — the bucket is incremented for a job that was never going to run. The next window rollover clears it, but in the meantime the effective rate-limit ceiling is lower than configured.

DB push does not accelerate Wait

DB push (opt.UseDatabasePush()) wakes workers on JobEnqueued notifications. Rate-limit Wait-mode reschedules land in State.Scheduled, which is handled by ScheduledJobActivation (time-driven, ScheduledActivationInterval default 10 s). Push does not speed up these reschedules — they wait for the next activation tick.

If you need sub-second Wait precision against a high-volume key, lower ScheduledActivationInterval rather than reaching for push.

Don't put PII in the key

Rate-limit keys appear in JobLog.Message rows and on the dashboard /warp/ratelimits page. Hash or tokenise tenant identifiers; never use raw emails or usernames as the key.

Admin overrides

Live limits are runtime-tunable via IRateLimitOverrideManager and exposed on the dashboard at /warp/ratelimits (hide-on-404 nav probe). Set / clear / list endpoints sit under /api/ratelimits. An admin row replaces the count and perSeconds of whatever the job resolved — see Precedence.

OpenTelemetry

Each acquire attempt emits a warp.rate_limit_check span (Internal kind) with these tags:

  • warp.rate_limit.key
  • warp.rate_limit.count
  • warp.rate_limit.window_seconds
  • warp.rate_limit.style (fixed / sliding)
  • warp.rate_limit.outcome (acquired / skipped / throttled / lock_contention)

acquired is the green path; skipped is the Skip rejection; throttled is the Wait reschedule; lock_contention is the brief retry path after a failed TryAcquire on the distributed lock.

Counters

Throttled outcomes are counted like every other job outcome: a Skip rejection lands in stats:deleted-ratelimit and a Wait reschedule in stats:requeued-ratelimit, beneath the deleted / requeued totals on the Job outcomes counter tab. A Wait reschedule also emits warp.job.requeued with reason=ratelimit (no key tag — keys are unbounded and PII-adjacent, so they stay on spans).

Out of scope

  • Multi-key composition — one [RateLimit] per handler. Multiple distinct keys on one job is a planned extension.