We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Tests That Earn Their Keep step 6 of 19
A rate limiter with an injected clock
A token-bucket rate limiter that calls time.monotonic() directly is
untestable, and the industry’s usual answer — mock.patch("mymodule.time") —
is worse than the disease.
Patching binds your test to the import spelling in the module under test.
Change import time to from time import monotonic and the patch silently
stops applying; the test still passes, because a rate limiter that never
refills still denies things. pytest’s own documentation puts the alternative
plainly:
For code that you control, a safer long-term pattern is to make dependencies explicit so they can be passed into the code under test instead of patched globally.
So: take the clock as an argument.
What to write
def make_rate_limiter(
*, capacity: int, refill_per_second: float, clock: Clock
) -> Callable[[int], bool]
It returns allow(n) — True if n tokens were available and consumed, False
otherwise. The bucket starts full, at capacity tokens, and refills at
refill_per_second continuously, never above capacity.
The only “now” it may consult is clock.monotonic(). Do not import time,
datetime or random. The tests drive a FakeClock whose readings start at
0.0; a real clock returns a number in the millions and every bucket you
build would look like it had been idle since the machine booted, so a real
clock does not merely make the test slow, it makes it wrong.
The hostile clock
monotonic promises never to go backwards. Across a suspend, a container
migration, or a VM snapshot restore, it does anyway — and so does any clock a
colleague hands you in six months. Your limiter must survive a decreasing
reading:
-
tokens must never exceed
capacity, - tokens must never go below zero,
- and it must not raise.
The naive elapsed = now - last computes a negative refill, drives the
bucket deeply negative, and then denies every request for a long time
afterwards while looking perfectly correct in every test written with an
increasing clock. Treat time going backwards as no time passing.
Each limiter owns its own bucket. Two limiters built from the same clock share nothing — a module-level counter fails that case immediately.
Why a Protocol and not a Mock
Clock is a Protocol with one method. FakeClock does not inherit from it
and does not register with it; it satisfies it structurally, and mypy
checks the fake against the same port as the real implementation. That is
the entire argument against MagicMock, whose type is Any: a mock accepts
clock.monotonik(), clock.monotonic(1, 2, "garbage") and every other call
you could make, and reports nothing. A fake that type-checks cannot drift out
of sync with the interface, because the interface is a type.
Note also what you must not do: isinstance(clock, Clock) raises
TypeError on a Protocol that is not @runtime_checkable, and even with the
decorator it would only check that the attribute exists, not its signature.
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.