diff --git a/.gitignore b/.gitignore
index db79492..6802009 100644
--- a/.gitignore
+++ b/.gitignore
@@ -24,3 +24,4 @@ wheels/
.venv
uv.lock
site/
+.superpowers/
diff --git a/docs/demos/bulkhead.md b/docs/demos/bulkhead.md
new file mode 100644
index 0000000..18e0fe1
--- /dev/null
+++ b/docs/demos/bulkhead.md
@@ -0,0 +1,30 @@
+# Bulkhead
+
+One slow dependency can sink a whole client: if every worker blocks on the slow call,
+fast calls starve behind them. A bulkhead caps concurrency to that dependency — excess
+calls fail fast with `BulkheadFullError` instead of piling up and exhausting the client.
+
+
+
+
diff --git a/docs/demos/circuit-breaker.md b/docs/demos/circuit-breaker.md
new file mode 100644
index 0000000..cd98ebb
--- /dev/null
+++ b/docs/demos/circuit-breaker.md
@@ -0,0 +1,38 @@
+# Circuit Breaker
+
+When a backend goes down, a client without a breaker keeps sending every request
+into a slow timeout, piling up in-flight work until it exhausts itself. The breaker
+trips after repeated failures and **fast-fails** instead — keeping the client healthy
+and probing for recovery.
+
+
+
+
diff --git a/docs/demos/demos.css b/docs/demos/demos.css
new file mode 100644
index 0000000..27365c1
--- /dev/null
+++ b/docs/demos/demos.css
@@ -0,0 +1,133 @@
+/* httpware resilience demos — shared stylesheet.
+ * Ported from the validated prototype (.superpowers/brainstorm/67614-1784395901/content/circuit-breaker-v6.html).
+ * Palette follows the mkdocs-material theme where an equivalent token exists
+ * (bg/fg/primary), falling back to the prototype's hex values otherwise.
+ * The --hw-ok/--hw-bad/--hw-warn/--hw-reject semantic colors have no material
+ * equivalent and are switched explicitly for the slate (dark) scheme.
+ */
+.hw-demo {
+ --hw-bg: var(--md-default-bg-color, #fff);
+ --hw-fg: var(--md-default-fg-color, #1b1b2e);
+ --hw-muted: #6b6b80;
+ --hw-line: #e4e4ee;
+ --hw-accent: var(--md-primary-fg-color, #5b4bd6);
+ --hw-ok: #12a150;
+ --hw-bad: #e5484d;
+ --hw-warn: #f5a623;
+ --hw-reject: #8e8ea0;
+ --hw-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
+ position: relative;
+ display: block;
+}
+[data-md-color-scheme="slate"] .hw-demo {
+ --hw-muted: #9a9ab0;
+ --hw-line: #2a2a3a;
+ --hw-ok: #3dd68c;
+ --hw-bad: #ff6369;
+ --hw-warn: #ffb84d;
+ --hw-reject: #6a6a80;
+}
+
+.hw-demo, .hw-demo *, .hw-demo *::before, .hw-demo *::after { box-sizing: border-box; }
+
+.hw-demo .hw-wrap {
+ color: var(--hw-fg);
+ font-family: inherit;
+ line-height: 1.5;
+}
+.hw-demo h2 { font-size: 1.25rem; margin: 0 0 4px; }
+.hw-demo .intro { color: var(--hw-muted); margin: 0 0 18px; max-width: 600px; }
+
+.hw-demo .ctl { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 8px; }
+.hw-demo .scenarios { display: flex; flex-wrap: wrap; gap: 8px; }
+.hw-demo .scenario-btn {
+ background: transparent; color: var(--hw-fg); border: 1px solid var(--hw-line);
+ border-radius: 8px; padding: 7px 14px; font-size: .85rem; cursor: pointer;
+}
+.hw-demo .scenario-btn.active { border-color: var(--hw-accent); color: var(--hw-accent); font-weight: 600; }
+.hw-demo .play {
+ background: var(--hw-accent); color: #fff; border: 0; border-radius: 8px; padding: 9px 22px;
+ font-size: .95rem; font-weight: 600; cursor: pointer;
+}
+.hw-demo .play:disabled { opacity: .5; cursor: not-allowed; }
+.hw-demo .play.ghost { background: transparent; color: var(--hw-muted); border: 1px solid var(--hw-line); }
+.hw-demo .scen { color: var(--hw-muted); font-size: .85rem; margin-bottom: 16px; }
+
+.hw-demo .timeline {
+ position: relative; height: 26px; border-radius: 8px; overflow: hidden; margin-bottom: 14px;
+ background: var(--hw-bg); border: 1px solid var(--hw-line); font-family: var(--hw-mono); font-size: .68rem;
+}
+.hw-demo .outage {
+ position: absolute; top: 0; bottom: 0; background: rgba(229, 72, 77, .18);
+ border-left: 2px solid var(--hw-bad); border-right: 2px solid var(--hw-bad);
+}
+.hw-demo .outage span { position: absolute; top: 50%; transform: translateY(-50%); left: 8px; white-space: nowrap; color: var(--hw-bad); }
+.hw-demo .playhead { position: absolute; top: 0; bottom: 0; width: 2px; background: var(--hw-fg); left: 0; transition: left .15s linear; }
+
+.hw-demo .lane {
+ border: 2px solid var(--hw-line); border-radius: 12px; padding: 16px 18px; margin-bottom: 14px;
+ transition: border-color .3s; position: relative;
+}
+.hw-demo .lane.hot { border-color: var(--hw-bad); }
+.hw-demo .lane.safe { border-color: var(--hw-ok); }
+.hw-demo .lane h3 { margin: 0 0 10px; font-size: .95rem; display: flex; align-items: center; gap: 8px; }
+.hw-demo .badge {
+ font-family: var(--hw-mono); font-size: .66rem; padding: 2px 7px; border-radius: 5px;
+ background: var(--hw-bg); border: 1px solid var(--hw-line); color: var(--hw-muted);
+}
+.hw-demo .badge.bad { color: var(--hw-bad); border-color: var(--hw-bad); }
+.hw-demo .badge.warn { color: var(--hw-warn); border-color: var(--hw-warn); }
+.hw-demo .badge.ok { color: var(--hw-ok); border-color: var(--hw-ok); }
+.hw-demo .laneflow { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
+.hw-demo .box {
+ font-family: var(--hw-mono); font-size: .75rem; border: 1.5px solid var(--hw-line); border-radius: 8px;
+ padding: 8px 10px; background: var(--hw-bg); white-space: nowrap; transition: all .25s;
+}
+.hw-demo .box.down { border-color: var(--hw-bad); background: rgba(229, 72, 77, .12); color: var(--hw-bad); }
+.hw-demo .box.open { border-color: var(--hw-bad); background: rgba(229, 72, 77, .12); color: var(--hw-bad); }
+.hw-demo .box.half { border-color: var(--hw-warn); background: rgba(245, 166, 35, .12); color: var(--hw-warn); }
+.hw-demo .arrow { flex: 1; height: 2px; background: var(--hw-line); }
+.hw-demo .track { position: relative; height: 20px; margin: 6px 0 4px; }
+.hw-demo .dot { position: absolute; top: 6px; width: 8px; height: 8px; border-radius: 50%; left: 0; transition: left .17s linear; }
+.hw-demo .dot.ok { background: var(--hw-ok); }
+.hw-demo .dot.bad { background: var(--hw-bad); border-radius: 0; }
+.hw-demo .dot.rej { background: transparent; border: 2px solid var(--hw-reject); }
+.hw-demo .score { display: flex; flex-wrap: wrap; gap: 6px 18px; font-family: var(--hw-mono); font-size: .82rem; margin-top: 6px; }
+.hw-demo .score .k { color: var(--hw-muted); }
+.hw-demo .stat { padding: 2px 6px; border-radius: 6px; }
+.hw-demo .inflight-big { font-size: 1.15rem; font-weight: 700; transition: color .3s; }
+.hw-demo .big { font-weight: 700; }
+.hw-demo .note { color: var(--hw-muted); font-size: .76rem; font-style: italic; margin-top: 6px; }
+
+/* spotlight overlay: 4 dim panels leave a bright hole around the target(s) */
+.hw-demo .dim { position: fixed; background: rgba(8, 8, 16, .6); z-index: 40; display: none; }
+.hw-demo .dim.show { display: block; }
+.hw-demo .ring {
+ position: fixed; z-index: 45; border: 2px solid var(--hw-accent); border-radius: 8px;
+ box-shadow: 0 0 0 4px rgba(91, 75, 214, .28); pointer-events: none; display: none; transition: all .2s;
+}
+.hw-demo .ring.show { display: block; }
+.hw-demo .coach {
+ position: fixed; z-index: 70; width: 300px; background: var(--hw-bg); border: 1px solid var(--hw-line);
+ border-radius: 12px; padding: 15px 17px; box-shadow: 0 16px 50px rgba(0, 0, 0, .35); display: none;
+ color: var(--hw-fg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+}
+.hw-demo .coach.show { display: block; }
+.hw-demo .coach .step {
+ font-family: var(--hw-mono); font-size: .68rem; color: var(--hw-accent); text-transform: uppercase;
+ letter-spacing: .06em; margin-bottom: 6px;
+}
+.hw-demo .coach h4 { margin: 0 0 6px; font-size: 1.02rem; }
+.hw-demo .coach p { margin: 0 0 14px; font-size: .9rem; }
+.hw-demo .coach .go {
+ background: var(--hw-accent); color: #fff; border: 0; border-radius: 8px; padding: 8px 16px;
+ font-weight: 600; cursor: pointer; font-size: .88rem;
+}
+.hw-demo .c-arrow {
+ position: absolute; width: 13px; height: 13px; background: var(--hw-bg);
+ border-left: 1px solid var(--hw-line); border-top: 1px solid var(--hw-line);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .hw-demo .playhead, .hw-demo .ring, .hw-demo .box, .hw-demo .lane, .hw-demo .dot { transition: none; }
+}
diff --git a/docs/demos/engine.js b/docs/demos/engine.js
new file mode 100644
index 0000000..d5a764d
--- /dev/null
+++ b/docs/demos/engine.js
@@ -0,0 +1,616 @@
+/* httpware resilience demos — shared engine.
+ *
+ * FIDELITY: these models mirror httpware's real semantics. The source of truth
+ * for every default is architecture/resilience.md. Behavioral constants below
+ * match the library; TIME constants marked (demo) are compressed so an outage
+ * fits a ~12s watchable timeline — semantics (ordering, thresholds, accounting)
+ * are unchanged. Keep this block the single place to check for drift.
+ *
+ * CircuitBreaker note: OPEN-state landing results are no-ops — real httpware's
+ * on_failure/on_success only count outcomes in CLOSED (and the HALF_OPEN probe).
+ */
+window.HttpwareDemo = (function () {
+ const REAL = {
+ circuitBreaker: { failureThreshold: 5, resetTimeout: 30.0, successThreshold: 1,
+ windowSeconds: 30.0, minimumCalls: 20 }, // resetTimeout->2s (demo)
+ retry: { maxAttempts: 3, baseDelay: 0.1, maxDelay: 5.0 },
+ retryBudget: { ttl: 10.0, minRetriesPerSec: 10.0, percentCanRetry: 0.2 },
+ bulkhead: { acquireTimeout: 1.0 }, // max_concurrent has no default
+ timeout: {},
+ };
+ const TICK = 170, ADV = 11; // ms per tick; dot advance %/tick
+ const RPS = 12; // simulated requests/sec — cosmetic pacing, not a fidelity constant
+ // Fixed seed: every run/replay traces the identical fault timeline. Picked (not
+ // arbitrary) because it is one of the minority of seeds where a 40%-probability
+ // brownout still produces 5 consecutive landed failures within its window — most
+ // seeds never trip the breaker at all, which would strand the guided tour before
+ // its OPEN-state stop.
+ const SEED = 103;
+
+ // seeded PRNG so both lanes face the identical fault timeline
+ function mulberry(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0;
+ let t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
+ return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
+
+ // ---- pattern models (faithful) ----
+ // OPEN-state landing results are no-ops — real httpware's on_failure/on_success
+ // only count outcomes in CLOSED (and the HALF_OPEN probe); a pre-trip in-flight
+ // request that lands after the circuit has already opened must not touch
+ // fails/openedAt, or a draining backlog keeps re-arming the reset clock.
+ function makeCircuitBreaker(cfg) {
+ const T = cfg.failureThreshold, R = cfg.resetTimeout, S = cfg.successThreshold;
+ // `recovered` is a read-only observability flag (no effect on allow/res logic):
+ // it flips true exactly when the circuit genuinely reaches CLOSED via a
+ // successful probe, so callers can gate on real recovery instead of a guessed
+ // timestamp — compressed demo timing can push recovery later than expected.
+ return { state: 'CLOSED', fails: 0, succ: 0, openedAt: 0, probe: false, recovered: false,
+ allow(now) {
+ if (this.state === 'OPEN') {
+ if (now - this.openedAt >= R) { this.state = 'HALF_OPEN'; this.probe = false; }
+ else return false;
+ }
+ if (this.state === 'HALF_OPEN') { if (this.probe) return false; this.probe = true; return true; }
+ return true;
+ },
+ res(ok, now) {
+ if (this.state === 'HALF_OPEN') { this.probe = false;
+ if (ok) { this.succ++; if (this.succ >= S) { this.state = 'CLOSED'; this.fails = 0; this.succ = 0; this.recovered = true; } }
+ else { this.state = 'OPEN'; this.openedAt = now; this.fails = 1; this.succ = 0; }
+ return;
+ }
+ if (this.state === 'OPEN') return; // late-landing in-flight result: no-op (matches on_failure/on_success)
+ if (ok) this.fails = 0;
+ else { this.fails++; if (this.fails >= T) { this.state = 'OPEN'; this.openedAt = now; } }
+ } };
+ }
+
+ // Finagle-style token bucket: caps retries to a fraction of traffic, with a floor.
+ // Byte-faithful port of budget.py's RetryBudget: both deposits AND withdrawals are
+ // timestamp windows purged by ttl (so spent capacity ages out and recovers over
+ // time, not a permanent ratchet); ceiling = ceil(deposits * pct) + floor (floor =
+ // int(minRetriesPerSec * ttl)); withdraw permitted while withdrawn.length < ceiling.
+ function makeRetryBudget(cfg) {
+ const ttl = cfg.ttl, floor = Math.floor(cfg.minRetriesPerSec * ttl), pct = cfg.percentCanRetry;
+ let deposits = [], withdrawn = []; // timestamps within the [now - ttl, now] window
+ function purge(now) { const cut = now - ttl;
+ deposits = deposits.filter((t) => t >= cut); withdrawn = withdrawn.filter((t) => t >= cut); }
+ return {
+ deposit(now) { purge(now); deposits.push(now); },
+ tryWithdraw(now) { purge(now);
+ const ceiling = Math.ceil(deposits.length * pct) + floor;
+ if (withdrawn.length >= ceiling) return false;
+ withdrawn.push(now); return true; },
+ };
+ }
+
+ // Semaphore concurrency limiter (bulkhead.py). Admits up to maxConcurrent in-flight;
+ // a request that can't get a slot within acquireTimeout fails fast (BulkheadFullError).
+ // The demo models the common case acquireTimeout->0 (reject immediately when full) —
+ // real httpware's default is acquireTimeout=1.0 (bounded wait), not modeled here.
+ function makeBulkhead(cfg) {
+ return { max: cfg.maxConcurrent, inUse: 0,
+ tryAcquire() { if (this.inUse < this.max) { this.inUse++; return true; } return false; },
+ release() { if (this.inUse > 0) this.inUse--; } };
+ }
+
+ // AsyncTimeout bounds the WHOLE operation (all retry attempts + backoff sleeps),
+ // not a single call — so a lane-B pend entry carries one deadline set at the
+ // first attempt and clamps every subsequent (re)push to it. A landing whose
+ // projected time would cross the deadline is cut short here and marked
+ // timedOut so the caller resolves it as a TimeoutError failure, never a retry.
+ function clampToDeadline(tick, landTicks, deadline) {
+ const land = tick + landTicks;
+ if (deadline === Infinity || land * TICK / 1000 <= deadline) return { land, timedOut: false };
+ return { land: Math.max(tick + 1, Math.round(deadline * 1000 / TICK)), timedOut: true };
+ }
+
+ const prefersReduced = window.matchMedia &&
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
+
+ const MW_LABELS = { circuitBreaker: 'CircuitBreaker', retry: 'Retry', budget: 'RetryBudget', bulkhead: 'Bulkhead', timeout: 'Timeout' };
+
+ function esc(s) {
+ return String(s).replace(/[&<>"']/g, (c) => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
+ }[c]));
+ }
+
+ function chainLabel(chainB) {
+ const keys = chainB ? Object.keys(chainB) : [];
+ return keys.length ? keys.map((k) => MW_LABELS[k] || k).join(' + ') : 'no protection';
+ }
+
+ // Human-readable "what's actually protecting lane B" note, cross-checked against REAL.
+ function describeChain(chainB) {
+ if (!chainB || !Object.keys(chainB).length) return 'no middleware';
+ const parts = [];
+ if (chainB.circuitBreaker) {
+ const c = chainB.circuitBreaker, r = REAL.circuitBreaker;
+ const resetNote = c.resetTimeout !== r.resetTimeout
+ ? `reset_timeout=${c.resetTimeout}s (demo; real default ${r.resetTimeout}s)`
+ : `reset_timeout=${c.resetTimeout}s`;
+ parts.push(`CircuitBreaker(failure_threshold=${c.failureThreshold}, ${resetNote}, success_threshold=${c.successThreshold})`);
+ }
+ if (chainB.retry) {
+ const t = chainB.retry;
+ parts.push(`Retry(max_attempts=${t.maxAttempts}, base_delay=${t.baseDelay}s, max_delay=${t.maxDelay}s)`);
+ }
+ if (chainB.budget) {
+ const b = chainB.budget;
+ parts.push(`RetryBudget(ttl=${b.ttl}s, min_retries_per_sec=${b.minRetriesPerSec}, percent_can_retry=${b.percentCanRetry})`);
+ }
+ return parts.join(', ');
+ }
+
+ function formatMs(ms) {
+ return ms >= 1 ? `${ms}s` : `${Math.round(ms * 1000)}ms`;
+ }
+
+ // Sample the fault function (worst-case rnd, so probabilistic branches always fire)
+ // to find the outage window for the decorative timeline bar. Cosmetic only — it
+ // never drives the sim; per-request outcomes always come from a real fault() call.
+ function computeOutageWindow(scenario) {
+ const steps = 200;
+ const worstRnd = () => 0;
+ let from = null, to = null, label = '';
+ let sawDown = false, sawSlow = false;
+ for (let i = 0; i <= steps; i++) {
+ const t = (scenario.dur * i) / steps;
+ const f = scenario.fault(t, worstRnd);
+ // High-latency-but-ok samples are stress too (matches the `slow` threshold
+ // updateUI already uses) — otherwise a latency-spike phase (ok:true, high ms)
+ // is invisible on this bar even though it's exactly the kind of backend
+ // trouble the guided tour calls out (e.g. full-stack's phase 1).
+ const down = !f.ok;
+ const slow = f.ok && f.ms >= 1.0;
+ if (down || slow) {
+ if (from === null) from = t;
+ to = t;
+ if (down) sawDown = true;
+ if (slow) sawSlow = true;
+ if (!label) label = down ? (f.label || 'DOWN') : (f.label || 'degraded');
+ }
+ }
+ if (from === null) return null;
+ // A window that mixes down and slow phases (e.g. full-stack's multi-phase
+ // incident) can't be summed up by one phase's label without misleading about
+ // the other — fall back to a generic label. Decorative only, never drives the sim.
+ if (sawDown && sawSlow) label = 'degraded';
+ return { from, to: Math.min(scenario.dur, to + scenario.dur / steps), label };
+ }
+
+ function template(config) {
+ const scenarioBtns = config.scenarios.map((sc) =>
+ ``
+ ).join('');
+ const title = config.title || 'Guided walk-through';
+ const intro = config.intro || 'The animation freezes at each beat and the callout appears in the ' +
+ 'empty space beside what you should watch (never on top of it), with an arrow pointing at it.';
+ return `
+
Faithful model of httpware, not httpware running in your browser. Pick a scenario above to begin.
+
+
+
+
+
+
+
+
Step 1 of 1
+
+
+
`;
+ }
+
+ function mount(selector, config) {
+ const container = document.querySelector(selector);
+ if (!container) return;
+ container.classList.add('hw-demo');
+ container.innerHTML = template(config);
+
+ const $ = (key) => container.querySelector('[data-el="' + key + '"]');
+ const els = {
+ scenarios: $('scenarios'), play: $('play'), replay: $('replay'), scenLabel: $('scenLabel'),
+ timeline: $('timeline'), outage: $('outage'), outageLabel: $('outageLabel'), playhead: $('playhead'),
+ laneA: $('laneA'), badgeA: $('badgeA'), srvA: $('srvA'), trackA: $('trackA'),
+ ifA: $('ifA'), okA: $('okA'), badA: $('badA'), latA: $('latA'),
+ laneB: $('laneB'), badgeB: $('badgeB'), srvB: $('srvB'), trackB: $('trackB'), brkB: $('brkB'),
+ ifB: $('ifB'), okB: $('okB'), badB: $('badB'), rejB: $('rejB'), latB: $('latB'),
+ poolWrap: $('poolWrap'), poolB: $('poolB'),
+ elapsedWrap: $('elapsedWrap'), elapsedB: $('elapsedB'),
+ note: $('note'),
+ dimT: $('dimT'), dimB: $('dimB'), dimL: $('dimL'), dimR: $('dimR'), ring: $('ring'),
+ coach: $('coach'), cArrow: $('cArrow'), cStep: $('cStep'), cTitle: $('cTitle'), cBody: $('cBody'), cGo: $('cGo'),
+ };
+ const ELS = { ifA: els.ifA, latA: els.latA, ifB: els.ifB, latB: els.latB, brkB: els.brkB, poolB: els.poolB, elapsedB: els.elapsedB };
+
+ // Built fresh inside run() from the selected scenario (STOPS is never read before a
+ // scenario is played, so it's safe to leave empty until then) — pages with one
+ // shared stop list across scenarios (buildStops ignoring its arg) keep working
+ // unchanged. Must NOT call config.buildStops() here at mount time: retry.md's
+ // buildStops dereferences its scenario argument, so calling it with no scenario
+ // selected yet would throw and abort mount() before the Play/scenario listeners
+ // are wired up.
+ let STOPS = [];
+
+ let timer = null, paused = false, tick = 0, stopIdx = 0;
+ let dotsA = [], dotsB = [];
+ let A = { ok: 0, bad: 0, if: 0, pend: [] };
+ let B = { ok: 0, bad: 0, rej: 0, if: 0, pend: [] };
+ let brk = null;
+ let bulk = null;
+ let retryCfg = null, budget = null, budgetExhausted = false;
+ let tmoCfg = null, timedOutCount = 0;
+ let rnd = mulberry(SEED);
+ let selectedScenario = null;
+
+ function spawnDot(arr, track, kind) {
+ const node = document.createElement('div');
+ node.className = 'dot ' + kind;
+ track.appendChild(node);
+ if (prefersReduced) {
+ node.style.transition = 'none';
+ node.style.left = '96%';
+ setTimeout(() => node.remove(), 200);
+ return;
+ }
+ arr.push({ node, x: 0 });
+ }
+ function advDots(arr) {
+ if (prefersReduced) return;
+ for (let i = arr.length - 1; i >= 0; i--) {
+ const d = arr[i];
+ d.x += ADV;
+ if (d.x >= 96) { d.node.style.opacity = '0'; setTimeout(() => d.node.remove(), 200); arr.splice(i, 1); }
+ else d.node.style.left = d.x + '%';
+ }
+ }
+
+ // ---- spotlight: 4 dim panels leave a bright hole over the union of targets ----
+ function spotlight(nodes) {
+ const rs = nodes.map((n) => n.getBoundingClientRect());
+ const pad = 6;
+ const x0 = Math.min(...rs.map((r) => r.left)) - pad, y0 = Math.min(...rs.map((r) => r.top)) - pad;
+ const x1 = Math.max(...rs.map((r) => r.right)) + pad, y1 = Math.max(...rs.map((r) => r.bottom)) + pad;
+ const W = window.innerWidth, H = window.innerHeight;
+ const set = (node, l, t, w, h) => {
+ node.style.left = l + 'px'; node.style.top = t + 'px';
+ node.style.width = Math.max(0, w) + 'px'; node.style.height = Math.max(0, h) + 'px';
+ node.classList.add('show');
+ };
+ set(els.dimT, 0, 0, W, y0);
+ set(els.dimB, 0, y1, W, H - y1);
+ set(els.dimL, 0, y0, x0, y1 - y0);
+ set(els.dimR, x1, y0, W - x1, y1 - y0);
+ els.ring.style.left = x0 + 'px'; els.ring.style.top = y0 + 'px';
+ els.ring.style.width = (x1 - x0) + 'px'; els.ring.style.height = (y1 - y0) + 'px';
+ els.ring.classList.add('show');
+ return { x0, y0, x1, y1 };
+ }
+ function hideSpot() {
+ [els.dimT, els.dimB, els.dimL, els.dimR, els.ring].forEach((n) => n.classList.remove('show'));
+ }
+
+ // ---- place coach on the side with the most room; arrow points at target ----
+ function placeCoach(hole) {
+ const c = els.coach;
+ c.classList.add('show');
+ const cw = c.offsetWidth, ch = c.offsetHeight, gap = 16;
+ const W = window.innerWidth, H = window.innerHeight, cy = (hole.y0 + hole.y1) / 2, cx = (hole.x0 + hole.x1) / 2;
+ const room = { right: W - hole.x1, left: hole.x0, bottom: H - hole.y1, top: hole.y0 };
+ let side, left, top;
+ if (room.right >= cw + gap) { side = 'right'; left = hole.x1 + gap; top = cy - ch / 2; }
+ else if (room.left >= cw + gap) { side = 'left'; left = hole.x0 - gap - cw; top = cy - ch / 2; }
+ else if (room.bottom >= ch + gap) { side = 'bottom'; top = hole.y1 + gap; left = cx - cw / 2; }
+ else { side = 'top'; top = hole.y0 - gap - ch; left = cx - cw / 2; }
+ left = Math.min(Math.max(10, left), W - cw - 10);
+ top = Math.min(Math.max(10, top), H - ch - 10);
+ c.style.left = left + 'px'; c.style.top = top + 'px';
+ const a = els.cArrow;
+ a.style.transform = 'rotate(45deg)';
+ if (side === 'right') { a.style.left = '-7px'; a.style.top = (cy - top - 6) + 'px'; a.style.borderTop = 'none'; a.style.borderRight = 'none'; a.style.borderLeft = '1px solid var(--hw-line)'; a.style.borderBottom = '1px solid var(--hw-line)'; }
+ else if (side === 'left') { a.style.left = (cw - 7) + 'px'; a.style.top = (cy - top - 6) + 'px'; a.style.borderBottom = 'none'; a.style.borderLeft = 'none'; a.style.borderTop = '1px solid var(--hw-line)'; a.style.borderRight = '1px solid var(--hw-line)'; }
+ else if (side === 'bottom') { a.style.top = '-7px'; a.style.left = (cx - left - 6) + 'px'; a.style.borderBottom = 'none'; a.style.borderRight = 'none'; }
+ else { a.style.top = (ch - 7) + 'px'; a.style.left = (cx - left - 6) + 'px'; a.style.borderTop = 'none'; a.style.borderLeft = 'none'; }
+ }
+
+ function showStop(s) {
+ paused = true;
+ const nodes = s.spot.map((key) => ELS[key]).filter(Boolean);
+ const hole = spotlight(nodes);
+ els.cStep.textContent = `Step ${stopIdx + 1} of ${STOPS.length}`;
+ els.cTitle.textContent = s.title;
+ els.cBody.textContent = s.body;
+ placeCoach(hole);
+ }
+ els.cGo.addEventListener('click', () => {
+ els.coach.classList.remove('show');
+ hideSpot();
+ paused = false;
+ stopIdx++;
+ });
+
+ function setupOutageBar(scenario) {
+ const win = computeOutageWindow(scenario);
+ if (win) {
+ els.outage.style.display = '';
+ els.outage.style.left = (win.from / scenario.dur * 100) + '%';
+ els.outage.style.width = ((win.to - win.from) / scenario.dur * 100) + '%';
+ els.outageLabel.textContent = 'backend ' + win.label;
+ } else {
+ els.outage.style.display = 'none';
+ }
+ }
+
+ function resetVisual() {
+ clearInterval(timer);
+ els.trackA.innerHTML = ''; els.trackB.innerHTML = '';
+ dotsA = []; dotsB = [];
+ tick = 0; stopIdx = 0; paused = false;
+ A = { ok: 0, bad: 0, if: 0, pend: [] };
+ B = { ok: 0, bad: 0, rej: 0, if: 0, pend: [] };
+ els.ifA.textContent = '0'; els.okA.textContent = '0'; els.badA.textContent = '0';
+ els.ifB.textContent = '0'; els.okB.textContent = '0'; els.badB.textContent = '0'; els.rejB.textContent = '0';
+ els.latA.textContent = '40ms'; els.latB.textContent = '40ms';
+ [els.latA, els.latB, els.ifA, els.ifB].forEach((n) => { n.style.color = ''; });
+ // Flow-diagram boxes reflect the selected scenario's CHAIN config, not the runtime
+ // middleware objects (which aren't constructed until run()). Otherwise the breaker
+ // box reads "no breaker" on a circuit-breaker page until the first Play.
+ const cfgB = selectedScenario ? selectedScenario.chainB : {};
+ els.brkB.className = 'box'; els.brkB.textContent = cfgB.circuitBreaker ? 'breaker CLOSED' : 'no breaker';
+ els.poolWrap.style.display = cfgB.bulkhead ? '' : 'none';
+ els.poolB.textContent = cfgB.bulkhead ? ('0/' + cfgB.bulkhead.maxConcurrent) : '—';
+ els.elapsedWrap.style.display = cfgB.timeout ? '' : 'none';
+ els.elapsedB.textContent = cfgB.timeout ? ('0.0s / ' + cfgB.timeout.timeout.toFixed(1) + 's') : '—';
+ els.srvA.className = 'box'; els.srvA.textContent = 'server ✓';
+ els.srvB.className = 'box'; els.srvB.textContent = 'server ✓';
+ els.laneA.className = 'lane'; els.laneB.className = 'lane';
+ els.playhead.style.left = '0%';
+ els.badgeA.className = 'badge'; els.badgeA.textContent = 'no protection';
+ els.badgeB.className = 'badge'; els.badgeB.textContent = selectedScenario ? chainLabel(selectedScenario.chainB) : 'CircuitBreaker';
+ els.coach.classList.remove('show'); hideSpot();
+ }
+
+ function updateUI(now, lastFault) {
+ els.ifA.textContent = A.if; els.okA.textContent = A.ok; els.badA.textContent = A.bad;
+ els.ifB.textContent = B.if; els.okB.textContent = B.ok; els.badB.textContent = B.bad; els.rejB.textContent = B.rej;
+ els.ifA.style.color = A.if > 10 ? 'var(--hw-bad)' : '';
+ els.ifB.style.color = B.if <= 6 ? 'var(--hw-ok)' : '';
+ if (bulk) els.poolB.textContent = bulk.inUse + '/' + bulk.max;
+ let maxElapsed = 0;
+ if (tmoCfg) {
+ maxElapsed = B.pend.reduce((m, p) => Math.max(m, now - p.start), 0);
+ els.elapsedB.textContent = maxElapsed.toFixed(1) + 's / ' + tmoCfg.timeout.toFixed(1) + 's';
+ }
+ // "Stress" covers two distinct fault shapes: outright failure (down) and a
+ // high-latency-but-ok fault (slow, e.g. bulkhead.md's 5s dependency) — both must
+ // read as backend trouble even though only `down` implies an error response.
+ const down = !!(lastFault && !lastFault.ok);
+ const slow = !!(lastFault && lastFault.ok && lastFault.ms >= 1.0);
+ const stressed = down || slow;
+ // latA is the plain client's p99: reflect the ACTUAL in-flight latency tail, not
+ // the last request. In a brownout (mixed fast successes + slow failures) the last
+ // sample flickers, so a last-request p99 drops to 40ms whenever the last request
+ // was a fast success — contradicting the "p99 blown" narration while slow requests
+ // are still piled up in flight. The in-flight max is stable and honest.
+ const aTailMs = A.pend.length ? A.pend.reduce((m, p) => Math.max(m, p.ms), 0) : 0.04;
+ const plainStress = aTailMs >= 1.0 || A.if > 4;
+ els.latA.textContent = plainStress ? (A.if > 4 ? '12s+' : formatMs(aTailMs)) : '40ms';
+ els.latA.style.color = plainStress ? 'var(--hw-bad)' : '';
+ // On timeout pages, AsyncTimeout bounds every lane-B attempt at the deadline —
+ // p99 must reflect that ACTUAL bounded latency, not the constant 40ms, or it
+ // visually contradicts elapsedB as it climbs toward the same deadline. Pages
+ // with no timeout configured keep the untouched 40ms constant.
+ els.latB.textContent = tmoCfg ? formatMs(Math.min(maxElapsed, tmoCfg.timeout)) : '40ms';
+ els.latB.style.color = (down && brk && brk.state === 'OPEN') ? 'var(--hw-ok)' : '';
+ const st = brk ? brk.state : 'CLOSED';
+ els.brkB.className = 'box ' + (st === 'OPEN' ? 'open' : st === 'HALF_OPEN' ? 'half' : '');
+ els.brkB.textContent = brk ? ('breaker ' + st) : 'no breaker';
+ els.srvA.className = 'box' + (stressed ? ' down' : '');
+ els.srvA.textContent = down ? ('server ✗' + (lastFault.label ? ' ' + lastFault.label : ''))
+ : slow ? ('server ' + (lastFault.label || 'slow')) : 'server ✓';
+ els.srvB.className = 'box' + (stressed ? ' down' : '');
+ els.srvB.textContent = down ? 'server ✗' : slow ? 'server slow' : 'server ✓';
+ els.laneA.classList.toggle('hot', plainStress && A.if > 8);
+ els.laneB.classList.toggle('safe', st === 'CLOSED' && !stressed);
+ els.badgeA.className = 'badge' + (plainStress ? ' bad' : '');
+ els.badgeA.textContent = plainStress ? 'drowning' : 'no protection';
+ const okBadge = st === 'CLOSED' && brk && !stressed;
+ els.badgeB.className = 'badge ' + (st === 'OPEN' ? 'warn' : okBadge ? 'ok' : '');
+ els.badgeB.textContent = st === 'OPEN' ? 'fast-failing' : st === 'HALF_OPEN' ? 'probing' : chainLabel(selectedScenario.chainB);
+ }
+
+ function run() {
+ if (!selectedScenario) return;
+ const scenario = selectedScenario;
+ STOPS = config.buildStops(scenario);
+ brk = scenario.chainB && scenario.chainB.circuitBreaker
+ ? makeCircuitBreaker(scenario.chainB.circuitBreaker) : null;
+ bulk = scenario.chainB && scenario.chainB.bulkhead
+ ? makeBulkhead(scenario.chainB.bulkhead) : null;
+ retryCfg = scenario.chainB && scenario.chainB.retry ? scenario.chainB.retry : null;
+ // retry.py: Retry(budget=None) defaults to RetryBudget() — a retry config with no
+ // explicit budget must still retry (real defaults), never silently go unbudgeted.
+ budget = retryCfg
+ ? makeRetryBudget(scenario.chainB.budget || REAL.retryBudget)
+ : null;
+ budgetExhausted = false;
+ tmoCfg = scenario.chainB && scenario.chainB.timeout ? scenario.chainB.timeout : null;
+ timedOutCount = 0;
+ rnd = mulberry(SEED);
+ resetVisual();
+
+ timer = setInterval(() => {
+ if (paused) return;
+ const now = tick * TICK / 1000;
+ const mw = { state: brk ? brk.state : 'CLOSED', recovered: brk ? brk.recovered : false,
+ budgetExhausted, rejected: B.rej, cb: brk, timedOut: timedOutCount };
+ const state = { now, A, B, mw };
+ if (stopIdx < STOPS.length && STOPS[stopIdx].when(state)) { showStop(STOPS[stopIdx]); return; }
+
+ advDots(dotsA); advDots(dotsB);
+ els.playhead.style.left = (Math.min(now, scenario.dur) / scenario.dur * 100) + '%';
+
+ for (const L of [A, B]) {
+ for (let i = L.pend.length - 1; i >= 0; i--) {
+ if (L.pend[i].land <= tick) {
+ const p = L.pend[i]; L.pend.splice(i, 1);
+ let retried = false;
+ // AsyncTimeout bounds the WHOLE operation (outermost: timeout ->
+ // circuitBreaker -> bulkhead -> retry -> terminal). An entry clamped
+ // to its deadline (see clampToDeadline) lands as a TimeoutError
+ // failure here, regardless of what the untimed outcome would have
+ // been, and is never retried.
+ if (L === B && p.timedOut) {
+ timedOutCount++;
+ // Retry decision happens at landing time: a failed lane-B attempt gets
+ // one more shot if maxAttempts allows it AND the budget grants a token;
+ // otherwise (or with no budget at all) it counts as a final failure.
+ } else if (L === B && retryCfg && !p.ok && (p.attempt + 1) < retryCfg.maxAttempts) {
+ if (budget && budget.tryWithdraw(now)) {
+ const nextAttempt = p.attempt + 1;
+ // Full-jitter backoff (demo seconds); the retry is a NEW attempt
+ // against the backend, so its outcome is sampled at the later time.
+ const backoffSec = rnd() * Math.min(retryCfg.maxDelay, retryCfg.baseDelay * Math.pow(2, p.attempt));
+ const f2 = scenario.fault(now + backoffSec, rnd);
+ const landTicks = Math.max(1, Math.round((backoffSec + f2.ms) * 1000 / TICK));
+ // Composition order is timeout -> circuitBreaker -> bulkhead -> retry ->
+ // terminal: the bulkhead sits OUTSIDE retry, so one slot is acquired
+ // once and held for the WHOLE operation (all attempts), released only
+ // when the operation finally lands. Carry `bh` forward on every retry
+ // push, or a retried request leaks its slot forever. The deadline (and
+ // start) is likewise carried forward unchanged — it bounds the WHOLE
+ // operation, set once at the first attempt, not reset per retry — and
+ // clamped again here in case the backoff + this attempt would cross it.
+ const { land, timedOut } = clampToDeadline(tick, landTicks, p.deadline);
+ L.pend.push({ land, ok: f2.ok, attempt: nextAttempt, bh: p.bh, start: p.start, deadline: p.deadline, timedOut });
+ spawnDot(dotsB, els.trackB, f2.ok ? 'ok' : 'bad');
+ retried = true;
+ } else if (budget) {
+ budgetExhausted = true;
+ }
+ }
+ // Release the bulkhead slot only once the attempt truly finishes (not on
+ // a retry re-push) — `p.bh` marks entries that actually acquired a slot,
+ // so a null/absent bulkhead or a rejected (never-pushed) request is a no-op.
+ // Breaker records ONCE per fully-exhausted retry sequence (not per attempt):
+ // brk.res is called here, only on the terminal (non-retried) landing. A
+ // timed-out entry is an outer-timeout cancellation: AsyncTimeout is
+ // OUTERMOST, so the inner breaker's `except BaseException` clause does see
+ // the CancelledError (it releases the in-flight probe slot) but does not
+ // count it as a success/failure outcome — only the outer TimeoutError
+ // surfaces to the caller.
+ if (!retried) {
+ L.if--;
+ if (L === B && p.timedOut) { L.bad++; }
+ else { if (L === B && brk) brk.res(p.ok, now); if (p.ok) L.ok++; else L.bad++; }
+ if (L === B && bulk && p.bh) bulk.release();
+ }
+ }
+ }
+ }
+
+ const n = Math.round(RPS * TICK / 1000);
+ let lastFault = null;
+ for (let i = 0; i < n; i++) {
+ const f = scenario.fault(now, rnd);
+ lastFault = f;
+ const landTicks = Math.max(1, Math.round(f.ms * 1000 / TICK));
+ A.if++; A.pend.push({ land: tick + landTicks, ok: f.ok, attempt: 0, ms: f.ms });
+ spawnDot(dotsA, els.trackA, f.ok ? 'ok' : 'bad');
+ if (brk && !brk.allow(now)) {
+ B.rej++; spawnDot(dotsB, els.trackB, 'rej');
+ } else if (bulk && !bulk.tryAcquire()) {
+ // Pool is full: fail fast (BulkheadFullError) rather than queue — the
+ // demo's acquireTimeout=0 choice (see makeBulkhead).
+ B.rej++; spawnDot(dotsB, els.trackB, 'rej');
+ } else {
+ if (budget) budget.deposit(now);
+ // Total deadline set once at the first attempt (Infinity when no
+ // AsyncTimeout is configured, so the clamp below is always a no-op).
+ const deadline = tmoCfg ? now + tmoCfg.timeout : Infinity;
+ const { land, timedOut } = clampToDeadline(tick, landTicks, deadline);
+ B.if++; B.pend.push({ land, ok: f.ok, attempt: 0, bh: !!bulk, start: now, deadline, timedOut });
+ spawnDot(dotsB, els.trackB, f.ok ? 'ok' : 'bad');
+ }
+ }
+ updateUI(now, lastFault);
+ tick++;
+ if (now >= scenario.dur) clearInterval(timer);
+ }, TICK);
+ }
+
+ els.scenarios.querySelectorAll('.scenario-btn').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ els.scenarios.querySelectorAll('.scenario-btn').forEach((b) => b.classList.remove('active'));
+ btn.classList.add('active');
+ selectedScenario = config.scenarios.find((s) => s.id === btn.dataset.scenario);
+ els.play.disabled = false;
+ els.scenLabel.textContent = 'Scenario: ' + selectedScenario.label;
+ els.note.textContent = "Faithful model of httpware's " + describeChain(selectedScenario.chainB) +
+ ' — not httpware running in your browser.';
+ brk = null; bulk = null; retryCfg = null; budget = null; budgetExhausted = false;
+ tmoCfg = null; timedOutCount = 0;
+ setupOutageBar(selectedScenario);
+ resetVisual();
+ });
+ });
+ els.play.addEventListener('click', run);
+ els.replay.addEventListener('click', run);
+ // Auto-select the first scenario so first paint shows a coherent ready-to-play
+ // state (correct flow-diagram boxes, Play enabled) instead of bare "—" placeholders.
+ const firstScenarioBtn = els.scenarios.querySelector('.scenario-btn');
+ if (firstScenarioBtn) firstScenarioBtn.click();
+ }
+
+ return { mount, _models: { makeCircuitBreaker, makeRetryBudget, makeBulkhead }, _util: { mulberry }, REAL, TICK, ADV };
+})();
diff --git a/docs/demos/full-stack.md b/docs/demos/full-stack.md
new file mode 100644
index 0000000..77aab6a
--- /dev/null
+++ b/docs/demos/full-stack.md
@@ -0,0 +1,39 @@
+# Full stack: composing the patterns
+
+Real clients don't use one pattern — they compose. The recommended order is
+`AsyncTimeout -> AsyncCircuitBreaker -> AsyncBulkhead -> AsyncRetry -> terminal`.
+Here a nasty multi-phase incident hits both clients; watch the layers interlock.
+
+
+
+
diff --git a/docs/demos/index.md b/docs/demos/index.md
new file mode 100644
index 0000000..0add4a5
--- /dev/null
+++ b/docs/demos/index.md
@@ -0,0 +1,15 @@
+# Resilience demos
+
+Interactive, self-contained walk-throughs of each resilience pattern under load.
+Each runs a plain client and an httpware client through the **same** outage, side by
+side, and pauses to point out exactly what changes.
+
+- [Circuit Breaker](circuit-breaker.md) — stop hammering a dead backend
+- [Retry + Budget](retry.md) — rescue blips without causing a storm
+- [Bulkhead](bulkhead.md) — contain one slow dependency
+- [Timeout](timeout.md) — bound total latency across retries
+- [Full stack](full-stack.md) — how they compose
+
+!!! note
+ These are a faithful **model** of httpware's behavior for teaching, not httpware
+ running in your browser. See [Resilience](../resilience.md) for the real API.
diff --git a/docs/demos/retry.md b/docs/demos/retry.md
new file mode 100644
index 0000000..c58eb30
--- /dev/null
+++ b/docs/demos/retry.md
@@ -0,0 +1,44 @@
+# Retry + Retry Budget
+
+A transient blip should be retried — but a *sustained* outage retried blindly turns
+one failure into a retry storm that amplifies the outage. httpware retries with
+full-jitter backoff, and a **budget** caps the share of traffic spent on retries so
+a dead backend can't be amplified.
+
+
+
+
diff --git a/docs/demos/timeout.md b/docs/demos/timeout.md
new file mode 100644
index 0000000..486888a
--- /dev/null
+++ b/docs/demos/timeout.md
@@ -0,0 +1,33 @@
+# Timeout (total deadline)
+
+`httpx2`'s per-call timeouts bound a single request — but a retry loop with backoff
+can still run for many seconds in total. `AsyncTimeout` bounds the **whole** operation,
+including every retry and every backoff sleep, so one call can't blow your latency SLA.
+
+
+
+
diff --git a/docs/resilience.md b/docs/resilience.md
index e631046..40602a5 100644
--- a/docs/resilience.md
+++ b/docs/resilience.md
@@ -8,6 +8,10 @@
A key ordering constraint: `AsyncBulkhead` must sit outside `AsyncRetry` (before it in `middleware=`) so one slot covers all retry attempts of a single call. For the full recommended ordering across all four primitives, see [Composition](#composition). Reach for the [Middleware guide](middleware.md) when you want to write your own resilience policy.
+!!! tip "See it under load"
+ New to these patterns? The [interactive demos](demos/index.md) show each one
+ surviving an outage side by side with an unprotected client.
+
- [`AsyncRetry`](#asyncretry)
- [`RetryBudget`](#retrybudget)
- [`AsyncBulkhead`](#asyncbulkhead)
diff --git a/mkdocs.yml b/mkdocs.yml
index 93782a5..a932049 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -7,6 +7,13 @@ edit_uri: edit/main/docs/
nav:
- Quick-Start: index.md
- Resilience: resilience.md
+ - Demos:
+ - Overview: demos/index.md
+ - Retry: demos/retry.md
+ - Circuit Breaker: demos/circuit-breaker.md
+ - Bulkhead: demos/bulkhead.md
+ - Timeout: demos/timeout.md
+ - Full stack: demos/full-stack.md
- Errors: errors.md
- Observability: observability.md
- Decoders: decoders.md
@@ -49,6 +56,10 @@ theme:
extra_css:
- css/brand.css
+ - demos/demos.css
+
+extra_javascript:
+ - demos/engine.js
validation:
omitted_files: warn
diff --git a/planning/changes/2026-07-18.02-resilience-demo-pages.md b/planning/changes/2026-07-18.02-resilience-demo-pages.md
new file mode 100644
index 0000000..921a9c9
--- /dev/null
+++ b/planning/changes/2026-07-18.02-resilience-demo-pages.md
@@ -0,0 +1,87 @@
+---
+summary: Shipped five interactive browser demo pages (circuit breaker, retry+budget, bulkhead, timeout, full-stack) plus a shared vanilla-JS engine modeling httpware's resilience patterns under load, in the docs Demos section.
+---
+
+# Design: Interactive resilience demo pages
+
+## Summary
+
+Add a **Demos** section to the docs site: five self-contained, browser-based pages
+that animate httpware's resilience patterns under a load/failure scenario, so a
+reader viscerally sees the failure each pattern prevents. One page per pattern
+(Retry + RetryBudget, Bulkhead, CircuitBreaker, AsyncTimeout) plus a composed
+full-stack page. Each is a guided walk-through: two clients (plain vs httpware)
+run the same seeded outage side by side; the animation freezes at key beats and a
+spotlight callout points at the exact number to watch. Pure static assets — no new
+Python dependency, no JS toolchain.
+
+## Motivation
+
+The resilience suite is httpware's flagship capability, but `docs/resilience.md`
+explains it in prose. Prose is weak at conveying *why* — that a dead backend drowns
+an unprotected client in hanging requests, that unbounded retries amplify an outage
+into a storm, that a bulkhead rejects rather than queues. A short animated
+before/after makes the motivation immediate in a way text cannot.
+
+## Design
+
+**Shared engine, thin pages.** One vanilla-JS `docs/demos/engine.js` owns everything:
+a fixed-step virtual clock, a seeded PRNG, a backend/fault model, one small model
+class per pattern (mirroring httpware's real semantics), the flow-diagram + counters
+renderer, and the guided-tour driver (spotlight cutout + side-placed callout with
+auto-flip and an arrow). Each page is a markdown file holding a mount `
` and a
+small config object — which middleware are in the chain, the scenario's fault
+schedule, the ordered tour stops, and which numbers each stop highlights. `engine.js`
+loads via `extra_javascript` in `mkdocs.yml`; a **Demos** nav section lists the five
+pages and `resilience.md` cross-links them.
+
+**Interaction (validated via prototype).** Side-by-side lanes: a plain client and an
+httpware client driven by the *same* seeded fault timeline, so the difference is the
+pattern, not luck. Requests render as tick-driven dots (green/red/grey) that truly
+freeze when paused. Press-play runs to the first stop; each stop dims the page except
+the element(s) to watch, anchors a callout in the adjacent open space, and resumes on
+Continue. Themes off mkdocs-material's CSS variables + `brand.css` (light/dark).
+`prefers-reduced-motion` falls back to stepped updates; outcomes never rely on color
+alone.
+
+**Per-page scenarios:** Retry blip/brownout (budget caps the retry storm); Bulkhead
+slow-dependency (semaphore rejects fast vs unbounded starvation); CircuitBreaker
+hard-down 6s (trip → fast-fail → single-probe recovery); AsyncTimeout stacked
+retry+backoff (total deadline bounds wall-clock); Full-stack multi-phase incident
+composed `Timeout → CircuitBreaker → Bulkhead → Retry`.
+
+**Two settled decisions.** Coach-marks are **hand-rolled** (~60 lines, proven in the
+prototype) rather than vendoring driver.js — zero third-party asset in a Python repo,
+fully in our control. Fidelity to httpware's real defaults is kept by a
+**source-of-truth comment** at the top of `engine.js` (citing
+`architecture/resilience.md`) plus PR review — the pattern constants (budget ratio,
+`window_seconds=30`, `minimum_calls=20`, 500–599 failure range, thresholds) live in
+one block so review has a single place to check. No automated drift guard (see Risk).
+
+## Non-goals
+
+- Not running real httpware in the browser — the engine is a faithful *model*, and
+ every page says so.
+- No sandbox sliders — guided on/off + preset scenarios only, to keep the point sharp.
+- No JS test runner — a JS toolchain is out of scope for a pure-Python repo.
+- `architecture/` is unchanged: this adds docs, not a capability. Only
+ `docs/resilience.md` gains cross-links.
+
+## Testing
+
+`mkdocs build --strict` passes (pages resolve, nav valid, no broken links). Manual
+check: each page loads under mkdocs-material light and dark, the walk-through steps
+through every stop with the callout beside (never over) the highlighted element, and
+`prefers-reduced-motion` degrades gracefully.
+
+## Risk
+
+- **Fidelity drift (medium likelihood, medium impact).** With no automated guard, a
+ future change to a resilience default won't fail any demo test. Mitigation: the
+ single constants block + citing comment; revisit and add the Python parity test if
+ drift is observed.
+- **Maintenance of hand-rolled coach-marks (low × low).** We own edge cases (resize,
+ odd viewports, focus handling). Mitigation: the logic is small and centralized;
+ driver.js remains a drop-in fallback if it proves fragile.
+- **Scope creep on the full-stack page (low × medium).** Composing four patterns can
+ get busy. Mitigation: same template, reuse the four validated single-pattern stops.