Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,5 @@ venv/
/.pytest_cache/
build/
dist/
docs/superpowers/
.claude/
74 changes: 74 additions & 0 deletions docs/superpowers/specs/2026-04-26-cor-fallback-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
---
title: Chain of Responsibility — Fallback Mechanism
date: 2026-04-26
status: approved
---

## Summary

Enhance `patterns/behavioral/chain_of_responsibility.py` by integrating a proper fallback mechanism using the Template Method pattern. Add a test suite and real-world documentation.

## Problem

The current `FallbackHandler` must be manually chained at the end of every chain. If a developer forgets it, unhandled requests are silently dropped with no feedback. `handle()` returns `None`, so callers have no way to know whether a request was processed.

## Design

### Pattern Composition: CoR + Template Method

`Handler.handle()` becomes a Template Method that owns the full flow:

```
check_range(request)
↓ not handled
successor exists?
├── yes → successor.handle(request)
└── no → self.handle_fallback(request) ← fires automatically
```

Subclasses fill in `check_range`. The base class calls `handle_fallback` automatically when the chain exhausts — developers never silently drop requests again.

### Handler base class changes

- `handle()` returns `bool` (was `None`)
- New `handle_fallback(request: int) -> bool` — default is a no-op returning `False`. Subclasses can override.

### FallbackHandler — two modes

```python
FallbackHandler(mode="log") # default — prints warning, returns False
FallbackHandler(mode="strict") # raises ValueError — for production systems
```

Constructor arg is preferred over subclasses: simpler, more Pythonic at this scale.

### Documentation

Replace abstract docstring with a **support ticket escalation** real-world analogy:
- L1 Support → L2 Support → L3 Support → fallback fires if none handle it

Add ecosystem reference: Django middleware chain.

### Tests — `tests/behavioral/test_chain_of_responsibility.py`

| Test | What it verifies |
|---|---|
| `test_handler_routes_correctly` | Each handler processes requests in its own range |
| `test_fallback_log_mode` | Unhandled request triggers log fallback, returns `False` |
| `test_fallback_strict_mode` | Unhandled request raises `ValueError` |
| `test_handle_returns_true_on_success` | `handle()` returns `True` when handled |
| `test_handle_returns_false_on_fallback` | `handle()` returns `False` when fallback fires |
| `test_chain_without_fallback_handler` | Chain exhausts gracefully via base no-op (no crash) |

## Files Changed

| File | Change |
|---|---|
| `patterns/behavioral/chain_of_responsibility.py` | Template Method integration, FallbackHandler modes, return types, docstring |
| `tests/behavioral/test_chain_of_responsibility.py` | New file — full pytest suite |

## Non-Goals

- No changes to concrete handlers (ConcreteHandler0/1/2)
- No new dependencies
- Existing doctest in `main()` remains valid
139 changes: 112 additions & 27 deletions patterns/behavioral/chain_of_responsibility.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,46 @@
"""
*What is this pattern about?

The Chain of responsibility is an object oriented version of the
`if ... elif ... elif ... else ...` idiom, with the
benefit that the condition–action blocks can be dynamically rearranged
and reconfigured at runtime.

This pattern aims to decouple the senders of a request from its
receivers by allowing request to move through chained
receivers until it is handled.

Request receiver in simple form keeps a reference to a single successor.
As a variation some receivers may be capable of sending requests out
in several directions, forming a `tree of responsibility`.
The Chain of Responsibility is an object-oriented version of the
`if ... elif ... elif ... else ...` idiom, with the benefit that
condition-action blocks can be dynamically rearranged and reconfigured
at runtime.

This pattern aims to decouple senders of a request from its receivers
by allowing the request to move through chained receivers until it is
handled. If no handler claims it, a fallback fires — requests are
never silently dropped.

*Real-world example — customer support escalation:
L1 Support → handles common issues (billing, password reset)
L2 Support → handles technical problems (bugs, integrations)
L3 Support → handles complex/escalated cases (architecture, security)
Fallback → logs unresolved tickets or raises a production alert

Each level tries to resolve the ticket. If it cannot, it escalates to
the next. The fallback guarantees no ticket silently disappears.

*Fallback mechanism:
Handler.handle() is a Template Method that owns the full dispatch flow:
1. check_range() — let this handler attempt to process the request
2. successor.handle() — if unhandled, delegate to the next in chain
3. handle_fallback() — if no successor, this always fires automatically

Use FallbackHandler for explicit control at the end of a chain:
FallbackHandler(mode="log") — prints warning, returns False (default)
FallbackHandler(mode="strict") — raises ValueError (production systems)

Without an explicit FallbackHandler, the base Handler.handle_fallback()
no-op fires — no crash, returns False, request silently ignored.

*Examples in Python ecosystem:
Django Middleware: https://docs.djangoproject.com/en/stable/topics/http/middleware/
The middleware components act as a chain where each processes the request/response.
Each middleware component processes the request/response in sequence.
Django's built-in 404/500 error views act as the chain's fallback.

*TL;DR
Allow a request to pass down a chain of receivers until it is handled.
Allow a request to pass down a chain of handlers. The fallback mechanism
guarantees the chain never silently swallows an unhandled request.
"""

