Rate Limiting¶
Token bucket, sliding window, concurrency reservation, and the rate-limit registry.
ratelimit ¶
Rate-limiting primitives for TaskQ.
QUEUE_CONCURRENCY_PREFIX
module-attribute
¶
Reserved namespace for fleet-wide per-queue concurrency cap reservations.
Primitives whose name starts with this prefix are internal to TaskQ's
queue-cap bootstrap path and must be registered via
RateLimitRegistry.register_queue_cap_reservation, not via the public
RateLimitRegistry.register (which rejects prefixed names to prevent
accidental or malicious shadowing of an internal queue cap). Keyed refs
must never derive concrete names into this namespace either — see the
base_name validators in taskq.ratelimit.refs.
Lives here (rather than in ratelimit.registry) because
ratelimit.refs must reference it in its validators, and refs is
imported BY registry — defining it in registry would be circular.
__all__
module-attribute
¶
__all__ = [
"QUEUE_CONCURRENCY_PREFIX",
"AcquiredResource",
"ConcurrencyReservation",
"KeyedRateLimitRef",
"KeyedReservationRef",
"RateLimitBackend",
"RateLimitDecision",
"RateLimitHandle",
"RateLimitRef",
"RateLimitRegistry",
"RateLimitState",
"ReservationHandle",
"ReservationRef",
"SlidingWindow",
"SyncResult",
"TokenBucket",
"get_redis_pool",
"queue_concurrency_reservation_name",
"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.
slot_index is whatever acquire() returned — in practice a
:class:~taskq.ratelimit.reservation.SlotLease, an int subclass that
also carries the lease fence. Storing and handing it back unchanged is
what makes release() safe against a zombie attempt: a handle from a
lease that has since expired and been re-acquired frees nothing, so the
bucket cannot silently exceed max_concurrent. Anything that rebuilds
a handle must carry the value through as-is rather than re-deriving an
int from it.
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.
KeyedRateLimitRef ¶
Bases: BaseModel
Reference to a per-key token bucket, derived from the payload.
Mirrors :class:KeyedReservationRef but for rate limits: base_name
namespaces the derived buckets (concrete name is f"{base_name}:{key}"),
key_fn derives the key from the actor's validated payload, and
capacity / refill_per_second configure every bucket derived
from this ref identically (all keys share the same per-key budget).
payload_type is the :class:~pydantic.BaseModel subclass that the
payload will be validated against. Use :meth:typed for type-safe
construction that binds key_fn to the same payload_type:
KeyedRateLimitRef.typed(MyPayload, base_name="api-per-tenant",
key_fn=lambda p: p.tenant_id, capacity=10, refill_per_second=1.0).
A consumer calling an external API with per-tenant rate limits would
declare rate_limits=[KeyedRateLimitRef.typed(MyPayload,
base_name="api-per-tenant", key_fn=lambda p: p.tenant_id, capacity=10,
refill_per_second=1.0)] to give each tenant its own independent
token budget, with each tenant's bucket materializing on first use.
Concurrency caps vs. rate limits. A concurrency limiter (how many
jobs at once, e.g. :class:KeyedReservationRef /
:class:~taskq.ratelimit.reservation.ConcurrencyReservation) and a
rate limiter (how many per unit time, e.g.
:class:~taskq.ratelimit.token_bucket.TokenBucket) solve different
problems: N concurrent slots with fast responses can still burst well
past a per-time-unit budget, so both may be needed together on the
same actor.
Backend selection. The backend field (default "redis")
controls which storage backend the materialized
:class:~taskq.ratelimit.token_bucket.TokenBucket uses, identical to
the backend constructor parameter on a static TokenBucket. In a
deployment without Redis configured, set backend="postgres" or
backend="memory" to avoid the Redis-required failure mode — a
keyed bucket with backend="redis" but no redis_client raises
RuntimeError on acquire (not caught by with_pg_fallback, which
only handles ConnectionError/TimeoutError).
PG fallback inheritance. _resolve_rate_limit_name constructs a
plain :class:~taskq.ratelimit.token_bucket.TokenBucket (with the
backend from this ref, default "redis") and calls its normal .acquire().
The existing with_pg_fallback path in
token_bucket._acquire_redis_wrapped is therefore inherited
automatically — on Redis ConnectionError/TimeoutError, the
acquire falls back to the PG rate_limit_buckets table governed by
settings.rate_limit_pg_fallback_enabled. No second fallback
mechanism is built or needed.
Dual growth bounds. Per-key Redis memory is self-bounding because
the token-bucket Lua script sets an EXPIRE TTL on each bucket's
Redis hash (computed from capacity/refill_per_second). The
Python-process-local dict/registry growth is bounded separately by
:meth:~taskq.ratelimit.registry.RateLimitRegistry.evict_idle_keyed_rate_limits,
which evicts idle entries from the in-memory registry. These are two
independent bounds — Redis TTL bounds Redis memory; registry eviction
bounds Python memory.
Concrete per-key :class:~taskq.ratelimit.token_bucket.TokenBucket
instances are registered lazily on first acquisition and are not
automatically removed — see
:meth:~taskq.ratelimit.registry.RateLimitRegistry.evict_idle_keyed_rate_limits
for bounding registry growth under high key cardinality.
.. note::
The declared type Callable[[BaseModel], str] is deliberately
unsound at the field level — :meth:typed stores a
Callable[[P], str] (contravariance prevents direct assignment).
Runtime safety is enforced by the registry's isinstance check
against payload_type before calling key_fn. Direct
invocation of ref.key_fn(model) is unchecked — prefer
:meth:typed for compile-time safety.
model_config
class-attribute
instance-attribute
¶
typed
classmethod
¶
typed(
payload_type: type[P],
*,
base_name: str,
key_fn: Callable[[P], str],
capacity: float,
refill_per_second: float,
backend: RateLimitBackend = "redis",
) -> KeyedRateLimitRef
Type-safe constructor that binds key_fn to payload_type.
The key_fn parameter is typed as Callable[[P], str] where
P is the provided payload_type — at static-analysis time
the caller's lambda or function is checked against the concrete
model's attributes (e.g. lambda p: p.tenant_id is verified
against payload_type.tenant_id).
At runtime the registry passes the validated payload model to
key_fn, so the callable always receives an instance of
payload_type (verified by isinstance in the registry —
same-type payloads pass through directly; different-type or dict
payloads are re-validated via model_validate).
Source code in src/taskq/ratelimit/refs.py
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 :class:~pydantic.BaseModel
instance — validated from the job row's raw JSON payload) and
must return a non-empty string — typically a tenant, session, or
account identifier already present on the payload.
payload_type is the :class:~pydantic.BaseModel subclass that the
payload will be validated against. Use :meth:typed for type-safe
construction that binds key_fn to the same payload_type:
KeyedReservationRef.typed(MyPayload, base_name="geocode-session",
key_fn=lambda p: p.session_id, slots=3, lease=timedelta(minutes=5)).
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.
.. note::
The declared type Callable[[BaseModel], str] is deliberately
unsound at the field level — :meth:typed stores a
Callable[[P], str] (contravariance prevents direct assignment).
Runtime safety is enforced by the registry's isinstance check
against payload_type before calling key_fn. Direct
invocation of ref.key_fn(model) is unchecked — prefer
:meth:typed for compile-time safety.
model_config
class-attribute
instance-attribute
¶
typed
classmethod
¶
typed(
payload_type: type[P],
*,
base_name: str,
key_fn: Callable[[P], str],
slots: int,
lease: timedelta,
) -> KeyedReservationRef
Type-safe constructor that binds key_fn to payload_type.
The key_fn parameter is typed as Callable[[P], str] where
P is the provided payload_type — at static-analysis time
the caller's lambda or function is checked against the concrete
model's attributes (e.g. lambda p: p.session_id is verified
against payload_type.session_id).
At runtime the registry passes the validated payload model to
key_fn, so the callable always receives an instance of
payload_type (verified by isinstance in the registry —
same-type payloads pass through directly; different-type or dict
payloads are re-validated via model_validate).
Source code in src/taskq/ratelimit/refs.py
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.
Ownership. A registry is an ordinary ownable object: construct one
per process and pass it to worker_main(..., rate_limit_registry=...)
/ create_router(..., rate_limit_registry=...), or rely on the
module-level registry singleton (the default at every entry point).
Actor-declared primitive instances are registered by the worker
bootstrap's collection pass; use explicit .register() for
primitives shared outside actor dispatch. :meth:clear resets all
state and is a test aid only — NOT safe while a worker is running.
Source code in src/taskq/ratelimit/registry.py
has_reservation ¶
O(1) membership test against the live reservations dict.
Unlike the :attr:reservations property this does NOT defensively
copy the dict — use it on per-job hot paths (e.g. the dispatch
queue-cap check), where copying the whole registry per call is
prohibitive at high keyed-entry cardinality.
Source code in src/taskq/ratelimit/registry.py
has_rate_limit ¶
O(1) membership test against the live rate-limits dict.
See :meth:has_reservation — the same no-copy guarantee applies.
register ¶
Source code in src/taskq/ratelimit/registry.py
register_queue_cap_reservation ¶
Register a fleet-wide queue-cap reservation in the reserved namespace.
This is the ONLY way to register a reservation whose name starts with
:data:QUEUE_CONCURRENCY_PREFIX. The public :meth:register rejects
such names to prevent users from accidentally shadowing internal
queue caps. Idempotency and conflict detection are identical to
:meth:register (duplicate-name-with-different-config →
ValueError; duplicate-name-with-same-config → idempotent no-op).
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: Sequence[
str
| KeyedRateLimitRef
| TokenBucket
| SlidingWindow
],
reservations: Sequence[
str | KeyedReservationRef | ConcurrencyReservation
],
*,
job_id: UUID,
worker_id: UUID,
payload: dict[str, object] | BaseModel | 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), :class:KeyedReservationRef
instances (resolved dynamically per job from payload — see
:meth:_resolve_reservation_name), or
:class:ConcurrencyReservation instances (normalized to their
.name up front — the instance must already be registered,
e.g. by the worker bootstrap's actor-declaration collection
pass; an unregistered instance raises KeyError exactly like
an unknown name). rate_limits entries may likewise be plain
names, :class:KeyedRateLimitRef instances, or
:class:TokenBucket / :class:SlidingWindow instances.
payload is required if any entry is a KeyedReservationRef
or KeyedRateLimitRef. It may be a dict (validated via
ref.payload_type.model_validate) or a BaseModel (used
directly if it matches ref.payload_type, otherwise re-validated
via model_dump() → model_validate()).
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
838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 | |
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).
Why pg_pool is unused: each handle captured the pool it needs at
acquisition time, so handle.release() is self-contained. The
parameter mirrors :meth:acquire_for_actor, which does need it, so
the acquire/release pair reads as one symmetric surface at the call
site (taskq.worker._consumer passes the same worker_pool to
both). It is public API, so it is kept rather than removed.
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. Each worker's
30-second sweep calls this automatically against its own registry
(not leader-gated) 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
evict_idle_keyed_rate_limits ¶
Drop registry entries for keyed rate limits idle at least idle_for.
Rate limits derived from a :class:KeyedRateLimitRef are registered
lazily and never removed automatically — under high key cardinality
(e.g. one token bucket per tenant over a long worker lifetime) this
dict grows without bound. Each worker's 30-second sweep calls this
automatically against its own registry (not leader-gated) 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 Redis hash for that
bucket; per-key Redis memory is already self-bounding via the Lua
script's EXPIRE TTL on the bucket's hash (see
:meth:_resolve_rate_limit_name). A key that is acquired again
after eviction is simply re-registered on next use (idempotent — see
:meth:_resolve_rate_limit_name), so eviction is always safe to
call, including concurrently with in-flight acquisitions for other
keys. Token buckets are not automatically removed from Redis; their
independent TTL handles that side.
Exemption — memory fixed-quota buckets. A backend="memory"
bucket with refill_per_second == 0 that has consumed any of its
quota is NOT evicted (see
:meth:TokenBucket.holds_consumed_memory_quota): its token state
lives on the instance, so eviction would silently reset the drained
quota to full on next acquire — whereas Redis deliberately retains
that same state for 24 h. The exemption applies to both callers of
this method (the per-worker sweep and the cap-pressure opportunistic
eviction). Trade-off, deliberately chosen: an exempt bucket counts
against settings.max_keyed_rate_limits until its quota returns
to full (refund/reset) or the process restarts, so under sustained
high-cardinality fixed-quota keys the cardinality cap can
permanently fill and deny NEW keys — the cap fails CLOSED with a
warning rather than silently resetting quotas, which is the correct
failure direction for a limiter.
Returns the number of entries evicted.
Source code in src/taskq/ratelimit/registry.py
clear ¶
Reset ALL mutable registry state — a test aid, NOT safe while running.
Clears the four dicts (_rate_limits, _reservations,
_keyed_reservation_last_used, _keyed_rate_limit_last_used)
AND resets the two opportunistic-eviction scan timestamps
(_keyed_reservation_last_eviction_scan /
_keyed_rate_limit_last_eviction_scan) to float("-inf").
Omitting the timestamps would leave the opportunistic-eviction
throttle stamped, silently suppressing scans for up to
_OPPORTUNISTIC_EVICT_MIN_INTERVAL (30 s) in the next test.
Not safe to call while a worker is running — concurrent dispatch / sweep iteration over the dicts would observe inconsistent state. Use for per-test isolation only.
Like the eviction methods, this resets IN-PROCESS bookkeeping
only — it does NOT touch Redis bucket hashes or Postgres
reservation_slots rows; backend state persists and will be
observed on next acquire.
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_fenced_sql",
"_release_sql",
"_schema",
"_slots",
"_table",
)
schema
property
¶
The PG schema this reservation's slot table lives in.
Workers filter registry-global reservations by their own schema at startup (a process-global registry may carry reservations declared for other schemas/databases — touching those would write into the wrong schema or fail noisily).
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 the acquired slot_index.
The return value is a :class:SlotLease — an int that also
carries the fence :meth:release needs to tell this lease apart from
an earlier, expired one on the same slot. Pass it back to
:meth:release (which is what ReservationHandle does) to get that
protection.
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.
Also a no-op when slot_index is a :class:SlotLease whose fence no
longer matches the slot row — i.e. the caller is releasing a lease that
has since expired and been re-acquired. Without that gate a zombie
attempt (heartbeats stalled, job reclaimed and redispatched, original
coroutine never cancelled) frees the slot its own LIVE successor
holds, and the bucket silently exceeds max_concurrent: the job id
cannot discriminate, because a retry reuses the same job row, and
neither can the worker id, because the redispatch commonly lands on
the same worker. A plain int releases unfenced, as before.
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
Acquire one admission slot.
The shared admission state is measured in the data store's own
clock domain: PG paths use clock_timestamp() and Redis paths
use Redis TIME inside their scripts, so callers on nodes with
divergent Python clocks are all measured against the same window.
The injected clock drives the memory backend only (its single
domain) and remains part of the public call shape — the unified
TokenBucket contract.
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
Read-only state snapshot.
PG and Redis peeks measure against the store's own clock (the same domain their admission state is stamped in); the memory backend uses the injected clock (its single domain) — the unified TokenBucket contract: clock is required only on memory.
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",
)
holds_consumed_memory_quota ¶
True if idle-evicting this bucket's registry entry would silently reset a consumed fixed quota.
Only the memory backend stores token state on the instance
(_mem_bucket); Redis and PG keep state in the store — Redis
deliberately for 24 h for fixed-quota buckets (see
_compute_ttl_seconds) — so a re-materialized bucket seamlessly
resumes prior state there, and eviction of those backends' registry
entries is always state-safe.
For a memory fixed-quota (refill_per_second == 0) bucket that
has consumed any of its quota, eviction destroys that state
permanently: the next acquire materializes a fresh instance at FULL
capacity, silently resetting a quota designed to never refill.
(Refilling buckets are not exempt: their state converges back
toward full on its own, so eviction loses at most one refill
window's worth of tokens — an accepted, bounded divergence.)
Reads _tokens without the bucket's async lock; safe because the
only caller (the registry's idle-eviction sweep) runs synchronously
in the event loop with no await between this read and the dict pop,
so the value is consistent at the sweep instant.
Source code in src/taskq/ratelimit/token_bucket.py
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 resolved RateLimitRegistry instance.
Registers the given :class:RateLimitRegistry as a Scope.LOOP value
so it is available at dispatch time via DI resolution. Skips when a
provider is already registered — a user pre-registered LOOP-scope value
provider wins (bootstrap resolution rule 2); after the bootstrap
kind/scope checks the DI-cached instance and the bootstrap
instance are provably the same object. When the module singleton is
resolved (the default), the DI-registered instance and
:data:taskq.ratelimit.registry.registry are the same object — 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
queue_concurrency_reservation_name ¶
Return the registry name for the fleet-wide concurrency cap of queue.
The taskq:global:queue: prefix namespaces these internally-generated
reservations apart from user-declared ones. A reservation only exists
in the registry for a queue if that queue's max_concurrent column
was set in the queues table (read from Postgres at worker startup);
there is no other config source. All workers sharing the schema
register and acquire against the same PG reservation_slots rows,
giving a true fleet-wide cap per queue.
Source code in src/taskq/ratelimit/registry.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. Keyed buckets
materialized lazily AFTER startup are published individually by the
acquisition path — see
:meth:RateLimitRegistry._resolve_rate_limit_name.
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.
"Free" / "held" use the same definition as the acquire CTE: a slot row
with an EXPIRED lease is acquirable, hence deletable; only rows with a
live (unexpired) lease are reported as skipped_held. The anomalous
job_id NOT NULL / lease_expires_at NULL state (no code path
produces it, but the nullable schema permits it) is conservatively
reported as held rather than deleted — matching the in-memory table.
Treating expired-lease rows as held would let a dead worker's leaked slots
defeat a shrink indefinitely — the rows stay acquirable, so the old
larger cap would keep being honored. Deleting an expired-lease row is
safe: lease expiry is the design's abandonment signal (the acquire CTE
would hand the same slot to a new job anyway), and a late release by
the old holder is a harmless no-op (worker_id mismatch or missing
row). A row acquired concurrently with the delete is protected by its
row lock: the delete blocks, then re-evaluates its predicate against
the post-acquire row (job_id now set, lease in the future) and
skips it.
Source code in src/taskq/ratelimit/reservation.py
627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 | |