Skip to content

Commit 10b9e8c

Browse files
lesnik512claude
andauthored
docs: interactive resilience demo pages (#109)
* docs: add resilience demo engine and circuit-breaker page * docs: make demo breaker OPEN-state results no-ops to match httpware * docs: gate circuit-breaker recovery callout on observed state * docs: show faithful-model disclaimer before interaction * docs: add retry + budget demo page * docs: make demo tour stops scenario-specific * docs: fix demo mount crash and align RetryBudget with budget.py * docs: add bulkhead demo page * docs: hide bulkhead pool stat by default and hold slot across retries * docs: add timeout demo page * docs: count breaker once per retry sequence, skip timed-out Move brk.res() from unconditional-per-landing to the terminal (non-retried) branch only, and skip it when the landing was cut short by AsyncTimeout. Per architecture/resilience.md the breaker sits outside retry and counts one outcome per fully-exhausted retry sequence, not per attempt; a deadline-cancelled operation surfaces as the outer TimeoutError and the inner breaker never sees a counted outcome for it. Only observable once a page combines circuitBreaker with retry/timeout (task 5's full-stack page); verified byte-for-byte no regression on circuit-breaker.md/retry.md/bulkhead.md/timeout.md. * docs: add full-stack compose demo page Composes all four resilience patterns (timeout, circuitBreaker, bulkhead, retry) on one page against a 16s multi-phase incident. Deviates from the drafted scenario in one number: hard-down fault latency 3.0s -> 0.3s. At 3.0s every hard-down attempt already exceeds the 2.0s AsyncTimeout on attempt 0, so (per the composition fix) the breaker never receives a real outcome and never trips -- the page's own third and fourth coach stops become unreachable. 0.3s lets real failures land and trip the breaker within the hard-down window; see .superpowers/sdd/task-5-report.md for the full numeric trace. * docs: add resilience demos landing page and cross-links Flesh out the demos index with links to all five pages and a faithful-model note, cross-link resilience.md to the demos, and correct the circuit-breaker comment describing how the async breaker handles an outer-timeout cancellation (it does see the CancelledError via except BaseException, it just doesn't count it as an outcome). * chore: gitignore .superpowers scratch (plans, ledger, brainstorm) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs: make demo latB reflect bounded latency and outage bar cover the spike phase latB was a hardcoded 40ms even on timeout/full-stack pages, contradicting elapsedB as it climbed toward the deadline. Derive it from the same bounded-latency measure as elapsedB when a timeout is configured; leave it untouched (40ms) elsewhere. computeOutageWindow only flagged hard failures, so full-stack's decorative outage bar silently dropped the 2-5s latency-spike phase. Also flag high-latency ok samples (ms >= 1.0, matching updateUI's existing `slow` threshold), falling back to a generic "degraded" label when a window mixes down and slow phases. * docs: distinguish demo dots by shape and de-dup breaker stat write * docs: defer demo mount() to DOMContentLoaded so engine.js loads first * docs: fix demo rendering and first-paint state from browser QA - inherit mkdocs-material font instead of a hardcoded system stack - fill the content column (drop 760px centering that left a stray margin) - keep the timeline outage label on one line (nowrap + vertical center) - plain-client p99 reflects the in-flight latency tail, so it stays high in a brownout instead of flickering to 40ms and contradicting the callout - derive the flow-diagram breaker/pool/elapsed boxes from the scenario chain and auto-select the first scenario, so first paint shows a coherent state Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 54848e2 commit 10b9e8c

12 files changed

Lines changed: 1051 additions & 0 deletions

File tree

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ wheels/
2424
.venv
2525
uv.lock
2626
site/
27+
.superpowers/

‎docs/demos/bulkhead.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Bulkhead
2+
3+
One slow dependency can sink a whole client: if every worker blocks on the slow call,
4+
fast calls starve behind them. A bulkhead caps concurrency to that dependency — excess
5+
calls fail fast with `BulkheadFullError` instead of piling up and exhausting the client.
6+
7+
<div class="hw-demo" id="bh-demo"></div>
8+
9+
<script>
10+
document.addEventListener('DOMContentLoaded', function () {
11+
HttpwareDemo.mount('#bh-demo', {
12+
scenarios: [
13+
{ id: 'slow', label: 'Dependency turns slow', dur: 12.5,
14+
fault: (now) => (now >= 2.0 && now < 9.0)
15+
? { ok: true, ms: 5.0, label: 'slow (5s)' } : { ok: true, ms: 0.05 },
16+
chainB: { bulkhead: { maxConcurrent: 8, acquireTimeout: 0 } } },
17+
],
18+
buildStops: () => [
19+
{ when: (s) => s.now >= 1.2, spot: ['ifA', 'poolB'], title: 'Fast calls, healthy pool',
20+
body: 'Both clients are humming. The httpware client has a bulkhead: at most 8 calls to this dependency at once.' },
21+
{ when: (s) => s.now >= 2.4, spot: ['ifA'], title: 'The dependency turns slow (5s)',
22+
body: 'Every call now takes 5s. The plain client has no cap — watch in-flight climb without limit as workers block.' },
23+
{ when: (s) => s.mw.rejected > 0, spot: ['poolB'], title: 'The bulkhead holds the line',
24+
body: 'The httpware pool fills to 8 and STOPS admitting more — excess calls fail fast instead of piling up. The client stays responsive for everything else.' },
25+
{ when: (s) => s.now >= 6.0, spot: ['ifA', 'poolB'], title: 'Bounded vs unbounded',
26+
body: 'Plain client: in-flight unbounded, whole client degraded. httpware: in-flight pinned at the pool size, blast radius contained to this one dependency.' },
27+
],
28+
});
29+
});
30+
</script>

‎docs/demos/circuit-breaker.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Circuit Breaker
2+
3+
When a backend goes down, a client without a breaker keeps sending every request
4+
into a slow timeout, piling up in-flight work until it exhausts itself. The breaker
5+
trips after repeated failures and **fast-fails** instead — keeping the client healthy
6+
and probing for recovery.
7+
8+
<div class="hw-demo" id="cb-demo"></div>
9+
10+
<script>
11+
document.addEventListener('DOMContentLoaded', function () {
12+
HttpwareDemo.mount('#cb-demo', {
13+
scenarios: [
14+
{ id: 'down', label: 'Backend goes down', dur: 12.5,
15+
fault: (now) => (now >= 2.0 && now < 8.0)
16+
? { ok: false, ms: 3.0, label: 'DOWN' } : { ok: true, ms: 0.04 },
17+
chainB: { circuitBreaker: { failureThreshold: 5, resetTimeout: 2.0, successThreshold: 1 } } },
18+
{ id: 'brownout', label: 'Brownout (40% errors)', dur: 12.5,
19+
fault: (now, rnd) => (now >= 2.0 && now < 9.0)
20+
? (rnd() < 0.4 ? { ok: false, ms: 3.0, label: 'erroring' } : { ok: true, ms: 0.04 })
21+
: { ok: true, ms: 0.04 },
22+
chainB: { circuitBreaker: { failureThreshold: 5, resetTimeout: 2.0, successThreshold: 1 } } },
23+
],
24+
buildStops: () => [
25+
{ when: (s) => s.now >= 1.2, spot: ['ifA', 'ifB'], title: 'Two clients, one backend',
26+
body: 'Both are healthy — in-flight near zero on each. The backend is about to die. Keep your eye on these two in-flight counters.' },
27+
{ when: (s) => s.now >= 2.35, spot: ['ifA'], title: 'Backend just went DOWN',
28+
body: 'Every request now hangs ~3s then fails. This plain client keeps sending — watch this number start to climb.' },
29+
{ when: (s) => s.mw.state === 'OPEN', spot: ['brkB', 'ifB'], title: 'The breaker tripped OPEN',
30+
body: '5 failures in a row -> circuit OPEN. It now fast-fails instantly; its in-flight stays flat while the plain client keeps piling up.' },
31+
{ when: (s) => s.now >= 5.6, spot: ['ifA', 'latA', 'ifB', 'latB'], title: 'The gap — this is the point',
32+
body: 'Plain client: in-flight high AND p99 blown to 12s — drowning. Protected client: in-flight flat, p99 still 40ms. Same outage, two outcomes.' },
33+
{ when: (s) => s.mw.recovered, spot: ['brkB'], title: 'Recovery via one probe',
34+
body: 'Backend is back. The breaker admits exactly ONE probe, sees success, and closes — no thundering herd.' },
35+
],
36+
});
37+
});
38+
</script>

‎docs/demos/demos.css‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/* httpware resilience demos — shared stylesheet.
2+
* Ported from the validated prototype (.superpowers/brainstorm/67614-1784395901/content/circuit-breaker-v6.html).
3+
* Palette follows the mkdocs-material theme where an equivalent token exists
4+
* (bg/fg/primary), falling back to the prototype's hex values otherwise.
5+
* The --hw-ok/--hw-bad/--hw-warn/--hw-reject semantic colors have no material
6+
* equivalent and are switched explicitly for the slate (dark) scheme.
7+
*/
8+
.hw-demo {
9+
--hw-bg: var(--md-default-bg-color, #fff);
10+
--hw-fg: var(--md-default-fg-color, #1b1b2e);
11+
--hw-muted: #6b6b80;
12+
--hw-line: #e4e4ee;
13+
--hw-accent: var(--md-primary-fg-color, #5b4bd6);
14+
--hw-ok: #12a150;
15+
--hw-bad: #e5484d;
16+
--hw-warn: #f5a623;
17+
--hw-reject: #8e8ea0;
18+
--hw-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
19+
position: relative;
20+
display: block;
21+
}
22+
[data-md-color-scheme="slate"] .hw-demo {
23+
--hw-muted: #9a9ab0;
24+
--hw-line: #2a2a3a;
25+
--hw-ok: #3dd68c;
26+
--hw-bad: #ff6369;
27+
--hw-warn: #ffb84d;
28+
--hw-reject: #6a6a80;
29+
}
30+
31+
.hw-demo, .hw-demo *, .hw-demo *::before, .hw-demo *::after { box-sizing: border-box; }
32+
33+
.hw-demo .hw-wrap {
34+
color: var(--hw-fg);
35+
font-family: inherit;
36+
line-height: 1.5;
37+
}
38+
.hw-demo h2 { font-size: 1.25rem; margin: 0 0 4px; }
39+
.hw-demo .intro { color: var(--hw-muted); margin: 0 0 18px; max-width: 600px; }
40+
41+
.hw-demo .ctl { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 8px; }
42+
.hw-demo .scenarios { display: flex; flex-wrap: wrap; gap: 8px; }
43+
.hw-demo .scenario-btn {
44+
background: transparent; color: var(--hw-fg); border: 1px solid var(--hw-line);
45+
border-radius: 8px; padding: 7px 14px; font-size: .85rem; cursor: pointer;
46+
}
47+
.hw-demo .scenario-btn.active { border-color: var(--hw-accent); color: var(--hw-accent); font-weight: 600; }
48+
.hw-demo .play {
49+
background: var(--hw-accent); color: #fff; border: 0; border-radius: 8px; padding: 9px 22px;
50+
font-size: .95rem; font-weight: 600; cursor: pointer;
51+
}
52+
.hw-demo .play:disabled { opacity: .5; cursor: not-allowed; }
53+
.hw-demo .play.ghost { background: transparent; color: var(--hw-muted); border: 1px solid var(--hw-line); }
54+
.hw-demo .scen { color: var(--hw-muted); font-size: .85rem; margin-bottom: 16px; }
55+
56+
.hw-demo .timeline {
57+
position: relative; height: 26px; border-radius: 8px; overflow: hidden; margin-bottom: 14px;
58+
background: var(--hw-bg); border: 1px solid var(--hw-line); font-family: var(--hw-mono); font-size: .68rem;
59+
}
60+
.hw-demo .outage {
61+
position: absolute; top: 0; bottom: 0; background: rgba(229, 72, 77, .18);
62+
border-left: 2px solid var(--hw-bad); border-right: 2px solid var(--hw-bad);
63+
}
64+
.hw-demo .outage span { position: absolute; top: 50%; transform: translateY(-50%); left: 8px; white-space: nowrap; color: var(--hw-bad); }
65+
.hw-demo .playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--hw-fg); left: 0; transition: left .15s linear; }
66+
67+
.hw-demo .lane {
68+
border: 2px solid var(--hw-line); border-radius: 12px; padding: 16px 18px; margin-bottom: 14px;
69+
transition: border-color .3s; position: relative;
70+
}
71+
.hw-demo .lane.hot { border-color: var(--hw-bad); }
72+
.hw-demo .lane.safe { border-color: var(--hw-ok); }
73+
.hw-demo .lane h3 { margin: 0 0 10px; font-size: .95rem; display: flex; align-items: center; gap: 8px; }
74+
.hw-demo .badge {
75+
font-family: var(--hw-mono); font-size: .66rem; padding: 2px 7px; border-radius: 5px;
76+
background: var(--hw-bg); border: 1px solid var(--hw-line); color: var(--hw-muted);
77+
}
78+
.hw-demo .badge.bad { color: var(--hw-bad); border-color: var(--hw-bad); }
79+
.hw-demo .badge.warn { color: var(--hw-warn); border-color: var(--hw-warn); }
80+
.hw-demo .badge.ok { color: var(--hw-ok); border-color: var(--hw-ok); }
81+
.hw-demo .laneflow { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
82+
.hw-demo .box {
83+
font-family: var(--hw-mono); font-size: .75rem; border: 1.5px solid var(--hw-line); border-radius: 8px;
84+
padding: 8px 10px; background: var(--hw-bg); white-space: nowrap; transition: all .25s;
85+
}
86+
.hw-demo .box.down { border-color: var(--hw-bad); background: rgba(229, 72, 77, .12); color: var(--hw-bad); }
87+
.hw-demo .box.open { border-color: var(--hw-bad); background: rgba(229, 72, 77, .12); color: var(--hw-bad); }
88+
.hw-demo .box.half { border-color: var(--hw-warn); background: rgba(245, 166, 35, .12); color: var(--hw-warn); }
89+
.hw-demo .arrow { flex: 1; height: 2px; background: var(--hw-line); }
90+
.hw-demo .track { position: relative; height: 20px; margin: 6px 0 4px; }
91+
.hw-demo .dot { position: absolute; top: 6px; width: 8px; height: 8px; border-radius: 50%; left: 0; transition: left .17s linear; }
92+
.hw-demo .dot.ok { background: var(--hw-ok); }
93+
.hw-demo .dot.bad { background: var(--hw-bad); border-radius: 0; }
94+
.hw-demo .dot.rej { background: transparent; border: 2px solid var(--hw-reject); }
95+
.hw-demo .score { display: flex; flex-wrap: wrap; gap: 6px 18px; font-family: var(--hw-mono); font-size: .82rem; margin-top: 6px; }
96+
.hw-demo .score .k { color: var(--hw-muted); }
97+
.hw-demo .stat { padding: 2px 6px; border-radius: 6px; }
98+
.hw-demo .inflight-big { font-size: 1.15rem; font-weight: 700; transition: color .3s; }
99+
.hw-demo .big { font-weight: 700; }
100+
.hw-demo .note { color: var(--hw-muted); font-size: .76rem; font-style: italic; margin-top: 6px; }
101+
102+
/* spotlight overlay: 4 dim panels leave a bright hole around the target(s) */
103+
.hw-demo .dim { position: fixed; background: rgba(8, 8, 16, .6); z-index: 40; display: none; }
104+
.hw-demo .dim.show { display: block; }
105+
.hw-demo .ring {
106+
position: fixed; z-index: 45; border: 2px solid var(--hw-accent); border-radius: 8px;
107+
box-shadow: 0 0 0 4px rgba(91, 75, 214, .28); pointer-events: none; display: none; transition: all .2s;
108+
}
109+
.hw-demo .ring.show { display: block; }
110+
.hw-demo .coach {
111+
position: fixed; z-index: 70; width: 300px; background: var(--hw-bg); border: 1px solid var(--hw-line);
112+
border-radius: 12px; padding: 15px 17px; box-shadow: 0 16px 50px rgba(0, 0, 0, .35); display: none;
113+
color: var(--hw-fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
114+
}
115+
.hw-demo .coach.show { display: block; }
116+
.hw-demo .coach .step {
117+
font-family: var(--hw-mono); font-size: .68rem; color: var(--hw-accent); text-transform: uppercase;
118+
letter-spacing: .06em; margin-bottom: 6px;
119+
}
120+
.hw-demo .coach h4 { margin: 0 0 6px; font-size: 1.02rem; }
121+
.hw-demo .coach p { margin: 0 0 14px; font-size: .9rem; }
122+
.hw-demo .coach .go {
123+
background: var(--hw-accent); color: #fff; border: 0; border-radius: 8px; padding: 8px 16px;
124+
font-weight: 600; cursor: pointer; font-size: .88rem;
125+
}
126+
.hw-demo .c-arrow {
127+
position: absolute; width: 13px; height: 13px; background: var(--hw-bg);
128+
border-left: 1px solid var(--hw-line); border-top: 1px solid var(--hw-line);
129+
}
130+
131+
@media (prefers-reduced-motion: reduce) {
132+
.hw-demo .playhead, .hw-demo .ring, .hw-demo .box, .hw-demo .lane, .hw-demo .dot { transition: none; }
133+
}

0 commit comments

Comments
 (0)