Skip to content

← The Type System as a Design Tool step 6 of 24

Easy Primitives

A @final rate limiter

Two different words, one keyword, and a decision you are making about your callers whether you mean to or not.

Final on a name says “this is never rebound”. On a module constant it is documentation the checker enforces. On a class attribute it also means “no subclass may override this”.

@final on a class says “do not subclass me”. That has two consequences. The social one: you are telling callers you will not maintain subclass compatibility, so you are free to rename private methods and change the MRO. The technical one, which is the more interesting: it lets a checker conclude that type(x) is C implies x is exactly a C, which unlocks narrowing that is otherwise unsound — with an open class, type(x) is C could still be a subclass instance with different behaviour.

@final on a method says “override this and it is an error”, which is how you protect an invariant that a template method depends on.

A correction worth carrying, because PEP 591 originally forbade it: Python 3.13 relaxed the Final/ClassVar combination. ClassVar[Final[int]] is now legal. Before that you had to choose between “shared across instances” and “never rebound” in a single annotation.

The task

@final
class RateLimiter:
    MAX_BURST: Final[int] = 3
    LIMITS: ClassVar[dict[str, int]] = {"admin": 5, "guest": 1}

    def limit_for(self, key: str) -> int: ...
    def allow(self, key: str) -> bool: ...


def solve(keys: list[str]) -> list[bool]: ...

limit_for returns the per-key override from LIMITS if there is one, and MAX_BURST otherwise. allow returns True and records the use while that key’s count is below its limit, and False forever after. Counts are per-instance, kept in a private attribute created in __init__.

solve builds one RateLimiter and returns the result of allow for each key in order.

Keys are exact and case-sensitive: "Admin" is not "admin" and gets the default burst.

The state trap this problem is built around

The harness executes your module once and then calls the entrypoint for every test case in the same namespace. Any state you park on the class survives from one case to the next.

That is not an artefact of the harness — it is exactly what happens to a long-lived process, and it is why “works in the test, fails on the second request” is such a common bug. If you put the counters in a ClassVar dict and never reset them, case two starts with case one’s traffic already counted, and every run after the first gives a different answer.

Here LIMITS is a ClassVar because it is shared, read-only configuration. The counters are per-instance because they are per-instance state. Getting that split right is the whole design decision; Final and @final are how you write it down so the checker holds you to it.

Types

--strict will not let you leave LIMITS unannotated. Note what each annotation buys:

  • MAX_BURST: Final[int] — reassigning it anywhere, including in a subclass or through an instance, is a type error.
  • LIMITS: ClassVar[dict[str, int]]self.LIMITS = {} is a misc error, because assigning to a ClassVar through an instance is exactly the shadowing bug that makes shared state stop being shared.
  • @final on the class — class Stricter(RateLimiter) is a type error.

None of those three is enforced at runtime. All three are enforced in CI, which is where you want the argument to happen.