Skip to content

Require aiohttp 3.15 and read request.timeout directly - #23

Draft
rodrigobnogueira wants to merge 28 commits into
masterfrom
rate-limit-aiohttp-315
Draft

rodrigobnogueira wants to merge 28 commits into
masterfrom
rate-limit-aiohttp-315

Conversation

@rodrigobnogueira

Copy link
Copy Markdown
Member

What do these changes do?

Raises the floor to aiohttp >= 3.15 and reads request.timeout.total
directly in RateLimitMiddleware, replacing the getattr() fallback that
#12 carries.

This is the change you asked for in #12. It cannot land until aiohttp 3.15
is on PyPI (latest today is 3.14.3), so it is parked here as a draft rather
than holding #12 red. Based on add-rate-limit-middleware, so the diff is
the one commit; GitHub will retarget it to master when #12 merges.

Are there changes in behavior for the user?

No behavior change on 3.15. The None guard that goes away was only ever
reachable on older aiohttp, where the attribute did not exist. Installs
resolving aiohttp below 3.15 will no longer get this package.

Related issue number

Follows the review on #12. The attribute comes from aio-libs/aiohttp#13176,
backported to the 3.15 branch as aio-libs/aiohttp#13211.

Checklist

  • Unit tests for the changes exist and pass (pytest).
  • pre-commit run --all-files and mypy pass.
  • Documentation reflects the changes where applicable.
  • Add a new news fragment into the CHANGES/ folder

Verification

Checked against the current 3.15 branch, which already carries the
ClientRequest.timeout property, imported ahead of the installed aiohttp:

aiohttp 3.14.1 (released) aiohttp 3.15 branch
this branch 3 failed, 27 passed 249 passed, mypy clean
#12 as it stands 249 passed, mypy clean 249 passed, mypy clean

The three failures are AttributeError on request.timeout, which is the
whole reason this is not merged yet.

Before merging

  1. Wait for aiohttp 3.15 on PyPI.
  2. Re-run CI here; nothing else should need touching.

Add RateLimitMiddleware, a token-bucket client middleware that throttles
outgoing requests to a configurable rate and burst, with optional
per-domain buckets and numeric Retry-After handling on HTTP 429.

Promoted from the example in aiohttp#11969 (which was moved here): drop the
demo and module-level logging config, keep a strong reference to the
scheduler task so it cannot be garbage collected mid-run, and add tests
and documentation.
- Never sleep on a non-finite or non-positive Retry-After, and clamp it
  to a configurable max_retry_after (default 300s), so a server cannot
  stall or hang the client with "inf"/"nan"/huge values.
- Clear the scheduler reference in a finally block so a bucket whose
  scheduler was cancelled mid-sleep (e.g. its event loop was torn down)
  restarts cleanly on the next acquire instead of deadlocking on reuse.
- On a cancelled acquire, drop the waiter from the queue and hand its
  slot to the next waiter rather than wasting an interval.
- Validate rate > 0 and burst >= 1 at construction (fail fast).
- Document that the per-domain bucket cache is unbounded.

Adds regression tests for each fix (kept at 100% branch coverage) plus
per-domain isolation, drift-cap, and cross-loop reuse.
The code-quality scanner flagged three bare ``await <task>`` statements
(inside ``pytest.raises``) as having no effect. Replace them with a
``_cancel_and_join`` helper that cancels the task, waits for it to finish
unwinding, and asserts it was cancelled -- a call expression rather than a
bare await, and a slightly stronger check. Behaviour and coverage are
unchanged.
Round out the constructor validation: rate and burst already raise on
out-of-range values, but max_retry_after did not, so a nan/negative value
would silently misbehave in the Retry-After clamp. Reject anything that is
not None or a non-negative finite number. Clarify the docs to distinguish
this config validation from the (separate) handling of hostile server-sent
Retry-After values.
… cap

Three robustness gaps found while re-reviewing before marking the PR ready:

- A scheduler task cancelled before its first step (loop torn down right
  after an acquire), or stranded by a loop that was closed without
  cancelling tasks, never runs the finally that clears _scheduler_task.
  The stale reference then blocks every restart and all later acquires
  hang silently, on any loop. Restart scheduling whenever the stored task
  is done or belongs to another loop, drop waiters that were queued on an
  abandoned loop (their events can never be set from the current loop),
  and make the finally clear only its own registration so a garbage
  collected zombie cannot clobber a live replacement.

- rate=nan/inf passed the rate <= 0 check and a subnormal rate overflowed
  1.0 / rate to inf; each silently disabled throttling. Require a positive
  finite rate whose interval is also finite.

- The default max_retry_after of 300s equalled aiohttp's default
  ClientTimeout total (300s), so a fully honored Retry-After was
  guaranteed to surface as a timeout error with the 429 lost. Lower the
  default cap to 60s, treat a 0.0 cap as disabling the sleep, and log at
  debug level. Document the ClientTimeout interplay, middleware ordering,
  and per-host bucket semantics.

