# Why does my cached function return the wrong value?

> Chimera CH-07 (Python decorators): Why does my cached function return the wrong value?

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

## Rules it tests

- [AR-12: Do decorators hide function signatures from coding agents?](https://beta.llmgraph.in/agent-readable/ar-12.md)

Last verified 2026-10-30
