Skip to content

← Stdlib Mastery step 29 of 55

Medium Primitives

partial: a signature-preserving retrier, and the 3.14 descriptor change

Write a retry wrapper that keeps the wrapped function’s signature, and a class that exposes pre-configured variants as attributes that bind correctly on 3.13 and 3.14+ alike.

def make_retrier(
    fn: Callable[P, R], *, attempts: int, delay: float,
    sleep: Callable[[float], None],
) -> Callable[P, R]: ...
  • Calls fn, retrying on ValueError, up to attempts times total.
  • Before attempt i (1-indexed from the second), calls sleep(delay * i) — so the waits are delay*1, delay*2, .... The clock is injected; a function that calls time.sleep directly cannot be tested.
  • Re-raises the last ValueError when every attempt fails.
  • attempts < 1 raises ValueError from make_retrier itself.
def shout(text: str, *, mark: str = "!") -> str: ...     # text.upper() + mark

class Notifier:
    def __init__(self, channel: str) -> None: ...
    def send(self, body: str, *, level: str = "info") -> str: ...
        # f"{self.channel}/{level}: {body}"
    alert = ...     # send with level="alert", still receives self
    banner = ...    # shout with mark="!!!", must NOT receive self
def solve(fail_times: int, attempts: int, boost: int | None) -> dict[str, object]:

solve builds a closure flaky(base: int, *, boost: int = 1) -> int that raises ValueError on its first fail_times calls and then returns base * boost, wraps it with make_retrier(..., delay=0.25, sleep=waits.append), and calls it as retrier(10) or retrier(10, boost=boost). It returns:

{"outcome": object,        # the result, or "exhausted", or "ValueError" if
                           #   make_retrier itself rejected `attempts`
 "calls": int, "waits": list[float],
 "alert": str,             # notifier.alert("disk full")  -> "ops/alert: disk full"
 "banner": str,            # notifier.banner("ship it")   -> "SHIP IT!!!"
 "partial_pickles": bool,  # a round-tripped partial still works
 "lambda_pickles": bool}   # pickling a lambda -> False

When make_retrier raises, return {"outcome": "ValueError", "calls": 0, "waits": [], "alert": "", "banner": "", "partial_pickles": False, "lambda_pickles": False}.

Use Notifier("ops"). For the pickle checks, round-trip partial(str.upper, "revived") and confirm calling it gives "REVIVED"; and attempt pickle.dumps on a lambda inside a try.

The 3.14 breaking change. functools.partial became a method descriptor in 3.14, so a bare partial stored as a class attribute now binds self when accessed through an instance. Before 3.14 it did not; 3.13 emitted a FutureWarning. banner therefore needs staticmethod — the documented fix for preserving the old behaviour — and alert needs partialmethod, which has bound self correctly on every version. A bare partial in a class body means one thing on 3.13 and the other on 3.14, and the difference surfaces as a TypeError about argument counts whose traceback names the wrapped function rather than the class.

Why picklability matters. multiprocessing and ProcessPoolExecutor have to ship your callable across a process boundary. A partial of a module-level function pickles; a lambda raises. That single difference decides most real choices between them.

Your submission must pass mypy --strict, and Callable[..., Any] will not do: it erases every call site of the retried function and turns the return into Any, which propagates. Use ParamSpec. (mypy has type-checked partial application since 1.11, so “partial is untypeable” is stale advice.)