Rate Limiting¶
Token bucket, sliding window, concurrency reservation, and the rate-limit registry.
ratelimit ¶
Rate-limiting primitives for TaskQ.
__all__
module-attribute
¶
__all__ = [
"AcquiredResource",
"ConcurrencyReservation",
"KeyedReservationRef",
"RateLimitBackend",
"RateLimitDecision",
"RateLimitHandle",
"RateLimitRef",
"RateLimitRegistry",
"RateLimitState",
"ReservationHandle",
"ReservationRef",
"SlidingWindow",
"SyncResult",
"TokenBucket",
"get_redis_pool",
"register_rate_limit_registry",
"register_redis_pool",
"registry",
"sync_rate_limit_buckets",
"sync_slots",
]
AcquiredResource ¶
RateLimitHandle
dataclass
¶
RateLimitHandle(
name: str,
primitive: TokenBucket | SlidingWindow,
decision: RateLimitDecision,
redis_client: Redis | None,
pg_pool: Pool | None,
clock: Clock | None,
settings: WorkerSettings | None = None,
count: float = 1.0,
refund_on_release: bool = True,
)
Handle for a successfully acquired rate-limit token.
release() is a no-op when refund_on_release is False (post-actor
path — token consumption is permanent). When refund_on_release is
True (rollback path), release() refunds count tokens via
primitive.refund().
release
async
¶
Source code in src/taskq/ratelimit/composition.py
ReservationHandle
dataclass
¶
ReservationHandle(
name: str,
reservation: ConcurrencyReservation,
slot_index: int,
job_id: UUID,
worker_id: UUID,
pool: Pool | None,
)
Handle for a successfully acquired reservation slot.
release() calls ConcurrencyReservation.release(slot_index, worker_id, pool)
which sets the slot row's job_id to NULL. Idempotent.
RateLimitDecision
dataclass
¶
RateLimitDecision(
allowed: bool,
remaining: float,
retry_after: timedelta | None,
bucket_name: str,
backend: RateLimitBackend,
request_id: str | None = None,
previous_state: dict[str, object] | None = None,
)
RateLimitState
dataclass
¶
RateLimitState(
bucket_name: str,
backend: RateLimitBackend,
is_exhausted: bool,
tokens_remaining: float = 0.0,
remaining: float = 0.0,
retry_after: timedelta | None = None,
capacity: float | None = None,
limit: int | None = None,
window: timedelta | None = None,
style: str | None = None,
refill_per_second: float | None = None,
)
Read-only snapshot of a rate-limit bucket's current state.
Returned by TokenBucket.peek(), SlidingWindow.peek(),
and ConcurrencyReservation.peek(). Fields are backend-agnostic:
TB backends populate tokens_remaining and capacity; SW backends
populate remaining, limit, window, and style.
KeyedReservationRef ¶
Bases: BaseModel
Reference to a per-key concurrency reservation, derived from the payload.
base_name namespaces the derived reservations (the concrete name
registered for a given key is f"{base_name}:{key}") so distinct
KeyedReservationRef declarations never collide. key_fn receives
the actor's validated payload (as a dict[str, object], the same
shape stored on the job row) and must return a non-empty string —
typically a tenant, session, or account identifier already present on
the payload.
slots and lease configure every reservation derived from this
ref identically (all keys share the same per-key cap and lease
duration); use a separate KeyedReservationRef if different keys
need different caps.
Concrete per-key reservations are registered lazily on first
acquisition and are not automatically removed — see
:meth:~taskq.ratelimit.registry.RateLimitRegistry.evict_idle_keyed_reservations
for bounding registry growth under high key cardinality.
model_config
class-attribute
instance-attribute
¶
RateLimitRef ¶
ReservationRef ¶
Bases: BaseModel
Typed reference to a concurrency reservation primitive by name.
RateLimitRegistry ¶
Unified registry for rate-limit and reservation primitives.
Stores two separate dicts: _rate_limits for TokenBucket /
SlidingWindow and _reservations for ConcurrencyReservation.
Cross-dict name collision is allowed — they live in separate namespaces.
Source code in src/taskq/ratelimit/registry.py
register ¶
Source code in src/taskq/ratelimit/registry.py
get_rate_limit ¶
get_reservation ¶
acquire
async
¶
acquire(
name: str,
count: float = 1.0,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> AsyncGenerator[RateLimitDecision, None]
Source code in src/taskq/ratelimit/registry.py
acquire_for_actor
async
¶
acquire_for_actor(
rate_limits: list[str],
reservations: Sequence[str | KeyedReservationRef],
*,
job_id: UUID,
worker_id: UUID,
payload: dict[str, object] | None = None,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> list[AcquiredResource]
AND-composition: acquire reservations first, then rate limits.
reservations entries may be plain names (resolved against
statically pre-registered primitives) or :class:KeyedReservationRef
instances (resolved dynamically per job from payload — see
:meth:_resolve_reservation_name). payload is required if any
entry is a KeyedReservationRef.
Returns the list of AcquiredResource handles on full success.
Raises ReservationUnavailable on any denial — rollback is performed
internally before re-raising (already-acquired resources released in
reverse order, each failure logged at ERROR).
Source code in src/taskq/ratelimit/registry.py
333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 | |
peek
async
¶
peek(
name: str,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitState
Look up a rate-limit primitive by name and return its current state.
Source code in src/taskq/ratelimit/registry.py
peek_all
async
¶
peek_all(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> dict[str, RateLimitState]
Peek all registered rate limits. Returns {name: RateLimitState}.
Source code in src/taskq/ratelimit/registry.py
reset
async
¶
reset(
name: str,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Reset a rate-limit bucket to full capacity.
Source code in src/taskq/ratelimit/registry.py
release_for_actor
async
¶
Release acquired resources after actor completion.
Sets refund_on_release=False on all RateLimitHandle instances
before iterating (token consumption is permanent after actor ran).
Releases in reverse acquisition order. Each release failure is caught,
logged at ERROR, and loop continues (same pattern as rollback).
Source code in src/taskq/ratelimit/registry.py
evict_idle_keyed_reservations ¶
Drop registry entries for keyed reservations idle at least idle_for.
Reservations derived from a :class:KeyedReservationRef are
registered lazily and never removed automatically — under high key
cardinality (e.g. one reservation per import session over a long
worker lifetime) this dict grows without bound. The leader sweep
calls this automatically with a 1-hour idle threshold; call directly
for custom eviction windows.
Only removes the in-memory registry entry and its
acquire-recency tracking — it does NOT touch the underlying
Postgres reservation_slots rows for that name; those are
already reclaimed independently by the existing lock-expiry sweep.
A key that is acquired again after eviction is simply
re-registered on next use (idempotent — see
:meth:_resolve_reservation_name), so eviction is always safe to
call, including concurrently with in-flight acquisitions for
other keys.
Returns the number of entries evicted.
Source code in src/taskq/ratelimit/registry.py
ConcurrencyReservation ¶
ConcurrencyReservation(
name: str,
slots: int,
lease: timedelta | float,
lock_lease: timedelta | None = None,
*,
clock: Clock | None = None,
schema: str = "taskq",
)
Concurrency reservation using pre-allocated slot rows.
Raises :class:ValueError if slots < 1 or lease <= 0.
Raises :class:ReservationUnavailable when no slot is available.
Source code in src/taskq/ratelimit/reservation.py
__slots__
class-attribute
instance-attribute
¶
__slots__ = (
"_acquire_sql",
"_ensure_sql",
"_lease",
"_lock_lease",
"_name",
"_release_sql",
"_schema",
"_slots",
"_table",
)
table
property
¶
The in-memory slot table (requires clock at construction).
ensure_slots
async
¶
Idempotent pre-allocation of slot rows.
acquire
async
¶
Acquire a slot. Returns slot_index.
When pool is None, the in-memory table (clock= at
construction) is used. Raises :class:ReservationUnavailable when
no slot is available.
Source code in src/taskq/ratelimit/reservation.py
release
async
¶
Release slot. No-op if worker_id mismatch.
When pool is None, the in-memory table is used.
Source code in src/taskq/ratelimit/reservation.py
peek
async
¶
Return {"free_count": int, "total_slots": int} for the bucket.
When pool is None, the in-memory table is used.
Source code in src/taskq/ratelimit/reservation.py
SyncResult
dataclass
¶
SlidingWindow ¶
SlidingWindow(
name: str,
limit: int,
window: timedelta,
backend: Literal[
"redis", "postgres", "memory"
] = "redis",
style: SlidingWindowStyle = "log",
ttl: timedelta | None = None,
)
Sliding-window rate limiter with pluggable backends.
Raises :class:ValueError if limit < 1, window <= timedelta(0),
or style is not "log" or "gcra".
Source code in src/taskq/ratelimit/sliding_window.py
__slots__
class-attribute
instance-attribute
¶
__slots__ = (
"_backend",
"_limit",
"_mem_gcra",
"_mem_log",
"_name",
"_redis_gcra_refund_script",
"_redis_gcra_script",
"_redis_log_script",
"_script_lock",
"_style",
"_ttl",
"_window",
)
acquire
async
¶
acquire(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitDecision
Source code in src/taskq/ratelimit/sliding_window.py
refund
async
¶
refund(
decision: RateLimitDecision,
*,
count: float = 1.0,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Source code in src/taskq/ratelimit/sliding_window.py
peek
async
¶
peek(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitState
Source code in src/taskq/ratelimit/sliding_window.py
reset
async
¶
reset(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Source code in src/taskq/ratelimit/sliding_window.py
TokenBucket ¶
TokenBucket(
name: str,
capacity: float,
refill_per_second: float,
backend: RateLimitBackend = "redis",
ttl: timedelta | None = None,
)
Token-bucket rate limiter with pluggable backends.
Raises :class:ValueError if capacity <= 0 or
refill_per_second < 0.
Source code in src/taskq/ratelimit/token_bucket.py
__slots__
class-attribute
instance-attribute
¶
__slots__ = (
"_backend",
"_capacity",
"_mem_bucket",
"_name",
"_redis_refund_script",
"_redis_script",
"_refill",
"_script_lock",
"_ttl",
)
acquire
async
¶
acquire(
count: float = 1.0,
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitDecision
Source code in src/taskq/ratelimit/token_bucket.py
refund
async
¶
refund(
decision: RateLimitDecision,
*,
count: float = 1.0,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Source code in src/taskq/ratelimit/token_bucket.py
peek
async
¶
peek(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> RateLimitState
Source code in src/taskq/ratelimit/token_bucket.py
reset
async
¶
reset(
*,
redis_client: Redis | None = None,
pg_pool: Pool | None = None,
clock: Clock | None = None,
settings: WorkerSettings | None = None,
) -> None
Source code in src/taskq/ratelimit/token_bucket.py
get_redis_pool
async
¶
Yield a Redis client for the worker loop lifetime.
The return type is AsyncIterator[Any] rather than
AsyncIterator[redis.asyncio.Redis] so the DI system can introspect
the annotation without requiring the [redis] extra at runtime.
The actual yielded value is a redis.asyncio.Redis instance.
Raises :class:RuntimeError when settings.redis_url is None
(Redis not configured but a Redis-backed rate limiter was registered).
Raises :class:ImportError when the [redis] extra is not installed.
Source code in src/taskq/ratelimit/_provider.py
register_rate_limit_registry ¶
register_rate_limit_registry(
di_registry: ProviderRegistry,
rl_registry: RateLimitRegistry,
) -> None
Idempotent registration of the LOOP-scope RateLimitRegistry singleton.
Registers the given :class:RateLimitRegistry as a Scope.LOOP value
so it is available at dispatch time via DI resolution. The same object
is also importable as :data:taskq.ratelimit.registry.registry — both
paths observe identical state.
Source code in src/taskq/ratelimit/_provider.py
register_redis_pool ¶
Idempotent registration of the LOOP-scoped Redis pool factory.
Calls registry.register_factory(redis.asyncio.Redis, Scope.LOOP,
get_redis_pool) only when registry.has_provider(redis.asyncio.Redis)
is False, so user-supplied registrations take precedence.
Silently skips registration when the [redis] extra is not installed.
Source code in src/taskq/ratelimit/_provider.py
sync_rate_limit_buckets
async
¶
sync_rate_limit_buckets(
rl_registry: RateLimitRegistry,
pool: Pool,
*,
schema: str = "taskq",
) -> None
Publish every registered rate limit to rate_limit_buckets.
Each worker calls this at startup so the admin UI can discover configured buckets from PG without depending on the in-memory singleton being populated in the admin process.
Uses ON CONFLICT DO NOTHING so concurrent workers and restarts
are idempotent. Only PG-backed primitives are written; memory-only
and log-style sliding windows (which have no PG backend) are skipped.
Source code in src/taskq/ratelimit/registry.py
sync_slots
async
¶
sync_slots(
reservations: list[ConcurrencyReservation],
pool: Pool,
*,
schema: str = "taskq",
) -> SyncResult
Synchronise slot rows to match the registered reservation config.
For each reservation: insert missing slots (filling gaps from prior held-slot-preserving shrinks), delete excess free slots, and report held slots that could not be deleted.
Source code in src/taskq/ratelimit/reservation.py
517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 | |