For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Chimeras

Why does my cached function return the wrong value?

Chimera CH-07 · Python decorators

Rules it tests: AR-12

A caching decorator replaces the function it wraps with its own wrapper(*args, **kwargs). Callers now hold the wrapper, so the real parameter list is gone at runtime and for mypy, and a cache key built from args alone ignores keyword arguments. Add one keyword to the decorated function and two different calls can share one cached answer. Wrap with functools.wraps, type the decorator with ParamSpec, and build the key from the bound, defaulted arguments.

The code below is from a small example project we wrote for this page. The break is shown by file and line, and we ran the project's own tests and type checks to confirm it.

1. The setup

Decorators are a good tool for this job. A cached(ttl) decorator is a dozen lines, it works on any function, and the function it wraps stays free of cache code. A pricing service has one function that quotes a line price, and two callers.

python# before/pricing_service/cache.py def cached(ttl): def decorator(func): store = {} def wrapper(*args, **kwargs): # line 10 key = args # line 11 ... value = func(*args, **kwargs) store[key] = (now, value) # line 17 return value return wrapper
python# before/pricing_service/pricing.py @cached(ttl=60) def quote_price(sku, qty, currency="EUR", *, region="EU"): # line 9
python# before/pricing_service/checkout.py return sum(quote_price(sku, qty, region=region) for sku, qty in items) # line 5 # before/pricing_service/invoices.py return quote_price(sku, qty, currency=currency, region=region) # line 5

tests/test_pricing.py checks quote_price directly, with a region and with a currency, and checks the cart total.

2. Where the call path hides

  • The name belongs to the wrapper. The Python language reference says the value a decorator returns is bound to the function's name in place of the function. After @cached(ttl=60) at pricing.py:8, quote_price is the object wrapper built at cache.py:10. A tool that looks for the definition of quote_price finds pricing.py:9. Asked what the name accepts, inspect.signature(quote_price) returns (*args, **kwargs), and mypy 2.4.1 types quote_price as Any.
  • Nothing was copied across. The functools documentation says wraps copies __module__, __name__, __qualname__, __annotations__, __type_params__ and __doc__ to the wrapper and sets __wrapped__. cache.py does not use it, so the wrapper's __name__ is wrapper, and inspect.signature, which follows __wrapped__ by default, has nothing to follow.
  • The key is built from part of the call. key = args at cache.py:11 holds the positional arguments and drops every keyword. The inspect documentation says Signature.bind maps both positional and keyword arguments to parameter names, which is the step the wrapper skips.
  • The two callers pass keywords. checkout.py:5 and invoices.py:5 both call quote_price with region= as a keyword, because region is keyword-only. Their keys are both ("kb-100", 2).

3. The edit that breaks it, and which call is left wrong

Regional tax was added. quote_price gained a keyword-only region:

python# before/pricing_service/pricing.py (edit applied) def quote_price(sku, qty, currency="EUR", *, region="EU"): # line 9 (was: def quote_price(sku, qty, currency="EUR"):)

The edit is finished in the file it touches. The direct tests were updated with it, and they pass: tests/test_pricing.py:6 asks for a US quote and gets 10000, and :10 asks for a USD quote and gets 102384.

The call left wrong is invoices.py:5. A customer in the US checks out, and checkout.py:5 quotes two kb-100 for region US and caches the result 9800 under ("kb-100", 2) (cache.py:17). The invoice for an Indian customer then runs invoices.py:5 for the same item and quantity with region IN. The key is ("kb-100", 2) again, so the wrapper returns 9800 from the cache. The tax-inclusive price for IN is 11564. The same thing happens to currency=: a call with currency="USD" as a keyword and a call without it share a key.

Nothing fails when the module loads, nothing raises, and every direct test passes, because each direct test uses its own arguments. The case that catches it is tests/test_regions.py:11 to :14, written as an xfail with the reason. With pytest the before/ suite reports 3 passed and 1 xfailed; the after/ suite reports 6 passed.

No type checker can flag this call, because the call is correct: the bug is in the key. The checkers also disagree on what the decorated name accepts. mypy 2.4.1 reads the untyped decorator's result as Any, so quote_price("kb-100", 2, nonsense=3) passes its check. pyright 1.1.414 infers through the decorator, shows the real parameters and reports No parameter named "nonsense". The typing documentation names the gap mypy falls into: before ParamSpec, a decorator's inner had *args and **kwargs annotated as Any, and the checker could not check it.

A second edit has the opposite shape. A call that passes "EUR" positionally and a call that leaves it out give two keys for one answer. That costs a cache miss and not a wrong value, so it hides in the timings.

4. The fix

Three parts, all in cache.py. functools.wraps copies the original's name and annotations and sets __wrapped__. ParamSpec types the decorator as Callable[P, R] to Callable[P, R], so the checker sees the real signature on the decorated name: on after/, mypy 2.4.1 now reports Unexpected keyword argument "nonsense" for "quote_price", and inspect.signature returns (sku, qty, currency='EUR', *, region='EU'). The key comes from inspect.signature(func).bind(...) with apply_defaults(), so a positional argument, a keyword and a default each land on the same entry for the same call.

diff--- before/pricing_service/cache.py+++ after/pricing_service/cache.py-import time+import functools+import inspect+import time+from collections.abc import Callable+from typing import ParamSpec, TypeVar +P = ParamSpec("P")+R = TypeVar("R") -def cached(ttl):- def decorator(func):- store = {}++def cached(ttl: float) -> Callable[[Callable[P, R]], Callable[P, R]]:+ def decorator(func: Callable[P, R]) -> Callable[P, R]:+ signature = inspect.signature(func)+ store: dict[tuple, tuple[float, R]] = {} - def wrapper(*args, **kwargs):- key = args+ @functools.wraps(func)+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:+ bound = signature.bind(*args, **kwargs)+ bound.apply_defaults()+ key = tuple(bound.arguments.items())

pricing.py, checkout.py and invoices.py are unchanged. The key holds every argument by name, so all of them must be hashable, as functools.lru_cache also requires. Where an entry never needs to expire, functools.lru_cache is the standard-library choice and needs no key code. It has no time limit, and its documentation says f(a=1, b=2) and f(b=2, a=1) may get two cache entries, so it errs toward a miss and not toward a wrong value.

5. Why the fix works

  • The decorated name keeps its shape. ParamSpec is documented as a way to tell the checker that the callable passed in and the callable returned have inter-dependent parameters. With Callable[P, R] on both sides, quote_price is typed as the function at pricing.py:9, and a call with a missing or misspelled argument is an error in the editor.
  • The runtime metadata survives. wraps sets __wrapped__, so inspect.signature(quote_price) returns (sku, qty, currency='EUR', *, region='EU'). after/tests/test_regions.py checks the name and the parameter list.
  • The key is the whole call. bind maps every argument to its parameter name and raises TypeError if the arguments do not match the signature. apply_defaults fills in the rest. The US and IN quotes now have different keys, and after/tests/test_regions.py:9 and :10 assert both prices.
  • A new parameter needs no cache edit. The key is built from the signature, so adding promo_code to quote_price changes the key without touching cache.py.

6. Rules that apply

  • AR-12: use functools.wraps and preserve signatures in decorators. A decorator that changes the shape on purpose should say so in its type; this one does not, so it should return the same shape it was given.

7. References

Python (fetched 2026-10-30):

  • functools (wraps, update_wrapper, __wrapped__, lru_cache arguments must be hashable, keyword order and separate entries): https://docs.python.org/3/library/functools.html
  • typing (ParamSpec, Concatenate, the Callable[P, T] decorator example): https://docs.python.org/3/library/typing.html
  • inspect (signature and follow_wrapped, Signature.bind, BoundArguments.apply_defaults): https://docs.python.org/3/library/inspect.html
  • Compound statements, function definitions, fetched 2026-10-30 (decorator result is bound to the function name): https://docs.python.org/3/reference/compound_stmts.html#function-definitions

Last verified

For agentsthis page as Markdown/agent-readable/ch-07.mdllms.txt