Skip to content

Commit 8ffe326

Browse files
committed
feat(behavioral): add fallback mechanism to Chain of Responsibility
- Handler.handle() refactored as a Template Method returning bool - Added handle_fallback() hook to Handler base class; fires automatically when the chain exhausts with no handler — requests never silently dropped - FallbackHandler redesigned with mode='log'|'strict' for dev vs production - Added tests/behavioral/test_chain_of_responsibility.py (16 tests, full coverage) - Updated module docstring with real-world support escalation analogy - Updated .gitignore to exclude docs/superpowers/ and .claude/
1 parent f258073 commit 8ffe326

3 files changed

Lines changed: 218 additions & 27 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,5 @@ venv/
1515
/.pytest_cache/
1616
build/
1717
dist/
18+
docs/superpowers/
19+
.claude/

patterns/behavioral/chain_of_responsibility.py

Lines changed: 112 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,46 @@
11
"""
22
*What is this pattern about?
33
4-
The Chain of responsibility is an object oriented version of the
5-
`if ... elif ... elif ... else ...` idiom, with the
6-
benefit that the condition–action blocks can be dynamically rearranged
7-
and reconfigured at runtime.
8-
9-
This pattern aims to decouple the senders of a request from its
10-
receivers by allowing request to move through chained
11-
receivers until it is handled.
12-
13-
Request receiver in simple form keeps a reference to a single successor.
14-
As a variation some receivers may be capable of sending requests out
15-
in several directions, forming a `tree of responsibility`.
4+
The Chain of Responsibility is an object-oriented version of the
5+
`if ... elif ... elif ... else ...` idiom, with the benefit that
6+
condition-action blocks can be dynamically rearranged and reconfigured
7+
at runtime.
8+
9+
This pattern aims to decouple senders of a request from its receivers
10+
by allowing the request to move through chained receivers until it is
11+
handled. If no handler claims it, a fallback fires — requests are
12+
never silently dropped.
13+
14+
*Real-world example — customer support escalation:
15+
L1 Support → handles common issues (billing, password reset)
16+
L2 Support → handles technical problems (bugs, integrations)
17+
L3 Support → handles complex/escalated cases (architecture, security)
18+
Fallback → logs unresolved tickets or raises a production alert
19+
20+
Each level tries to resolve the ticket. If it cannot, it escalates to
21+
the next. The fallback guarantees no ticket silently disappears.
22+
23+
*Fallback mechanism:
24+
Handler.handle() is a Template Method that owns the full dispatch flow:
25+
1. check_range() — let this handler attempt to process the request
26+
2. successor.handle() — if unhandled, delegate to the next in chain
27+
3. handle_fallback() — if no successor, this always fires automatically
28+
29+
Use FallbackHandler for explicit control at the end of a chain:
30+
FallbackHandler(mode="log") — prints warning, returns False (default)
31+
FallbackHandler(mode="strict") — raises ValueError (production systems)
32+
33+
Without an explicit FallbackHandler, the base Handler.handle_fallback()
34+
no-op fires — no crash, returns False, request silently ignored.
1635
1736
*Examples in Python ecosystem:
1837
Django Middleware: https://docs.djangoproject.com/en/stable/topics/http/middleware/
19-
The middleware components act as a chain where each processes the request/response.
38+
Each middleware component processes the request/response in sequence.
39+
Django's built-in 404/500 error views act as the chain's fallback.
2040
2141
*TL;DR
22-
Allow a request to pass down a chain of receivers until it is handled.
42+
Allow a request to pass down a chain of handlers. The fallback mechanism
43+
guarantees the chain never silently swallows an unhandled request.
2344
"""
2445

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

