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 ` +
+

${esc(title)}

+

${intro}

+ +
+
${scenarioBtns}
+ + +
+
Pick a scenario above.
+ +
+ +
+
+ +
+

Plain client no protection

+
+
client
+
server ✓
+
+
+
+ in-flight 0 + 0 + 0 + p99 40ms +
+
+ +
+

httpware client CircuitBreaker

+
+
client
+
+
server ✓
+
+
+
+ in-flight 0 + 0 + 0 + ⏻ fast-failed 0 + + + p99 40ms +
+
+ +

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.