Skip to content

Commit 0787458

Browse files
Make RateLimiter.acquire() async (#25)
1 parent e3bb0c0 commit 0787458

3 files changed

Lines changed: 397 additions & 123 deletions

File tree

‎aiohttp_client_middlewares/rate_limit.py‎

Lines changed: 60 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -21,31 +21,28 @@
2121
class RateLimiter(ABC):
2222
"""Base class for rate-limit algorithms.
2323
24-
Implementations provide the synchronous :meth:`acquire` and
25-
:meth:`clone`; the async sleeping and timeout logic live here in
26-
:meth:`wait`, shared by every algorithm. Because :meth:`acquire` is
27-
synchronous, callers on one event loop reserve slots atomically in
28-
arrival order.
29-
30-
A limiter that needs I/O to reserve a slot -- one backed by Redis or a
31-
database, say -- overrides :meth:`wait` rather than :meth:`acquire`:
32-
``wait`` is the only method the middleware calls and is already a
33-
coroutine. Such an implementation owns what it takes over: ordering
34-
between concurrent callers, charging the round trip against *timeout*,
35-
and handing the slot back when the caller goes away.
36-
37-
:meth:`acquire` and :meth:`clone` stay abstract either way, so an
38-
implementation that overrides :meth:`wait` still has to define both to
39-
be instantiable. Its :meth:`acquire` is never called and can simply
40-
raise; :meth:`clone` is called for real by ``per_domain=True``.
24+
Implementations provide an async :meth:`acquire` and a synchronous
25+
:meth:`clone`. The sleeping, timeout and post-reservation cancellation
26+
logic lives in :meth:`wait`, shared by every algorithm, so reserving a
27+
slot may perform I/O of its own -- against Redis or a database, say.
28+
29+
Until :meth:`acquire` returns, cleaning up a half-made reservation is
30+
its own responsibility; once it returns, :meth:`wait` owns the slot and
31+
calls :meth:`release` if it cannot be used.
32+
33+
An async method that contains no suspension point still runs atomically
34+
when awaited directly. :class:`TokenBucket` relies on that property to
35+
preserve arrival ordering on one event loop.
4136
"""
4237

4338
@abstractmethod
44-
def acquire(self) -> float:
39+
async def acquire(self) -> float:
4540
"""Reserve a slot and return the delay to sleep before sending.
4641
47-
Must return without awaiting: the arrival-order guarantee above
48-
holds precisely because there is no suspension point here.
42+
The delay must be non-negative, finite seconds; :meth:`wait` takes that
43+
on trust, and a NaN would send the request through unthrottled. If
44+
cancellation or another exception prevents this method from
45+
returning, it must not leave a reservation behind.
4946
"""
5047

5148
@abstractmethod
@@ -64,28 +61,35 @@ def release(self) -> None:
6461
cancelled while sleeping. The default is a no-op for algorithms
6562
that have nothing to return.
6663
67-
Runs from an ``except asyncio.CancelledError`` block, so it must
68-
not await either: a second cancellation, or the loop shutting
69-
down, would truncate it part-way and lose the slot for good.
64+
Must neither await nor raise, since one of those calls is from an
65+
``except asyncio.CancelledError`` block: awaiting there can be
66+
truncated part-way, and raising would replace the exception the
67+
caller is owed. A limiter that has to reach its backend to hand a
68+
slot back can schedule that round trip as a task from here.
7069
"""
7170

7271
async def wait(self, timeout: float | None = None) -> None:
73-
"""Reserve a slot and sleep out its delay.
72+
"""Reserve a slot and wait until the request may be sent.
7473
75-
When the delay would exceed *timeout*, the slot is handed back and
76-
:exc:`asyncio.TimeoutError` is raised without sleeping, so a
77-
request that could never be sent in time fails fast.
78-
79-
This is the method the middleware calls, and the one to override
80-
when reserving a slot needs I/O of its own.
74+
Time in :meth:`acquire` is charged against *timeout* once it
75+
returns, though not bounded by it, so an implementation that can
76+
hang needs its own deadline. When the delay exceeds what is left,
77+
the slot is handed back and :exc:`asyncio.TimeoutError` raised
78+
without sleeping.
8179
"""
82-
delay = self.acquire()
83-
if timeout is not None and delay > timeout:
84-
self.release()
85-
raise asyncio.TimeoutError(
86-
f"rate limiter would delay the request {delay:.3f}s, "
87-
f"beyond the {timeout:.3f}s timeout"
88-
)
80+
started = time.monotonic()
81+
delay = await self.acquire()
82+
83+
if timeout is not None:
84+
# Goes negative when acquiring alone outlasted the timeout; the
85+
# message reports it as such rather than clamping it to zero.
86+
remaining = timeout - (time.monotonic() - started)
87+
if delay > remaining:
88+
self.release()
89+
raise asyncio.TimeoutError(
90+
f"rate limiter would delay the request {delay:.3f}s, "
91+
f"beyond the {remaining:.3f}s remaining timeout"
92+
)
8993
if delay > 0.0:
9094
try:
9195
await asyncio.sleep(delay)
@@ -131,11 +135,13 @@ def _refill(self) -> None:
131135
)
132136
self._last_refill = now
133137

