Card 04/ 08

ExampleDifficulty: Intermediate2 min

Decorator Example: Logging and Retrying an HTTP Client

Wrap the client in a retry layer, then in a logging layer, without editing either.

A network client is a natural Decorator, and this example maps the template onto it. The client's interface is one method, get(url). Retrying and logging are each a decorator that implements get and holds another client, and the example builds the same two layers in both orders to show that the order changes what is logged.

The template's participants in the HTTP example
TemplateHTTP example
ComponentHttpClient, with get(url)
ConcreteComponentFlakyClient, which times out a set number of times before it answers
ConcreteDecoratorRetryingClient, which tries again after a timeout, and LoggingClient, which prints each call
py
from abc import ABC, abstractmethod


class HttpClient(ABC):
    @abstractmethod
    def get(self, url: str) -> str: ...


class FlakyClient(HttpClient):
    def __init__(self, failures: int) -> None:
        self._failures = failures

    def get(self, url: str) -> str:
        if self._failures > 0:
            self._failures -= 1
            raise ConnectionError("timed out")
        return "200 OK"


class RetryingClient(HttpClient):
    def __init__(self, inner: HttpClient, attempts: int) -> None:
        self._inner = inner
        self._attempts = attempts

    def get(self, url: str) -> str:
        for attempt in range(1, self._attempts + 1):
            try:
                return self._inner.get(url)
            except ConnectionError:
                if attempt == self._attempts:
                    raise
        raise AssertionError("unreachable")


class LoggingClient(HttpClient):
    def __init__(self, inner: HttpClient) -> None:
        self._inner = inner

    def get(self, url: str) -> str:
        print(f"  log: GET {url}")
        try:
            result = self._inner.get(url)
        except ConnectionError as error:
            print(f"  log: failed, {error}")
            raise
        print(f"  log: {result}")
        return result


print("logging outside retry:")
outside = LoggingClient(RetryingClient(FlakyClient(failures=2), attempts=3))
print(outside.get("/orders"))

print("retry outside logging:")
inside = RetryingClient(LoggingClient(FlakyClient(failures=2)), attempts=3)
print(inside.get("/orders"))
OutputRan with python:3.12-slim (Python 3.12.13)
logging outside retry:
  log: GET /orders
  log: 200 OK
200 OK
retry outside logging:
  log: GET /orders
  log: failed, timed out
  log: GET /orders
  log: failed, timed out
  log: GET /orders
  log: 200 OK
200 OK
The same two decorators in both orders, around a client that fails twice and then succeeds.

Both stacks return "200 OK", and they log different things. With logging outside, the logger sees one call that succeeded, because the retries happen inside the stack where it cannot see them. With logging inside, the retry layer calls the logger three times, and every failed attempt is printed.

One call through retry outside logging
StepLayerWhat happens
1RetryingClientattempt 1 calls the logging layer
2LoggingClientprints GET /orders and calls the flaky client
3FlakyClientraises timed out; the logger prints the failure and re-raises
4RetryingClientcatches the timeout; attempt 2 repeats steps 2 and 3
5RetryingClientattempt 3 calls the logging layer again
6FlakyClientreturns 200 OK; the logger prints it and returns it

Neither order is wrong; they answer different questions. Logging outside records what the caller experienced, one slow request. Logging inside records what the network did, three attempts. You choose the order by which of those you need, and only the line that builds the stack changes.