33-
def handle(self, request: int) -> None:
54+
def handle(self, request: int) -> bool:
55+
"""
56+
Template Method: attempt handling, delegate up the chain, or fall back.
57+
58+
Returns True if a concrete handler processed the request,
59+
False if the request reached the end of the chain unhandled.
60+
Delegates to handle_fallback() if no successor is set.
61+
"""
62+
if self.check_range(request):
63+
return True
64+
if self.successor:
65+
return self.successor.handle(request)
66+
return self.handle_fallback(request)
67+
68+
def handle_fallback(self, request: int) -> bool:
3469
"""
35-
Handle request and stop.
36-
If can't - call next handler in chain.
70+
Called automatically when the chain exhausts without handling request.
3771
38-
As an alternative you might even in case of success
39-
call the next handler.
72+
Override this in a subclass to customize end-of-chain behavior
73+
(e.g., write to a dead-letter queue, send an alert, log metrics).
74+
Subclasses may also raise instead of returning False (see FallbackHandler strict mode).
75+
Default implementation is a silent no-op that returns False.
4076
"""
41-
res = self.check_range(request)
42-
if not res and self.successor:
43-
self.successor.handle(request)
77+
return False
4478

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

4983

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

6296

6397
class ConcreteHandler1(Handler):
64-
"""... With it's own internal state"""
98+
"""... With its own internal state"""
6599

66100
start, end = 10, 20
67101

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

89123

90124
class FallbackHandler(Handler):
91-
@staticmethod
92-
def check_range(request: int) -> Optional[bool]:
125+
"""
126+
Terminal handler for explicit end-of-chain fallback control.
127+
128+
Place at the end of a chain to define what happens when no concrete
129+
handler processes a request. Supports two modes set at construction:
130+
131+
mode="log" (default) — prints a warning and returns False.
132+
Useful during development and for monitoring.
133+
mode="strict" — raises ValueError. Use in production systems
134+
where an unhandled request is always a bug.
135+
136+
Real-world analogy — support ticket escalation:
137+
l3 = ConcreteHandler2(FallbackHandler(mode="strict"))
138+
l2 = ConcreteHandler1(l3)
139+
l1 = ConcreteHandler0(l2)
140+
l1.handle(ticket_priority) # raises ValueError if nobody claims it
141+
142+
To extend: subclass FallbackHandler and override handle_fallback() to add
143+
custom logic such as writing to a dead-letter queue or sending an alert.
144+
145+
Do not assign a successor to FallbackHandler — it is the terminal node.
146+
If a successor is set, handle_fallback() will never be called.
147+
"""
148+
149+
def __init__(self, mode: str = "log") -> None:
150+
super().__init__()
151+
if mode not in ("log", "strict"):
152+
raise ValueError(
153+
f"Invalid mode {mode!r}. Choose 'log' or 'strict'."
154+
)
155+
self.mode = mode
156+
157+
def check_range(self, request: int) -> Optional[bool]:
158+
return None # FallbackHandler never handles requests directly
159+
160+
def handle_fallback(self, request: int) -> bool:
161+
if self.mode == "strict":
162+
raise ValueError(f"No handler found for request {request}")
93163
print(f"end of chain, no handler for {request}")
94164
return False
95165

