# Why does my renamed keyword argument fail at runtime?

> Chimera CH-09 (Python typing): Why does my renamed keyword argument fail at runtime?

A service function takes `**opts`, hands it to a retry helper, and the helper hands it to the function that does the work. Each layer is a few lines and none of them names a parameter. Rename a keyword on the bottom function and every layer above still accepts the old one, so the failure waits for a `TypeError` at the bottom call. Spell the parameters at the layer callers use, and give the retry wrapper a `ParamSpec` type so it carries the callee's signature.

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

Forwarding is a good tool for this job. A retry helper that takes `**kwargs` works for any function, and the layer above it needs no edit when a new option appears. A notifications service has four layers and three callers.

```python
# before/notifications/transport.py
def deliver(user: str, channel: str = "email", *, template_id: str, locale: str = "en") -> str:   # line 8
```

```python
# before/notifications/retry.py
def send_with_retry(**kwargs: Any) -> str:           # line 8
    ...
            return deliver(**kwargs)                 # line 12

# before/notifications/service.py
def notify(user: str, **opts: Any) -> str:           # line 6
    return send_with_retry(user=user, **opts)        # line 7
```

```python
# before/notifications/api.py
return notify(user, template="welcome")                              # line 5
return notify(user, template_id="receipt", locale=locale)            # line 9

# before/notifications/jobs.py
return notify(user, channel="sms", template="digest", locale="de")   # line 5
```

`tests/test_transport.py` calls `deliver` directly, with a template, a channel and a locale, and with an unknown channel.

## 2. Where the call path hides

- **No layer names a parameter.** `notify` at `service.py:6` accepts `**opts: Any`, and `send_with_retry` at `retry.py:8` accepts `**kwargs: Any`. The only signature that lists `template_id` is `deliver` at `transport.py:8`, three calls down.
- **`Any` switches the check off.** The mypy documentation says to think of `Any` as a way to locally disable type checking, and that you can do anything with an `Any` value without the checker complaining. A keyword that arrives inside `**opts: Any` is not compared with `deliver`. PEP 612 states the same limit for `Callable[..., R]`: an ellipsis in place of parameter types means no validation on arguments, so a wrong call type-checks and fails at runtime.
- **A search for the parameter finds the signature and one caller.** In the source files, `template_id` appears at `transport.py:8`, at `transport.py:11` and at `api.py:9`. The two stale calls spell `template`, a different word, and it reaches `deliver` as the key of a dict.
- **The retry layer cannot help.** It catches `TransportError` at `retry.py:13` and re-raises after three attempts. A `TypeError` is not a `TransportError`, so it passes through on the first attempt.

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

The template argument was renamed so that it says what it holds. `deliver` now takes `template_id`:

```python
# before/notifications/transport.py (edit applied)
def deliver(user: str, channel: str = "email", *, template_id: str, locale: str = "en") -> str:   # line 8 (was: ..., *, template: str, locale: str = "en")
```

The edit is finished in the files it touches. The direct tests were updated with it: `tests/test_transport.py` calls `deliver("u1", template_id="welcome")` and expects `email:u1:welcome:en`. `api.py:9`, the receipt sender, was updated in the same change.

The calls left wrong are two:

- `api.py:5`, `notify(user, template="welcome")`
- `jobs.py:5`, `notify(user, channel="sms", template="digest", locale="de")`

Each call passes through `notify`, then `send_with_retry`, and arrives at `deliver(**kwargs)` at `retry.py:12` with a key `deliver` does not have. Python raises `TypeError: deliver() got an unexpected keyword argument 'template'` at that line. Nothing says anything before that: the module loads, and no layer lists its parameters. The failure shows when `welcome_user` or `send_digest` runs, and the traceback ends at `retry.py:12`, a file the edit never touched.

The cases that catch it are `tests/test_notifications.py:15` and `:20`, written as `xfail` with the reason. They call `welcome_user` and `send_digest` and expect the sent message. The receipt test at `:7` is the part of the service the edit remembered. With pytest the `before/` suite reports 4 passed and 2 xfailed; the `after/` suite reports 7 passed.

Type checkers have nothing to flag: mypy 2.4.1 and pyright 1.1.414 both report no issues on `before/`. `api.py:5` calls `notify(user, template=...)`, and `notify` accepts any keyword. `retry.py:12` calls `deliver(**kwargs)` with values typed `Any`, which the checker has no reason to compare with `deliver`'s parameters. The call that is wrong is the one in `api.py`, and the only function that could reject it is three layers down.

## 4. The fix

Two changes. The public layer, `notify`, spells the parameters it forwards, so a stale keyword is an error where the caller wrote it. The retry layer becomes a decorator typed with `ParamSpec`: `Callable[P, R]` goes in and `Callable[P, R]` comes out, so the wrapped function keeps its parameter list. The Python typing documentation describes `ParamSpec` as the way to tell the checker that the callable passed into a decorator and the one it returns have inter-dependent parameters.

