Releases: Elijas/token-throttle
Release list
v10.1.1
-
Fixes the SQLite backends leaking a reservation from
snapshot_state()["in_flight_reservations"]when a refund failed closed with
UnknownReservationError— for example after the acquire marker expired.
The reservation stayed counted as in flight for the life of the limiter, so a
long-running process could drift toward its in-flight cap and eventually raise
CardinalityLimitExceededErroron healthy acquires. The Redis backends
already released the reservation in this case; SQLite now matches them.
Capacity accounting is unaffected — the refund still fails closed and credits
nothing. -
Fixes the memory backends never finalizing a reservation whose buckets all
disappeared in a callable-config metric-set change. Such a refund returned
early — before releasing the reservation's backend acquire state or recording
its dedup entry — so the backend kept treating it as acquired for the life of
the process. The refund is now finalized on that path under ordinary warning
handling. Capacity accounting is unchanged: there are no surviving buckets to
credit.One pre-existing limitation is unchanged by this fix: that refund path emits a
Refund droppedRuntimeWarningbefore it reaches the backend, so a caller
running with warnings promoted to errors (-W error) still raises there and
still leaves the reservation live. Reordering the notification after
finalization is tracked separately.
v10.1.0
- Adds stdlib-only synchronous and asynchronous SQLite backends for persistent,
multi-process rate-limit coordination on one host without a separate server.
The four new public exports areSqliteBackend,SqliteBackendBuilder,
SyncSqliteBackend, andSyncSqliteBackendBuilder.
Use memory for one process, SQLite for multiple processes on one local
filesystem, and Redis when a budget must span machines. SQLite state uses WAL
transactions, durable acquire/refund bookkeeping, expiring runtime
overrides, and configurable contention and TTL bounds; network filesystems
are outside its supported scope. - Adds spawn-based multi-process coverage for SQLite and Redis shared-budget
enforcement, cross-process refunds, crash-orphan linear refill, late-refund
failure, contention accounting, and fresh-interpreter persistence, plus a
SQLite try-acquire writer-contention regression. - Widens the diagnostic backend type additively with the
"sqlite"literal.
SQLitediagnose()output now uses a first-class structured health section
with exact acquire-marker and refund-tombstone counts;snapshot_state()
exposes the same local best-effort durable-count estimates as Redis. - Adds a runnable Anthropic Messages example with independent RPM, ITPM, and
OTPM buckets; server-side pre-flight token counting; prompt-cache prewarming;
cache-aware input refunds; observed-p99 output reservations; raw rate-limit
header logging; and conservative cleanup on every response/error path. - Clarifies in the README that the high major-version sequence reflects strict
semver during rapid beta development, rather than implying ten generations
of product maturity. - Restores the missing v2.0.0-v4.0.0 history from the archived migration guide
and adds explicit upgrade actions to the terse v5.0.0-v8.0.0 entries, so the
changelog's major-version migration claim is complete and auditable.
v10.0.0
- Breaking: this library no longer carries pre-v9 upgrade tooling. The
token_throttle.migrationmodule and its four public names —
validate_config_for_v2_0,cleanup_legacy_buckets,
async_cleanup_legacy_buckets, andConfigMigrationIssue— have been
removed, along with the standalone migration guide. Those helpers existed to
pre-flight a configuration migration for the v1.4.x-to-v2.0.0 upgrade and are
not part of the package any longer. Current documentation describes current
behavior only; each major version's breaking changes and upgrade steps remain
recorded in the entries below. The Redis ACL command list, the+@scripting
rationale, and theSCRIPT FLUSHoperational hazard that the guide carried
now live indocs/operations.md. - Breaking: the logging-callback factories (
create_logging_callbacksand
create_sync_logging_callbacks) no longer accept theTRACEorSUCCESS
log-level names. Passing either now raisesValueErrorlisting the supported
standard level names (DEBUG,INFO,WARNING,ERROR,CRITICAL) instead
of quietly remapping them toDEBUG/INFO. - Breaking:
refund_capacity_from_response(async and sync) now rejects
unknown keyword arguments withTypeError. A mistyped keyword — for example
usaage=instead ofusage=— used to be silently ignored, which could drop
the refund amount you intended. The supportedresponse=andusage=
arguments are unchanged. - Removed internal legacy-upgrade code paths that had no effect on current
usage: the rejection branch for reservations issued without a limiter
instance id, a Redis probe for an old max-capacity key that was only ever
logged and never applied, and a write-only Redis schema-version marker key.
These changes do not alter behavior for reservations and Redis state produced
by this version. - Reworded the warning emitted when a
usage_counteris defined without
**kwargs. It now presents accepting**kwargsas the recommended
convention and a fixed signature as a supported convenience, rather than
labeling the fixed-signature path "deprecated". Behavior is unchanged: fixed-
signature counters still work and still warn that request fields not named in
the signature are filtered out before the counter is called. The warning text
still contains "without **kwargs" if you match on it.
v9.1.1
- Fixes a Redis lock-loss error that surfaced the wrong exception. When a
per-bucket lock was lost mid-operation (its TTL lapsed or another worker stole
it), non-waiting ops such asconsume_capacity,refund_capacity, and
set_max_capacitywere documented to raiseBackendLockContentionError, but
the lock-release cleanup on the way out hit the same lost lock and leaked a raw
redis.exceptions.LockNotOwnedErrorthat replaced it. The release cleanup now
tolerates an already-lost lock, so callers reliably see
BackendLockContentionError(safe to retry) as promised in
docs/operations.md. Applies to both the async and sync
Redis backends. - Fixes the Redis backend's server-clock sanity rail raising a spurious
RuntimeErrorafter a host suspend/resume (paused VM, laptop sleep, live
migration) or after a single slow RedisTIMEreply. The rail compares
consecutiveTIMEreadings against locally-elapsed monotonic time, and a
suspended host stalls the monotonic clock while real time keeps passing -
which previously looked identical to a server-side clock jump. The local
wall clock now discriminates the two cases: when it corroborates the
server's advance, the backend re-anchors its baseline and logs a warning
instead of raising. Each reading's own round-trip is also now bounded into
the detection tolerance, so one delayed reply cannot trip the rail. A
genuine server-side forward jump (for example a Sentinel or managed
failover to a clock-skewed primary) still raises, and is now reported
exactly once per event: out-of-order readings from concurrent callers no
longer regress the detection baseline and re-raise for the same jump. - Hardens the
reserve()context manager against three edge cases that could
leak an in-flight reservation or fabricate capacity accounting:- The scope returned by
reserve()is now single-use. Re-entering the same
scope — whether after it has exited or while it is still active — raises
RuntimeErrorinstead of silently acquiring a second reservation while
reusing the first block's recorded actual usage. Callreserve()again for
each attempt. - When a
reserve()block exits without callingset_actual_usage(), the
reservation is refunded before the "forgot to report actual usage"
RuntimeWarningis emitted, so running under-W error(warnings promoted
to exceptions) can no longer skip the refund and leak the reservation. - When a
reserve()block exits normally but the recorded actual usage is
malformed (for example its metric keys do not match the reservation), the
reservation is conservatively closed before the resultingValueErroris
re-raised, so the bad-usage error still surfaces to the caller without
leaking the in-flight reservation.
- The scope returned by
- Fixes caller cancellation being silently lost when an async callback's
cancellation cleanup raised an ordinary exception: previously that exception
replaced theCancelledError, was logged and swallowed like any callback
error, and the cancelledacquire_capacity()returned normally, defeating
asyncio.timeout()andTaskGroupaborts. The callback error is now logged
andCancelledErroris re-raised, so cancellation propagates and reserved
capacity is refunded. Applies both withcallback_timeoutwrapping and with
callback_timeout=None. - Fixes a callback that itself raises
TimeoutErrorbeing misreported as
exceedingcallback_timeout, on both the async and sync paths. It is now
handled as an ordinary callback error (warning logged, acquire/refund call
unaffected) instead of deadline expiry. - Fixes an unbounded internal accumulation of timed-out async callbacks whose
event loop closed before they finished; stale entries are now pruned, so
short-lived event loops are no longer pinned in memory by abandoned
callbacks. - Fixes "Exception ignored" noise when an in-flight callback invocation is
torn down via coroutineclose()(for example during garbage collection or
event-loop shutdown); teardown now propagates plainGeneratorExitinstead
of an internal wrapper exception. - Documents in
docs/observability.mdthat an
abandoned timed-out async callback that also swallows cancellation can block
asyncio.run()shutdown, unlike abandoned sync callbacks, which run in
daemon helper threads. - Fixes the two
OpenAIUsageCounterregressions disclosed in the v9.1.0 entry
below: a stored-prompt-only Responses request (prompt={"id": ...}with no
input) is counted instead of rejected, andprompt.variablesvalues that
are images or files are accepted again (counted as 0 tokens, with a
once-per-process warning naming the variable and the best-effort tradeoff),
restoring v9.0.0 behavior. - Fixes
OpenAIUsageCounterraising an error while counting request text that
contains atiktokenspecial-token literal (for example<|endoftext|>,
which can show up verbatim in text copied from LLM documentation or output).
Such text is now counted as the ordinary request text it is on the wire
instead of crashing the acquire. - Fixes
cleanup_legacy_buckets/async_cleanup_legacy_bucketsin
token_throttle/migration.pynot escaping
Redis glob metacharacters (*,?,[,],\) in a configured
key_prefixbefore building its cleanup scan pattern, so a prefix
containing one of those characters could match and delete a sibling
deployment's keys instead of only its own. - Fixes
validate_config_for_v2_0in
token_throttle/migration.pyreporting a
false-positive "Redis builders require key_prefix" issue for configs where
the Redis builder options, includingkey_prefix, live in a nestedredis
section rather than at the top level. - Fixes
RateLimiter.aclose()/SyncRateLimiter.close()raising
AttributeErrorwhen closing a custom backend builder that omits the
documented-optionalaclose()/close()cleanup hook; the hook is now
called only when the backend defines it. - Fixes the limiter-close warning misreporting "1 reservations still in
flight" (instead of "1 reservation") when exactly one reservation is
outstanding at close. No behavior change. - Documents
diagnose()'sRateLimiterDiagnosticreturn type in
docs/observability.md, including when to reach
for it over the lightersnapshot_state(); this API surface previously
shipped without documentation. No behavior change.
Full details: CHANGELOG.md
v9.1.0
- Fixes
OpenAIUsageCounterunder-reserving for two request fields that carry
real, billed prompt text but were not yet counted: Chat Completions'
prediction(Predicted Outputs content, which shows up as accepted/rejected
prediction tokens in usage) and the Responses API'sprompt.variables
(the client-supplied values substituted into a stored prompt template).
Requests that use either feature now get a larger, more accurate token
reservation instead of one that silently under-counts; a stored prompt's
server-side template body itself remains unknowable from the client and is
documented as a best-effort blind spot. - Clarifies the Redis lock-contention warning log message and its docstrings
to say "a waiter" instead of "the no-timeout waiter", since deadline-bounded
callers also retry through contention as of v9.0.0, not just callers with no
timeout. No behavior change. - Restructures the README to lead the quickstart with
reserve()and move
operational depth (concurrency model, lifecycle events, bucket-state loss)
intodocs/operations.mdanddocs/observability.md; adds a Requirements
line and a strict-semver/migration note. No behavior change.
Full details: CHANGELOG.md
v9.0.0
- Breaking: adds the public
BackendLockContentionErrorexception and stops
leaking rawredis.exceptions.LockError. Redis per-bucket lock contention now
surfaces as this library exception:await_for_capacity/wait_for_capacity
with no caller timeout retry through contention instead of raising (logging a
throttled warning), andconsume_capacity,refund_capacity,
set_max_capacity, and reconfiguration raiseBackendLockContentionError
(chained from the underlying redis error) on lock starvation or mid-operation
lock loss. Handlers that caughtredis.exceptions.LockErrormust catch
BackendLockContentionErrorinstead; seeMIGRATION.mdand
the per-bucket locking section indocs/operations.md. - Breaking:
RedisBackendBuilder.build()/SyncRedisBackendBuilder.build()
now raiseValueErrorat build time when any configured quota's
per_secondswindow is longer thanbucket_ttl_seconds. That combination
previously built without error but silently reset a drained long-window
quota back to full capacity once an idle gap outlived the TTL. Widen
bucket_ttl_seconds, or shorten the offending quota'sper_seconds, for any
configuration the check now rejects; seeMIGRATION.mdand
the key-TTL guidance indocs/operations.md. - Breaking:
OpenAIUsageCounter/get_encodingno longer guess a
tokenizer from a hardcoded model-family fallback table for models the
installedtiktokencannot resolve on its own (for example a very new model
release). They now raise aValueErrorwith upgrade/workaround guidance
instead of either a possibly-wrong guessed encoding or a rawKeyError
escaping fromtiktoken. Code that specifically caughtKeyErroraround
token counting must catchValueErrorinstead; upgradetiktokenor pass an
explicitget_encoding_functoOpenAIUsageCounterfor models it does not
yet recognize. SeeMIGRATION.md. - Breaking:
UsageQuotasno longer accepts the private
_allow_empty_quotasconstructor keyword; passing it now raisesTypeError
(unknown keyword argument) instead of silently building an empty quota set.
UsageQuotas([])still raises the sameValueErrorpointing you to
UsageQuotas.unlimited(), which remains the supported way to build an
explicit no-limit quota set. SeeMIGRATION.md. - Fixes Redis
await_for_capacity/wait_for_capacitywith a caller
timeout: lock contention now retries acquisition until the caller's
deadline instead of raisingTimeoutErrorafter
lock_blocking_timeout_seconds(default 5s).timeout=0still fails fast,
and the timeout message now names lock contention as the cause instead of
misleading capacity fields. See the per-bucket locking section in
docs/operations.md. - Fixes async
callback_timeoutso it returns at the deadline even when a
callback swallows cancellation, including when it is torn down via
GeneratorExit(for example an async generator that uses the limiter being
closed early). Previously such a callback could blockacquire_capacity/
refund_capacityfor its full runtime and, on a swallowed cancellation,
without ever logging the documented "callback exceeded timeout" warning; the
async path now abandons the callback the same way the synchronous path
already does, logging any error the callback raises afterward. See
docs/observability.md. - Fixes a
SyncRateLimiterdeadlock when aPerModelConfigGettercalls back
into the limiter (for exampleclear_unused_model_families) while shared
model-family validation is in progress; the internal validation lock is now
reentrant.acquire_capacity_for_requestalso now emits the same
RuntimeWarningasacquire_capacitywhen called from inside a running
event loop. - Fixes a spurious shutdown warning: closing a limiter with zero in-flight
reservations no longer logs a "reservations still outstanding" warning. - Fixes the Redis backend hard-failing every rate-limit operation whenever the
host's local clock lags behind the Redis server clock (for example an NTP
outage, a paused/resumed VM, or container clock drift). Refill math already
uses Redis server time exclusively, so a lagging local clock is harmless to
correctness; the library now detects a genuine server-side clock jump by
comparing consecutive RedisTIMEreadings against locally-elapsed
monotonic time instead of the local wall clock, and raises only on a real
forward jump between readings (the realistic trigger is a Sentinel/managed
failover to a clock-skewed primary). A large divergence between the Redis
server clock and the local wall clock now logs a one-time warning about
possible NTP trouble instead of raising. - Fixes two error messages: the
ValueErrorraised when usage exceeds a
bucket's max capacity during acquire now names the failing quota window
(for example "for the 60s window"), disambiguating cases where two windows
on the same metric share a limit value; andset_max_capacity's validation
now reports a dedicated "must be an int or float" message for wrong-typed
inputs instead of misleadingly reusing the finite/positive-value message. - Fixes cancellation-path capacity refunds that fail: they now log a warning
identifying the affected reservation instead of failing silently; the
original cancellation error still propagates and the reserved capacity
still recovers through normal refill. - Adds
RateLimiter.reserve()/SyncRateLimiter.reserve(): a context
manager over the acquire -> call -> refund cycle. It yields a handle with
.reservationand.set_actual_usage(), refunds the unused remainder on
normal exit (warning and conservatively refunding the full reserved usage if
set_actual_usagewas never called), and on an exception refunds with an
optionalusage_on_error(or conservatively) before re-raising the original
exception. If a non-criticalusage_on_errorrefund itself fails (for
example its metric keys do not match the reservation), the reservation
still falls back to the conservative refund instead of leaking as
in-flight; the failure is logged, and the caller's original exception
still propagates. See the README's "Reserve capacity around a call"
example. - Fixes
OpenAIUsageCounterundercounting Responses API requests that use
text={"format": {...}}for structured output: that config is now counted
by JSON-serializing it likeresponse_format/tools/functions, instead of
being walked as plain text fragments that dropped the JSON structural
tokens (previously undercounting affected requests by roughly 62%). - Adds a weekly
tokenizer-driftCI canary (no API key required) that checks
the OpenAI token counter against the latest unpinnedopenai/tiktoken
releases for newly-unresolvable models or untriaged request parameters. - Fixes the Redis ACL command list in
MIGRATION.mdand
docs/operations.md: it was missingPEXPIRE(used
by redis-py's lock extend/reacquire script) andMULTI/EXEC/
DISCARD(used by redis-py's transaction pipelines), so a user provisioned
strictly per the old list could pass an initial smoke test but fail under
ordinary multi-quota usage. - Expands documentation coverage: the Redis ACL command list in
MIGRATION.mdnow includesPTTL; its validation-error
guidance more precisely distinguishes pydanticValidationErrorfrom
CardinalityLimitExceededError; the README's OpenAI example sets an
explicit output-token budget and notes the zero-token refund on error as an
approximation;docs/configuration.mdgains a
"Choosing reservation sizes" subsection; and
docs/operations.mdgains an "Application-facing
errors" reference section coveringDuplicateRefundError,
UnknownReservationError,AcquireRefundFailedError, and
CardinalityLimitExceededError. - Adds a test-suite safety gate that refuses to run when
--redis-urlpoints at
a non-empty Redis database. The suite flushes that database around every test,
so it now aborts with an actionable message instead of silently wiping data;
setTOKEN_THROTTLE_TESTS_ALLOW_FLUSH=1to opt in to running against a
non-empty database. - Adds a test-suite thread-leak detector that fails the session if a test leaves
a non-daemon thread or a thread-pool worker alive after a short grace period,
catching cross-test interference that previously surfaced only as full-suite
flakiness. SetTOKEN_THROTTLE_THREAD_LEAK_MODE=reportto investigate a leak
without failing the run. - Adds a stdlib-only acquire-path benchmark harness under
benchmarks/
(uv run python -m benchmarks.run, ortask bench) that reports p50/p90/p99
and ops/sec for the memory and Redis backends across sync/async and
uncontended/contended workloads, with optional JSON output. It is not part of
the test suite and adds no runtime dependency; absolute numbers are
machine- and Redis-locality-dependent and meant to be read relatively. See
benchmarks/README.md. - Adds a weekly scheduled soak/stress workflow (
.github/workflows/soak.yml,
also runnable on demand) that repeats the concurrency stress suites many times
back to back, runs the property-based accounting suite, and runs a
tightened-timing conformance pass. It exists to catch load- and soak-class
regressions (contention and accounting bugs that only appear under sustained,
repeated load) that the single-pass PR CI does not exercise. It changes no
library behavior. - Widens the recommended pip install version bounds in the README from a
next-minor cap to a next-major cap (for example>=8.0.8,<9.0.0instead of
>=8.0.8,<8.1.0...