We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← The Type System as a Design Tool step 17 of 24
Retry, and the predicate that reads backwards
A callable’s type is variant in both directions at once, and they point opposite ways.
Callable[[A], R] is:
-
covariant in
R— a function returningDogcan be used where one returningAnimalis expected. Obvious. -
contravariant in
A— a function acceptingAnimalcan be used where one acceptingDogis expected. Backwards-looking, and correct.
Say the second one out loud and it stops being strange. If the caller is only
ever going to hand your callback a Dog, a callback that handles any animal
is perfectly safe. A callback that only handles Cat is not. The callback must
accept at least what the caller will send — so a broader parameter is
always acceptable, and a narrower one never is.
The error message reads backwards, though, which is why this shows up in review rather than in the author’s own reading of the diff:
error: Argument 2 to "retry" has incompatible type "Callable[[ValueError], bool]";
expected "Callable[[BaseException], bool]"
Callback-heavy code is where this bites: handlers, sort keys, retry predicates, middleware chains, DI wiring, event buses.
And it is why a subclass may not narrow an overridden method’s parameter type — the Liskov substitution violation that is by far the more common review finding:
class Base:
def handle(self, event: Event) -> None: ...
class Child(Base):
def handle(self, event: ClickEvent) -> None: ... # error: violates Liskov
Callers hold a Base and will pass any Event. Child.handle promised to
handle fewer. Widening a parameter in an override is fine; narrowing never is.
One thing that is allowed and surprises people: extra parameters with
defaults. def f(a: Animal, b: int = 0) -> None satisfies
Callable[[Animal], None], because every call the protocol permits is a call
f accepts.
The task
def retry[T](
operation: Callable[[], T],
should_retry: Callable[[BaseException], bool],
attempts: int,
) -> T: ...
def solve(outcomes: list[str], attempts: int, broad: bool) -> tuple[str, str, int]: ...
retry calls operation up to attempts times. On an exception it asks
should_retry: if the answer is False the exception propagates immediately
(no further attempts); if True it tries again. When the attempts run out, the
last exception is re-raised. attempts < 1 raises
ValueError("attempts must be >= 1").
solve builds a deterministic operation from outcomes: the n-th call
consults outcomes[n] and returns "value" for "ok", raises ValueError for
"ValueError", and raises KeyError for anything else. Past the end of the
list it returns "value". It counts its own invocations.
Two predicates are provided:
def is_transient(exc: BaseException) -> bool: ... # ValueError only
def accept_anything(exc: object) -> bool: ... # broader than BaseException
broad selects between them. Both must be assignable to
Callable[[BaseException], bool] — the second one because object is
broader, which is contravariance in action. A predicate declared
(exc: ValueError) -> bool would be rejected, and that is the thing to
internalise.
solve returns ("ok", result, calls) on success and
("failed", exception_type_name, calls) when the exception escapes.
Why the call count is in the return value
Retry logic is timing-shaped, and timing is not testable. The call count is the
deterministic artefact that captures the same behaviour: a non-retryable failure
stops at 1 call even with attempts=3, an exhausted retry makes exactly
attempts calls, and a broad predicate turns the KeyError case from 1 call
into 2. Every claim the docstring would make about the control flow is pinned by
an integer.
Types
retry is generic in the operation’s return type. retry(lambda: 1, pred, 3)
must reveal int — if it reveals Any, every call site downstream is silently
unchecked, which is the failure mode this whole track exists to prevent.
except Exception is deliberate rather than except BaseException:
KeyboardInterrupt and SystemExit are not retryable, and a retry loop that
swallows them is a process you cannot stop.
Loading visualization…
Stuck?
Python reference solution
Sign in to attempt this problem and reveal the reference solution.