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.
# 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# before/pricing_service/pricing.py
@cached(ttl=60)
def quote_price(sku, qty, currency="EUR", *, region="EU"): # line 9# 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 5tests/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)atpricing.py:8,quote_priceis the objectwrapperbuilt atcache.py:10. A tool that looks for the definition ofquote_pricefindspricing.py:9. Asked what the name accepts,inspect.signature(quote_price)returns(*args, **kwargs), and mypy 2.4.1 typesquote_priceasAny. - Nothing was copied across. The
functoolsdocumentation sayswrapscopies__module__,__name__,__qualname__,__annotations__,__type_params__and__doc__to the wrapper and sets__wrapped__.cache.pydoes not use it, so the wrapper's__name__iswrapper, andinspect.signature, which follows__wrapped__by default, has nothing to follow. - The key is built from part of the call.
key = argsatcache.py:11holds the positional arguments and drops every keyword. Theinspectdocumentation saysSignature.bindmaps both positional and keyword arguments to parameter names, which is the step the wrapper skips. - The two callers pass keywords.
checkout.py:5andinvoices.py:5both callquote_pricewithregion=as a keyword, becauseregionis 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:
# 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.
--- 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.
ParamSpecis documented as a way to tell the checker that the callable passed in and the callable returned have inter-dependent parameters. WithCallable[P, R]on both sides,quote_priceis typed as the function atpricing.py:9, and a call with a missing or misspelled argument is an error in the editor. - The runtime metadata survives.
wrapssets__wrapped__, soinspect.signature(quote_price)returns(sku, qty, currency='EUR', *, region='EU').after/tests/test_regions.pychecks the name and the parameter list. - The key is the whole call.
bindmaps every argument to its parameter name and raisesTypeErrorif the arguments do not match the signature.apply_defaultsfills in the rest. The US and IN quotes now have different keys, andafter/tests/test_regions.py:9and:10assert both prices. - A new parameter needs no cache edit. The key is built from the signature, so adding
promo_codetoquote_pricechanges the key without touchingcache.py.
6. Rules that apply
- AR-12: use
functools.wrapsand 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_cachearguments must be hashable, keyword order and separate entries): https://docs.python.org/3/library/functools.html - typing (
ParamSpec,Concatenate, theCallable[P, T]decorator example): https://docs.python.org/3/library/typing.html - inspect (
signatureandfollow_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