I've implemented a retry decorator with some parameters:
def retry(max_tries: int = 3, delay_secs: float = 1, backoff: float = 1.5):
print("level 1:", max_tries, delay_secs, backoff)
def decorator(func):
print("level 2:", max_tries, delay_secs, backoff)
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal delay_secs ## UnboundLocalError if remove this line
print("level 3:", max_tries, delay_secs, backoff)
for attempt in range(max_tries):
try:
return func(*args, **kwargs)
except Exception as e:
print(f"attempt {attempt} Exception: {e} Sleeping {delay_secs}")
time.sleep(delay_secs)
delay_secs *= backoff
print("exceeded maximun tries")
raise e
return wrapper
return decorator
@retry(max_tries=4, delay_secs=1, backoff=1.25)
def something():
raise Exception("foo")
something()
If I remove this line, I got UnboundLocalError
nonlocal delay_secs
But that only happens for delay_secs, NOT for max_tries or backoff! I tried reordering/renaming the params and still that delay param is problematic.
Can you help me understand why that parameter out of scope within the wrapper function but the other 2 parameters are just fine?
Python: 3.9.2
OS: Debian 11 Linux
The answer is almost trivial:
out of the 3 variables, delay_secs
, max_tries
and backoff
, there is only one you write to in your inner function.
When one tries to retrieve the value of a variable for which there is no assignment inside a function, there is no ambiguity: Python automatically searches for that variable in outer scopes (functions where the inner function is nested in), then in the global, and finally the built-in scope. (If it is not found in any of those, a NameError
is raised).
But the compiler of the Python runtime assumes that any variable that is assigned to in a function is a local variable - if one wants to change the values of variables in outer or global scopes, they have to be explicitly declared as so (with the nonlocal
or global
keywords, respectivelly).
So, since the value of delay_secs
is changed, without the nonlocal declaration, Python assumes that is a local variable. When it tries to retrieve its value, both in your print
call, and then in the implicit read when you use the *=
operator (it has to retrieve the original value in order to multiply it), there is no local value assigned to it yet, so the error. Python "knows", because it marks it at compile time, that at some point that function should have a local variable named delay_secs
- so you don't get a NameError
- but since there is no value to be read, it raises UnboundLocalError
instead.
Note that some other languages would use the outer scope variable for reading, until it was assigned in the inner function, at which point a local variable would be created - this is not how Python works, and the rationale for that is written in the PEP which created the nonlocal
keyword itself: https://peps.python.org/pep-3104/