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

  1. Define the interface for the behavior you want to vary (RetryStrategy.run(fn)).
  2. Create small strategies, each implementing that interface.
  3. Inject the chosen strategy into your client at runtime.
  4. 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/elif maze, 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.

Pythonic alternatives

  • Decorator: @retry around functions; least ceremony if you don’t need many variants.
  • Higher-order function: retry(backoff=..., retries=...)(fn) returns a wrapped callable.
  • Libraries: use tenacity or backoff (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).