Functions are values: you can pass one to another function and get a new one back. A decorator is a function that takes a function and returns a wrapped version of it. @log_calls above a def is short for f = log_calls(f).
The wrapper usually takes *args, **kwargs, so it fits any function, does something before or after, and returns the original function's result. Forgetting that return is the classic bug: the decorated function suddenly returns None.
Put @functools.wraps(func) on the wrapper so the decorated function keeps its name and docstring; otherwise every decorated function is called wrapper.
To give a decorator options, like @retry(times=3), add one more layer: a function that takes the options and returns the decorator.
You have already used decorators from the standard library: @functools.lru_cache and @dataclass.