134-
def acquire(self) -> float:
138+
async def acquire(self) -> float:
135139
"""Take one token and return the delay to sleep before sending.
136140
137141
The delay is the exact fractional deficit (not rounded to whole
138142
intervals), so a caller never waits longer than the bucket needs.
143+
There is deliberately no suspension point: callers on one event
144+
loop reserve slots atomically, in arrival order.
139145
"""
140146
self._refill()
141147
self._tokens -= 1.0
@@ -155,14 +161,16 @@ class RateLimitMiddleware:
155161
"""Client middleware that throttles requests through a :class:`RateLimiter`.
156162
157163
The middleware waits on the limiter before sending, so the client never
158-
sends faster than the limiter allows and slots are granted in arrival
159-
order. Cancellation is the one exception: a slot handed back by
160-
:meth:`RateLimiter.release` frees capacity that queued callers have
161-
already been given fixed delays against, so two of them can briefly
162-
send in the same instant. When aiohttp exposes the request's timeout
163-
(aiohttp 3.15 and newer), a wait that would exceed it fails immediately
164-
with :exc:`asyncio.TimeoutError` instead of sleeping toward a guaranteed
165-
timeout.
164+
sends faster than the limiter allows. What that ordering is worth is the
165+
limiter's to say. :class:`TokenBucket` grants slots in arrival order
166+
because its :meth:`~RateLimiter.acquire` has no suspension point.
167+
168+
For :class:`TokenBucket`, cancellation is the one exception to arrival
169+
order: a handed-back slot frees capacity that queued callers have already
170+
been given fixed delays against, so two of them can briefly send in the
171+
same instant. When aiohttp exposes the request's timeout (aiohttp 3.15
172+
and newer), a wait that would exceed it fails immediately with
173+
:exc:`asyncio.TimeoutError` instead of sleeping toward a guaranteed timeout.
166174
167175
Middleware order matters: middlewares listed earlier wrap the ones listed
168176
later, and a middleware that retries internally (for example,
@@ -231,5 +239,10 @@ async def __call__(
231239
# the getattr() is gated on raising the floor to 3.15; that change
232240
# is ready in #23 and waits only on the aiohttp release.
233241
client_timeout: ClientTimeout | None = getattr(request, "timeout", None)
234-
await limiter.wait(None if client_timeout is None else client_timeout.total)
242+
total = None if client_timeout is None else client_timeout.total
243+
if total is not None and total <= 0.0:
244+
# aiohttp arms its own deadline only for a positive total, so a
245+
# zero or negative one means "no timeout", not "no budget left".
246+
total = None
247+
await limiter.wait(total)
235248
return await handler(request)

‎docs/api.rst‎

Lines changed: 58 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -82,37 +82,61 @@ Rate limiting
8282

8383
.. class:: RateLimiter()
8484

85-
Abstract base class for rate-limit algorithms. Implementations provide the
86-
synchronous ``acquire()``, which reserves a slot and returns the delay, in
87-
seconds, to sleep before sending, and ``clone()``, which returns a fresh
88-
limiter with the same configuration (used once per host by per-domain
89-
mode). The async ``wait(timeout=None)`` method is shared by all
90-
implementations: it acquires a slot, fails fast with
91-
:exc:`asyncio.TimeoutError` -- handing the slot back -- when the delay
92-
would exceed *timeout*, sleeps out the delay otherwise, and hands the slot
93-
back if the caller is cancelled mid-sleep. ``release()`` defaults to a
94-
no-op for algorithms that have nothing to return.
95-
96-
Neither ``acquire()`` nor ``release()`` may await. Their being synchronous
97-
is what reserves slots atomically, in arrival order, across concurrent
98-
callers on one event loop, and ``release()`` additionally runs from a
99-
cancellation handler, where an awaiting implementation can be truncated
100-
and lose the slot. A limiter that needs I/O to reserve a slot -- one
101-
backed by Redis or a database, say -- overrides ``wait()`` instead: it is
102-
the only method the middleware calls and is already a coroutine. Such an
103-
implementation takes on ordering between concurrent callers, charging its
104-
round trip against *timeout*, and returning the slot when the caller goes
105-
away. ``acquire()`` and ``clone()`` are abstract either way, so it still
106-
has to define both to be instantiable; its ``acquire()`` is never called
107-
and can simply raise, while ``clone()`` is used for real by
108-
``per_domain=True``.
85+
Abstract base class for rate-limit algorithms, and the type
86+
:class:`RateLimitMiddleware` accepts. Implementations provide async
87+
``acquire()``, which reserves a slot and returns its delay as a non-negative
88+
finite number of seconds -- ``wait()`` takes that on trust -- and
89+
``clone()``, which returns a fresh limiter with the same configuration
90+
(used once per host by per-domain mode)::
91+
92+
class RedisLimiter(RateLimiter):
93+
def __init__(self, redis, key, script):
94+
self._redis, self._key, self._script = redis, key, script
95+
96+
async def acquire(self):
97+
# An atomic script reserves the next slot and returns the delay
98+
# in milliseconds: Redis turns Lua numbers into integers, so a
99+
# fractional second cannot come back as one.
100+
ms = await self._redis.evalsha(self._script, 1, self._key)
101+
return ms / 1000
102+
103+
def clone(self):
104+
return RedisLimiter(self._redis, self._key, self._script)
105+
106+
The sketch keeps one key across clones, and ``clone()`` takes no arguments
107+
so it cannot learn the host; give each host its own limiter rather than
108+
using ``per_domain=True``. It also leaves ``release()`` at the default
109+
no-op, and a cancelled round trip can leave a reservation nobody holds;
110+
an expiry on each reservation covers both.
111+
112+
``wait(timeout=None)`` is supplied by the base class. It charges async
113+
acquisition against *timeout* once ``acquire()`` returns -- without bounding
114+
the call itself, so an implementation that can hang needs its own deadline --
115+
then fails fast when the delay exceeds what is left, sleeps otherwise, and
116+
calls ``release()`` if an acquired slot cannot be used. ``release()`` defaults
117+
to a no-op for algorithms with nothing to return, and stays synchronous: one
118+
of those calls is from a cancellation handler, where awaiting can be truncated
119+
part-way and lose the slot for good, and raising would replace the exception
120+
the caller is owed. A limiter that has to reach its backend to hand a slot
121+
back can schedule that round trip as a task.
122+
123+
``acquire()`` must be cancellation-safe: if cancellation or another
124+
exception prevents it from returning, it must leave no reservation behind.
125+
Once it returns successfully, ``wait()`` owns that cleanup. A backend whose
126+
reservation can outlive a cancelled network operation should use an
127+
idempotency key, transaction, or expiry so interrupted acquisition cannot
128+
leak capacity.
129+
130+
An async ``acquire()`` with no suspension point still reserves atomically
131+
across callers on one event loop. An implementation that performs I/O
132+
determines its own ordering at those suspension points.
109133

110134
.. class:: TokenBucket(rate=10.0, burst=10)
111135

112136
A :class:`RateLimiter`: tokens accrue continuously at ``rate`` per second,
113-
capped at ``burst``; ``acquire()`` takes one token and the count may go
114-
negative, which is what queues callers up in arrival order. The bucket
115-
holds no tasks or loop state.
137+
capped at ``burst``; async ``acquire()`` takes one token and the count may
138+
go negative, which is what queues callers up in arrival order. It contains
139+
no suspension point and the bucket holds no tasks or loop state.
116140

117141
:param float rate: Token accrual rate, in tokens per second. Must be a
118142
positive, finite number.
@@ -137,10 +161,13 @@ Rate limiting
137161
:raises TypeError: if ``limiter`` is not a :class:`RateLimiter`.
138162

139163
The middleware waits on the limiter before sending, so the client never
140-
sends faster than the limiter allows and slots are granted in arrival
141-
order. Cancellation is the one exception: a slot handed back by
142-
``release()`` frees capacity that queued callers already hold fixed delays
143-
against, so two of them can briefly send in the same instant. When aiohttp
164+
sends faster than the limiter allows. What that ordering is worth is the
165+
limiter's to say: :class:`TokenBucket` grants slots in arrival order because
166+
its async ``acquire()`` does not suspend, while an I/O-backed limiter orders
167+
callers according to its backend. For :class:`TokenBucket`, cancellation is
168+
the one exception: a slot handed back by ``release()`` frees capacity that
169+
queued callers already hold fixed delays against, so two of them can
170+
briefly send in the same instant. When aiohttp
144171
exposes the request's total timeout to the middleware
145172
(aiohttp 3.15 and newer), a wait that would exceed it fails immediately
146173
with :exc:`asyncio.TimeoutError` instead of sleeping toward a guaranteed

0 commit comments

Comments
 (0)