@@ -104,7 +174,7 @@ def main():
104174
105175
>>> requests = [2, 5, 14, 22, 18, 3, 35, 27, 20]
106176
>>> for request in requests:
107-
... h0.handle(request)
177+
... _ = h0.handle(request)
108178
request 2 handled in handler 0
109179
request 5 handled in handler 0
110180
request 14 handled in handler 1
@@ -114,6 +184,21 @@ def main():
114184
end of chain, no handler for 35
115185
request 27 handled in handler 2
116186
request 20 handled in handler 2
187+
188+
>>> # Strict mode raises ValueError for unhandled requests:
189+
>>> h_strict = ConcreteHandler0(FallbackHandler(mode="strict"))
190+
>>> h_strict.handle(5)
191+
request 5 handled in handler 0
192+
True
193+
>>> h_strict.handle(99)
194+
Traceback (most recent call last):
195+
...
196+
ValueError: No handler found for request 99
197+
198+
>>> # Chain without FallbackHandler returns False gracefully:
199+
>>> h_bare = ConcreteHandler0()
200+
>>> h_bare.handle(99)
201+
False
117202
"""
118203

119204

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import pytest
2+
from patterns.behavioral.chain_of_responsibility import (
3+
ConcreteHandler0,
4+
ConcreteHandler1,
5+
ConcreteHandler2,
6+
FallbackHandler,
7+
)
8+
9+
# These tests are written in TDD style — they currently fail because:
10+
# - Handler.handle() returns None (planned to return bool)
11+
# - FallbackHandler has no mode= parameter (planned in next task)
12+
# Failures are intentional. Subsequent tasks implement against these tests.
13+
14+
15+
def make_chain(fallback=None):
16+
"""Build h0 -> h1 -> h2 -> fallback (optional)."""
17+
h2 = ConcreteHandler2(fallback)
18+
h1 = ConcreteHandler1(h2)
19+
h0 = ConcreteHandler0(h1)
20+
return h0
21+
22+
23+
class TestHandlerRouting:
24+
"""handle() returns True when a concrete handler processes the request."""
25+
26+
def test_routes_to_handler0(self):
27+
assert make_chain().handle(5) is True
28+
29+
def test_routes_to_handler1(self):
30+
assert make_chain().handle(15) is True
31+
32+
def test_routes_to_handler2(self):
33+
assert make_chain().handle(25) is True
34+
35+
def test_boundary_value_handler0(self):
36+
assert make_chain().handle(0) is True # range is [0, 10)
37+
38+
def test_boundary_value_handler1(self):
39+
assert make_chain().handle(10) is True # range is [10, 20)
40+
41+
def test_boundary_value_handler2(self):
42+
assert make_chain().handle(20) is True # range is [20, 30)
43+
44+
45+
class TestChainWithoutExplicitFallback:
46+
"""When no FallbackHandler is chained, the base no-op fires — no crash."""
47+
48+
def test_returns_false_when_no_handler_matches(self):
49+
chain = make_chain() # No FallbackHandler
50+
assert chain.handle(99) is False
51+
52+
def test_does_not_raise(self):
53+
chain = make_chain()
54+
chain.handle(99) # must not raise
55+
56+
57+
class TestFallbackLogMode:
58+
"""FallbackHandler(mode='log') prints a warning and returns False."""
59+
60+
def test_returns_false(self):
61+
chain = make_chain(FallbackHandler(mode="log"))
62+
assert chain.handle(99) is False
63+
64+
def test_prints_warning(self, capsys):
65+
chain = make_chain(FallbackHandler(mode="log"))
66+
chain.handle(99)
67+
captured = capsys.readouterr()
68+
assert captured.out.strip() == "end of chain, no handler for 99"
69+
70+
def test_default_mode_is_log(self, capsys):
71+
chain = make_chain(FallbackHandler())
72+
result = chain.handle(99)
73+
captured = capsys.readouterr()
74+
assert result is False
75+
assert "no handler for 99" in captured.out
76+
77+
def test_does_not_raise(self):
78+
chain = make_chain(FallbackHandler(mode="log"))
79+
chain.handle(99) # must not raise, unlike strict mode
80+
81+
82+
class TestFallbackStrictMode:
83+
"""FallbackHandler(mode='strict') raises ValueError for unhandled requests."""
84+
85+
def test_raises_value_error(self):
86+
chain = make_chain(FallbackHandler(mode="strict"))
87+
with pytest.raises(ValueError, match="No handler found for request 99"):
88+
chain.handle(99)
89+
90+
def test_does_not_raise_for_handled_request(self):
91+
chain = make_chain(FallbackHandler(mode="strict"))
92+
assert chain.handle(5) is True # handled by ConcreteHandler0, strict never fires
93+
94+
95+
class TestFallbackHandlerValidation:
96+
"""FallbackHandler rejects unknown modes at construction time."""
97+
98+
def test_invalid_mode_raises(self):
99+
with pytest.raises(ValueError, match="Invalid mode"):
100+
FallbackHandler(mode="invalid")
101+
102+
def test_valid_modes_do_not_raise(self):
103+
FallbackHandler(mode="log")
104+
FallbackHandler(mode="strict")

0 commit comments

Comments
 (0)