Also rescale the two tightest timing tests (the drift-cap bounds sat 5ms
and 2ms from flaking) and relax three roundtrip upper bounds to 0.5s.
The PyPI-facing README still described the package as digest-auth only.
In the API reference, spell out the details a user needs to avoid
surprises: rate must be finite; per-domain buckets are keyed on host only;
Retry-After is honored on 429 only, delays just the request that received
it, and its sleep counts against the session ClientTimeout (hence the 60s
default cap); configuration is fixed at construction; and the rate limiter
should be listed last so internal retries by other middlewares are also
throttled.
docs/code/index.py backs index.rst (the two quickstart examples merge
into one session showing middleware composition and ordering) and
docs/code/api.py backs the RateLimitMiddleware usage block in api.rst,
replacing the inline code block.
aiohttp raises InvalidUrlClientError for host-less URLs before any
middleware runs, on the initial request and on every redirect hop
(client.py checks raw_host before constructing ClientRequest), so the
'unknown' fallback could never be reached; it existed only because
URL.host is Optional in the type.
TokenBucket.acquire() now just computes: it refills fractionally from
elapsed time, takes a token (the count may go negative, queueing
callers in arrival order) and returns the exact delay to sleep, so the
middleware owns the sleep. The waiter queue, events, scheduler task,
loop rebinding and their hardening tests are all deleted.

acquire() takes an optional timeout and raises asyncio.TimeoutError,
handing the token back, when the wait alone would exceed it. The
middleware feeds it the request's total timeout when aiohttp exposes
one; 3.x carries only the timer context, so the bound engages on newer
versions. A caller cancelled mid-sleep returns its slot through the
new release().

The bucket is injectable -- RateLimitMiddleware(TokenBucket(...)) --
leaving room for alternative algorithms; per_domain still builds
per-host buckets from rate/burst and rejects a single injected bucket.
TokenBucket is exported. Bucket tests run on a fake clock as pure
math, with wall-clock sleeps confined to the integration tests.
…After

- Add the RateLimiter ABC defining acquire()/release(), with the async
  wait() (sleep plus cancellation handback) shared by all
  implementations
- RateLimitMiddleware now requires the limiter: an instance, or a
  zero-argument factory with per_domain=True; rate/burst live only on
  TokenBucket
- Drop the Retry-After handling as a potentially separate feature
- Docs: two separate quickstart examples included by pyobject from the
  page-named files
- Prefer the public request.timeout attribute once aiohttp exposes it
- The timeout check moves from each implementation's acquire() into the
  shared RateLimiter.wait(), which hands the slot back through release()
  and raises asyncio.TimeoutError; acquire() is now just reserve-and-
  return-delay
- Replace the per-domain factory callable with a clone() abstractmethod:
  the middleware always takes a RateLimiter instance, and per_domain=True
  clones it once per target host
aiohttp exposes the request ClientTimeout to client middlewares as a
read-only property since aio-libs/aiohttp#13176, so the private
_timeout fallback is gone and the docs name the version (3.15) instead
of 'newer versions'. On 3.12-3.14 the attribute is absent and the
limiter keeps waiting without a budget, as before.
Retry-After handling was dropped from the middleware during review, but
the README still advertised it. Describe what actually ships instead: a
pluggable limiter with a token bucket included.

The rate-limit example installs a single middleware, so the aside about
ordering was describing something the example never showed.
acquire() and release() are deliberately synchronous. That is what makes
concurrent callers on one event loop reserve slots atomically, in arrival
order, and release() additionally runs from a cancellation handler, where
an awaiting implementation can be cut short and lose the slot.

A limiter that needs I/O to reserve a slot overrides wait() instead. It
is already a coroutine and is the only method the middleware calls, so
nothing about the base class stands in the way of a Redis- or
database-backed implementation.
ClientRequest.timeout is a read-only property, so assigning it through
the ClientRequest annotation needs an ignore whose code depends on the
installed aiohttp: attr-defined before the property existed, misc after.
Setting it inside the helper, while the autospec mock is still untyped,
needs no ignore on either.
ClientRequest.timeout is public and read-only from aiohttp 3.15
(aio-libs/aiohttp#13176), and absent on every release available today,
so the attribute cannot be resolved statically here. Annotating the
result of the getattr() gives mypy the value type to check against
instead of Any, which is most of what dropping the getattr() would buy.

Verified against the installed 3.14.1 and against the current 3.15
branch: 249 tests and a clean mypy run on both, so the branch survives
3.15 landing without anyone touching it.
The 3.15 change Dreamsorcerer asked for is written and verified in #23;
it waits only on the aiohttp release, so the comment says where it is.
Three things a pre-merge review turned up, all cheap now and awkward
after 0.2.0 ships:

per_domain was a writable public attribute that nothing read. The
limiters are built once in __init__, so assigning it afterwards was
silently inert in both directions. It is a read-only property now, which
keeps it introspectable without pretending it is a switch.

The docstrings told an I/O-backed limiter to override wait() rather than
acquire(), but acquire() and clone() are abstract, so a class following
that advice literally could not be instantiated. Both docs now say the
two still have to be defined, and why clone() is the one that matters.

The arrival-order guarantee was stated absolutely. A slot handed back by
release() after a cancellation frees capacity that queued callers already
hold fixed delays against, so two of them can send in the same instant.
That is inherent to the synchronous-acquire design and not worth changing,
but the docs should not overpromise.
The getattr() fallback existed because ClientRequest carried no timeout
before aio-libs/aiohttp#13176. With the floor at 3.15 the attribute is
always there, so the middleware reads it directly and mypy checks it. The
None guard goes with it: the property is typed ClientTimeout, never
Optional, so the check is statically dead.

Verified against the 3.15 branch (the #13211 backport tree): 249 passed,
and mypy is clean on this package, tests and docs.
Base automatically changed from add-rate-limit-middleware to master August 9, 2026 05:14

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant