2121class 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 )
0 commit comments