from abc import ABC, abstractmethod
Expand All @@ -30,21 +51,34 @@ class Handler(ABC):
def __init__(self, successor: Optional["Handler"] = None):
self.successor = successor

def handle(self, request: int) -> None:
def handle(self, request: int) -> bool:
"""
Template Method: attempt handling, delegate up the chain, or fall back.

Returns True if a concrete handler processed the request,
False if the request reached the end of the chain unhandled.
Delegates to handle_fallback() if no successor is set.
"""
if self.check_range(request):
return True
if self.successor:
return self.successor.handle(request)
return self.handle_fallback(request)

def handle_fallback(self, request: int) -> bool:
"""
Handle request and stop.
If can't - call next handler in chain.
Called automatically when the chain exhausts without handling request.

As an alternative you might even in case of success
call the next handler.
Override this in a subclass to customize end-of-chain behavior
(e.g., write to a dead-letter queue, send an alert, log metrics).
Subclasses may also raise instead of returning False (see FallbackHandler strict mode).
Default implementation is a silent no-op that returns False.
"""
res = self.check_range(request)
if not res and self.successor:
self.successor.handle(request)
return False

@abstractmethod
def check_range(self, request: int) -> Optional[bool]:
"""Compare passed value to predefined interval"""
"""Return True if request was handled, None/False to pass it along."""


class ConcreteHandler0(Handler):
Expand All @@ -61,7 +95,7 @@ def check_range(request: int) -> Optional[bool]:


class ConcreteHandler1(Handler):
"""... With it's own internal state"""
"""... With its own internal state"""

start, end = 10, 20

Expand All @@ -88,8 +122,44 @@ def get_interval_from_db() -> Tuple[int, int]:


class FallbackHandler(Handler):
@staticmethod
def check_range(request: int) -> Optional[bool]:
"""
Terminal handler for explicit end-of-chain fallback control.

Place at the end of a chain to define what happens when no concrete
handler processes a request. Supports two modes set at construction:

mode="log" (default) — prints a warning and returns False.
Useful during development and for monitoring.
mode="strict" — raises ValueError. Use in production systems
where an unhandled request is always a bug.

Real-world analogy — support ticket escalation:
l3 = ConcreteHandler2(FallbackHandler(mode="strict"))
l2 = ConcreteHandler1(l3)
l1 = ConcreteHandler0(l2)
l1.handle(ticket_priority) # raises ValueError if nobody claims it

To extend: subclass FallbackHandler and override handle_fallback() to add
custom logic such as writing to a dead-letter queue or sending an alert.

Do not assign a successor to FallbackHandler — it is the terminal node.
If a successor is set, handle_fallback() will never be called.
"""

def __init__(self, mode: str = "log") -> None:
super().__init__()
if mode not in ("log", "strict"):
raise ValueError(
f"Invalid mode {mode!r}. Choose 'log' or 'strict'."
)
self.mode = mode

def check_range(self, request: int) -> Optional[bool]:
return None # FallbackHandler never handles requests directly

def handle_fallback(self, request: int) -> bool:
if self.mode == "strict":
raise ValueError(f"No handler found for request {request}")
print(f"end of chain, no handler for {request}")
return False

Expand All @@ -104,7 +174,7 @@ def main():

