Why does a decorator factory called as @valid_until(time.time() + 3600) freeze one deadline?
answer
- Ask when that expression actually ran
- Decoration happens once per process
- The closure keeps one value forever
- Timestamp computed at import, not per call
- Pass a callable or a duration instead
basics
~20 sBecause the argument expression runs once, when the decoration executes at import, not on each call. The factory captures that single timestamp in its closure, so every later call compares against the same fixed instant rather than a rolling hour.
solid answer
~40 sA decorator factory's arguments are ordinary expressions evaluated exactly once, at the moment the `def` below them executes - import time for a module-level function. The factory closes over the resulting value and every call through the wrapper reads that same object. So a long-running route-optimisation job gets one deadline chosen at process start: two workers launched ten minutes apart disagree, restarts appear to fix it, and once the hosts' clocks drift the frozen value looks like a clock-skew artefact tied to the machine rather than to the work. The same trap catches config read from the environment in a decoration line and mutable options shared across every call. The fix is to resolve late: pass a duration or a zero-argument callable and compute inside the wrapper.
code
python · 23 linesimport functools
import time
def valid_until(deadline):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if time.time() > deadline:
raise RuntimeError("deadline passed")
return func(*args, **kwargs)
return wrapper
return decorator
@valid_until(time.time() + 0.05)
def plan_route():
return "planned"
print(plan_route())
time.sleep(0.1)
try:
plan_route()
except RuntimeError as exc:
print("expired:", exc)go deeper
Recall that decoration happens once, when the module is imported, so anything written inside the decoration line is computed once too - not on every call to the function below it.
Explain that the factory's arguments are ordinary expressions evaluated at that single decoration, captured in a closure cell, and never recomputed - and show the fix of resolving the value inside the wrapper instead.
Diagnose the symptom in a live system: a value that never moves until restart, behaviour that varies with process age, workers that disagree once clocks drift, and the memory a large captured argument pins for the process lifetime.
Set the rule for what may be frozen at import and what must be resolved per call, so the system can be reconfigured and tested without a redeploy, and so no decoration line quietly becomes a hidden start-up dependency.
### The timing model, stated precisely A decorator factory involves three calls at three different moments, and only one of them is per call: 1. The argument expressions in the decoration line are evaluated — once, when the `def` statement below them executes. 2. The factory runs with those values and returns a decorator — once, same moment. 3. The decorator runs with the function and returns the wrapper — once, same moment. 4. The wrapper runs — every call, forever after. For a module-level function, steps 1 to 3 happen during import, which for a long-running process means "once, at start-up". `time.time() + 3600` therefore produces a single float, at start-up, that is captured in the factory's closure and compared against on every subsequent call. Nothing recomputes it. The deadline is not a rolling hour; it is one instant, chosen at import. ```python import functools import time def valid_until(deadline): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): if time.time() > deadline: # `deadline` is a fixed float raise RuntimeError("deadline passed") return func(*args, **kwargs) return wrapper return decorator @valid_until(time.time() + 3600) # evaluated once, at import def plan_route(): return "planned" ``` ### What that looks like in production A route-optimisation job that imports once and then runs for days will pass its check for the first hour of the process and fail every call afterwards — or, with the comparison the other way round, never expire at all. Two workers started ten minutes apart carry two different deadlines from the same code, so behaviour depends on process age rather than on anything in the request. Restarts "fix" it, which is the worst property a bug can have. When the hosts' clocks disagree, the frozen value turns into a clock-skew artefact that appears to correlate with the machine rather than with the work: the same input succeeds on one worker and is rejected on another, and nothing in the request or the logs explains why. The same shape catches configuration. `@retry(times=int(os.environ["RETRIES"]))` reads the environment at import, which is before any code that wires configuration later in start-up has run; tests that set the value after importing the module see no effect at all, because the closure already holds the old one. A mutable factory argument is worse still — a list or dict passed as an option is one object shared by every call through that wrapper for the life of the process, so anything the wrapper mutates accumulates. There is a memory dimension too. Whatever you pass to the factory is reachable from the wrapper's closure for as long as the decorated function exists, which for a module-level function is the process lifetime. Hand a factory a preloaded lookup table and you have pinned a 2.4 GB working set that no call frequency, no garbage collector pass and no idle period will release. The decoration line is not where that cost is visible, which is what makes it a genuinely hard leak to find. ### The fixes Resolve late instead of capturing early. Pass a duration and compute the deadline inside the wrapper, or pass a zero-argument callable and call it per call: ```python def valid_until(deadline_fn): def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): if time.time() > deadline_fn(): raise RuntimeError("deadline passed") return func(*args, **kwargs) return wrapper return decorator @valid_until(lambda: time.time() + 3600) def plan_route(): return "planned" ``` The generalisation: a decorator factory argument is a compile-time constant for your process. Pass values there when you *want* them frozen — a precompiled pattern, a lookup table you deliberately build once, a name used for a metric — and pass an indirection (a callable, a key into a settings object read inside the wrapper) when the value can change or must be observable in tests. ### The diagnostic habit When a value in a decorated path refuses to change until a restart, ask when the expression that produced it ran. "Once, at import" is the answer surprisingly often, and it applies to far more than decorators: default parameter values, class attributes, and module-level constants all share the same evaluate-once semantics. Decorator factories are just the place where the gap between "written next to a function" and "evaluated at import" is widest, because the decoration line reads like part of the call.
- How would you let a test change the retry budget after the module has been imported?Stop capturing it in the factory. Have the wrapper read the value at call time - from a settings object, a module attribute or a zero-argument callable passed as the option - so the lookup happens inside the per-call layer. Anything the factory received is already sealed in a closure cell by the time the test runs, and patching the source of that value afterwards changes nothing.
- What is the memory consequence of passing a large object as a factory argument?It is pinned for the process lifetime. The wrapper's closure keeps the argument reachable for as long as the decorated function exists, which for a module-level function means until the process exits. Handing a factory a preloaded table can hold a multi-gigabyte working set that no idle period or collection pass will release, and the decoration line gives no hint of the cost.
- When is freezing a factory argument exactly what you want?Whenever the value is genuinely constant and expensive: a compiled pattern, a lookup table you intend to build once, a metric name, a resolved schema. Evaluate-once is the whole point there - you pay at import instead of on every call. The rule is to freeze deliberately, and use an indirection for anything that can change or must be observable in tests.
The decoration line is a photograph, not a live feed. Whatever you point it at is captured at start-up, and every later call looks at the photograph.
saying these in an interview costs you the question
- Says factory arguments are re-evaluated on every call
- Blames caching or memoisation for the frozen value
- Expects patching the config after import to take effect
- Thinks the decoration re-runs whenever the function is called
- Raises the constant instead of resolving the value per call
- Assumes a closure-captured object is collected between calls