Strategy (swap behavior without if/elif ladders)
When to use
- You need interchangeable algorithms (e.g., retry vs no-retry, gzip vs zstd, round-robin vs hash partition).
- You want easy testing: run the same client with different policies.
- You expect to add new behaviors without touching existing code.
Avoid when there’s only one sensible behavior or a single function/decorator is enough.
Diagram (text)
Client ──> RetryStrategy (interface)
▲
┌────────┴────────┐
NoRetry Backoff
Step-by-step idea
- Define the interface for the behavior you want to vary (
RetryStrategy.run(fn)). - Create small strategies, each implementing that interface.
- Inject the chosen strategy into your client at runtime.
- Test behaviors in isolation.
Python example (≤40 lines, type-hinted)
Real case: pluggable retry policy around an HTTP/DB call.
from __future__ import annotations
import time
from typing import Protocol, Callable, TypeVar
from dataclasses import dataclass
T = TypeVar("T")
class RetryStrategy(Protocol):
def run(self, fn: Callable[[], T]) -> T: ...
@dataclass(frozen=True)
class NoRetry:
def run(self, fn: Callable[[], T]) -> T:
return fn()
@dataclass(frozen=True)
class Backoff:
retries: int = 3
base: float = 0.2
sleep: Callable[[float], None] = time.sleep
def run(self, fn: Callable[[], T]) -> T:
delay = self.base
for attempt in range(self.retries + 1):
try:
return fn()
except Exception:
if attempt == self.retries:
raise
self.sleep(delay)
delay *= 2
@dataclass
class Client:
get: Callable[[], T]
retry: RetryStrategy
def fetch(self) -> T:
return self.retry.run(self.get)
Tiny pytest (cements it)
def test_backoff_eventually_succeeds():
calls, sleeps = {"n": 0}, []
def flaky():
calls["n"] += 1
if calls["n"] < 3: raise ValueError("try again")
return "OK"
c = Client(get=flaky, retry=Backoff(retries=5, base=0, sleep=lambda d: sleeps.append(d)))
assert c.fetch() == "OK"
assert len(sleeps) == 2 # retried twice before success
Trade-offs & pitfalls
- Pros: Clean separation, no
if/elifmaze, easy to add policies, great testability. - Cons: A couple of extra small types; might be overkill for trivial logic.
- Pitfalls:
- Packing shared concerns (logging/metrics) into each strategy—prefer a thin decorator around
Client.fetch. - Strategies holding hidden mutable state—keep them config-only (
@dataclass(frozen=True)helps). - Catching broad
Exception—in real code, filter to retryable exceptions.
- Packing shared concerns (logging/metrics) into each strategy—prefer a thin decorator around
Pythonic alternatives
- Decorator:
@retryaround functions; least ceremony if you don’t need many variants. - Higher-order function:
retry(backoff=..., retries=...)(fn)returns a wrapped callable. - Libraries: use
tenacityorbackoff(jitter, stop conditions, exception whitelists). - Protocols (as used) over ABCs—lighter and duck-typed.
Mini exercise
Extend Backoff to retry only on a set of exceptions (e.g., (TimeoutError, ConnectionError)). Add a test that raises a non-retryable exception and verify it doesn’t sleep/retry.
Checks (quick checklist)
- One small interface for the behavior (
run(fn) -> T). - Strategies are tiny, immutable configs.
- Client depends on the interface, not concrete classes.
- Tests cover success, final failure, and edge parameters.
- Shared cross-cutting stuff handled outside (decorator/wrapper).




