Card 06/ 08

GotchaDifficulty: Advanced3 min

A Request That Falls Off the End of the Chain

No handler accepts it, nobody raises an error, and the request silently does nothing.

When no handler in a chain accepts a request, the last link has nowhere to send it, and in the usual implementation it returns nothing. The caller receives an empty result instead of an error, and carries on. This card shows a request going unhandled, and how to end every chain with an answer.

A shop books parcels with the first carrier that can take their weight: a courier up to 2 kg, a van up to 30 kg, and freight up to 1,000 kg. The last carrier returns None when there is no next link.

py
def book(self, weight_kg: float) -> str | None:
    if weight_kg <= self.max_kg:
        return self.name
    if self.next is not None:
        return self.next.book(weight_kg)
    return None

A customer orders a 1,500 kg pallet.

What does the shop print for the pallet? Decide, then look.
py
class Carrier:
    def __init__(self, name: str, max_kg: float) -> None:
        self.name = name
        self.max_kg = max_kg
        self.next: Carrier | None = None

    def book(self, weight_kg: float) -> str | None:
        if weight_kg <= self.max_kg:
            return self.name
        if self.next is not None:
            return self.next.book(weight_kg)
        return None


courier = Carrier("courier", 2)
courier.next = van = Carrier("van", 30)
van.next = Carrier("freight", 1_000)

for weight in (1.5, 400, 1_500):
    print(f"{weight} kg booked with: {courier.book(weight)}")
OutputRan with python:3.12-slim (Python 3.12.13)
1.5 kg booked with: courier
400 kg booked with: freight
1500 kg booked with: None
The pallet is booked with `None`, and the program carries on.

It prints "booked with: None". No carrier took the parcel, nothing raised an error, and the order goes on to the next step with no carrier attached.

The cost is a failure found later and further away: at the warehouse, when nobody arrives to collect the pallet. The chain did exactly what it was written to do, and nobody had decided what it should do with a request it could not handle.

The fix is to decide. Either end the chain with a default handler that accepts anything, or make the last link raise an error. This version does both: ManualQuote accepts every weight and asks for a quote, and a chain built without it raises LookupError rather than returning None.

py
class Carrier:
    def __init__(self, name: str, max_kg: float) -> None:
        self.name = name
        self.max_kg = max_kg
        self.next: Carrier | None = None

    def book(self, weight_kg: float) -> str:
        if weight_kg <= self.max_kg:
            return self.name
        if self.next is None:
            raise LookupError(f"no carrier set up for {weight_kg} kg")
        return self.next.book(weight_kg)


class ManualQuote(Carrier):
    def __init__(self) -> None:
        super().__init__("manual quote", float("inf"))

    def book(self, weight_kg: float) -> str:
        return f"manual quote requested for {weight_kg} kg"


courier = Carrier("courier", 2)
courier.next = van = Carrier("van", 30)
van.next = freight = Carrier("freight", 1_000)
freight.next = ManualQuote()

for weight in (1.5, 400, 1_500):
    print(f"{weight} kg booked with: {courier.book(weight)}")
OutputRan with python:3.12-slim (Python 3.12.13)
1.5 kg booked with: courier
400 kg booked with: freight
1500 kg booked with: manual quote requested for 1500 kg
The chain now ends in a handler that always answers.

The default handler is the usual choice when an unhandled request has a sensible answer, as with the 404 a web framework sends. Raising is the choice when no answer is sensible and the failure should stop the caller at once.