>>> requests = [2, 5, 14, 22, 18, 3, 35, 27, 20]
>>> for request in requests:
... h0.handle(request)
... _ = h0.handle(request)
request 2 handled in handler 0
request 5 handled in handler 0
request 14 handled in handler 1
Expand All @@ -114,6 +184,21 @@ def main():
end of chain, no handler for 35
request 27 handled in handler 2
request 20 handled in handler 2

>>> # Strict mode raises ValueError for unhandled requests:
>>> h_strict = ConcreteHandler0(FallbackHandler(mode="strict"))
>>> h_strict.handle(5)
request 5 handled in handler 0
True
>>> h_strict.handle(99)
Traceback (most recent call last):
...
ValueError: No handler found for request 99

>>> # Chain without FallbackHandler returns False gracefully:
>>> h_bare = ConcreteHandler0()
>>> h_bare.handle(99)
False
"""


Expand Down
104 changes: 104 additions & 0 deletions tests/behavioral/test_chain_of_responsibility.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import pytest
from patterns.behavioral.chain_of_responsibility import (
ConcreteHandler0,
ConcreteHandler1,
ConcreteHandler2,
FallbackHandler,
)

# These tests are written in TDD style — they currently fail because:
# - Handler.handle() returns None (planned to return bool)
# - FallbackHandler has no mode= parameter (planned in next task)
# Failures are intentional. Subsequent tasks implement against these tests.


def make_chain(fallback=None):
"""Build h0 -> h1 -> h2 -> fallback (optional)."""
h2 = ConcreteHandler2(fallback)
h1 = ConcreteHandler1(h2)
h0 = ConcreteHandler0(h1)
return h0


class TestHandlerRouting:
"""handle() returns True when a concrete handler processes the request."""

def test_routes_to_handler0(self):
assert make_chain().handle(5) is True

def test_routes_to_handler1(self):
assert make_chain().handle(15) is True

def test_routes_to_handler2(self):
assert make_chain().handle(25) is True

def test_boundary_value_handler0(self):
assert make_chain().handle(0) is True # range is [0, 10)

def test_boundary_value_handler1(self):
assert make_chain().handle(10) is True # range is [10, 20)

def test_boundary_value_handler2(self):
assert make_chain().handle(20) is True # range is [20, 30)


class TestChainWithoutExplicitFallback:
"""When no FallbackHandler is chained, the base no-op fires — no crash."""

def test_returns_false_when_no_handler_matches(self):
chain = make_chain() # No FallbackHandler
assert chain.handle(99) is False

def test_does_not_raise(self):
chain = make_chain()
chain.handle(99) # must not raise


class TestFallbackLogMode:
"""FallbackHandler(mode='log') prints a warning and returns False."""

def test_returns_false(self):
chain = make_chain(FallbackHandler(mode="log"))
assert chain.handle(99) is False

def test_prints_warning(self, capsys):
chain = make_chain(FallbackHandler(mode="log"))
chain.handle(99)
captured = capsys.readouterr()
assert captured.out.strip() == "end of chain, no handler for 99"

def test_default_mode_is_log(self, capsys):
chain = make_chain(FallbackHandler())
result = chain.handle(99)
captured = capsys.readouterr()
assert result is False
assert "no handler for 99" in captured.out

def test_does_not_raise(self):
chain = make_chain(FallbackHandler(mode="log"))
chain.handle(99) # must not raise, unlike strict mode


class TestFallbackStrictMode:
"""FallbackHandler(mode='strict') raises ValueError for unhandled requests."""

def test_raises_value_error(self):
chain = make_chain(FallbackHandler(mode="strict"))
with pytest.raises(ValueError, match="No handler found for request 99"):
chain.handle(99)

def test_does_not_raise_for_handled_request(self):
chain = make_chain(FallbackHandler(mode="strict"))
assert chain.handle(5) is True # handled by ConcreteHandler0, strict never fires


class TestFallbackHandlerValidation:
"""FallbackHandler rejects unknown modes at construction time."""

def test_invalid_mode_raises(self):
with pytest.raises(ValueError, match="Invalid mode"):
FallbackHandler(mode="invalid")

def test_valid_modes_do_not_raise(self):
FallbackHandler(mode="log")
FallbackHandler(mode="strict")
Loading