```diff
--- before/notifications/retry.py
+++ after/notifications/retry.py
-from typing import Any
+import functools
+from collections.abc import Callable
+from typing import ParamSpec, TypeVar
 
 from notifications.transport import TransportError, deliver
 
+P = ParamSpec("P")
+R = TypeVar("R")
+
 MAX_ATTEMPTS = 3
 
 
-def send_with_retry(**kwargs: Any) -> str:
-    last_error: TransportError | None = None
-    for _ in range(MAX_ATTEMPTS):
-        try:
-            return deliver(**kwargs)
-        except TransportError as error:
-            last_error = error
-    assert last_error is not None
-    raise last_error
+def with_retry(func: Callable[P, R]) -> Callable[P, R]:
+    @functools.wraps(func)
+    def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
+        last_error: TransportError | None = None
+        for _ in range(MAX_ATTEMPTS):
+            try:
+                return func(*args, **kwargs)
+            except TransportError as error:
+                last_error = error
+        assert last_error is not None
+        raise last_error
+
+    return wrapper
+
+
+send_with_retry = with_retry(deliver)
```

```diff
--- before/notifications/service.py
+++ after/notifications/service.py
-from typing import Any
-
 from notifications.retry import send_with_retry
 
 
-def notify(user: str, **opts: Any) -> str:
-    return send_with_retry(user=user, **opts)
+def notify(user: str, channel: str = "email", *, template_id: str, locale: str = "en") -> str:
+    return send_with_retry(user, channel, template_id=template_id, locale=locale)
```

`api.py:5` and `jobs.py:5` change from `template=` to `template_id=`. On `before/` they stayed wrong because nothing reported them. On `after/` the signature of `notify` has no `template`, so a checker has a mismatch to report at each of those two calls, and the list of edits comes from the tool and not from a search. With the stale `template="welcome"` put back into `after/api.py:5`, mypy 2.4.1 reports `Unexpected keyword argument "template" for "notify"; did you mean "template_id"?` and pyright 1.1.414 reports `No parameter named "template"` at that line.

`transport.py` is unchanged. `Concatenate`, from the same documentation page, is for a wrapper that adds, removes or transforms leading parameters of the function it wraps, for example a decorator that supplies a lock as the first argument. This retry wrapper changes nothing, so `ParamSpec` alone is enough.

For a function that takes many optional keywords, the typing documentation shows another form: `Unpack` with a `TypedDict` types `**kwargs` by key and value (added to the typing module in Python 3.11). A named `TypedDict` documents the options once and lets the checker validate each key.

## 5. Why the fix works

- **The public layer lists the contract.** `notify` at `service.py:4` has the same parameters as `deliver`. A call with `template=` names a keyword that is not there, which is an error at the caller. Renaming `template_id` again points at `service.py:4`, `service.py:5` and every caller.
- **The wrapper carries the callee's parameters.** `P.args` annotates `*args` and `P.kwargs` annotates `**kwargs`, and the documentation says both require the `ParamSpec` to be in scope. With `Callable[P, R]` on both sides of the decorator, `send_with_retry` has the parameter list of `deliver`, and the call at `service.py:5` is checked against it.
- **The runtime metadata survives.** `functools.wraps` copies `__name__`, `__qualname__`, `__annotations__` and `__doc__` to the wrapper and sets `__wrapped__`. `after/tests/test_notifications.py:20` checks the name and the parameter list.
- **The retry still sees only transport errors.** `with_retry` catches `TransportError` and nothing else, so a `TypeError` at run time still goes straight through.

## 6. Rules that apply

- **AR-11**: avoid `**kwargs` pass-through layers that hide the real signature; spell the parameters or use `ParamSpec`. A thin adapter that must forward everything keeps its generic shape by typing it with `ParamSpec`, and a public entry point spells its parameters.

## 7. References

Python (fetched 2026-10-31):
- typing (`ParamSpec`, `P.args` and `P.kwargs`, `Concatenate`, the `Callable[P, T]` decorator example, `Unpack` with `TypedDict` for `**kwargs`, added in Python 3.11): https://docs.python.org/3/library/typing.html
- functools (`wraps`, `update_wrapper`, `__wrapped__`): https://docs.python.org/3/library/functools.html
- PEP 612, motivation (ellipsis in `Callable` means no validation on arguments): https://peps.python.org/pep-0612/

mypy (fetched 2026-10-31):
- Dynamically typed code (`Any` locally disables type checking): https://mypy.readthedocs.io/en/stable/dynamic_typing.html
- Generics, declaring decorators (a parameter specification is a more faithful annotation than a bound `TypeVar` with `cast`): https://mypy.readthedocs.io/en/stable/generics.html

## Rules it tests

- [AR-11: Why does my agent break calls that go through **kwargs?](https://beta.llmgraph.in/agent-readable/ar-11.md)

Last verified 2026-10-31
