From 78fa1c475fabe0f8a1e8d02b07f8650cd328b8c4 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 22:14:43 +0300 Subject: [PATCH 01/18] docs: add resilience demo engine and circuit-breaker page --- docs/demos/circuit-breaker.md | 36 +++ docs/demos/demos.css | 135 +++++++++++ docs/demos/engine.js | 422 ++++++++++++++++++++++++++++++++++ docs/demos/index.md | 3 + mkdocs.yml | 7 + 5 files changed, 603 insertions(+) create mode 100644 docs/demos/circuit-breaker.md create mode 100644 docs/demos/demos.css create mode 100644 docs/demos/engine.js create mode 100644 docs/demos/index.md diff --git a/docs/demos/circuit-breaker.md b/docs/demos/circuit-breaker.md new file mode 100644 index 0000000..dd4fbc2 --- /dev/null +++ b/docs/demos/circuit-breaker.md @@ -0,0 +1,36 @@ +# 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..f301821 --- /dev/null +++ b/docs/demos/demos.css @@ -0,0 +1,135 @@ +/* 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 { + max-width: 760px; + margin: 0 auto; + color: var(--hw-fg); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + 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: 6px; left: 8px; 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); } +.hw-demo .dot.rej { background: 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..d770e57 --- /dev/null +++ b/docs/demos/engine.js @@ -0,0 +1,422 @@ +/* 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. + */ +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) ---- + function makeCircuitBreaker(cfg) { + const T = cfg.failureThreshold, R = cfg.resetTimeout, S = cfg.successThreshold; + return { state: 'CLOSED', fails: 0, succ: 0, openedAt: 0, probe: 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; } } + else { this.state = 'OPEN'; this.openedAt = now; this.fails = 1; this.succ = 0; } + return; + } + if (ok) this.fails = 0; + else { this.fails++; if (this.fails >= T) { this.state = 'OPEN'; this.openedAt = now; } } + } }; + } + + const prefersReduced = window.matchMedia && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + const MW_LABELS = { circuitBreaker: 'CircuitBreaker', retry: 'Retry', 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})`); + } + 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 = ''; + for (let i = 0; i <= steps; i++) { + const t = (scenario.dur * i) / steps; + const f = scenario.fault(t, worstRnd); + if (!f.ok) { + if (from === null) from = t; + to = t; + if (!label) label = f.label || 'DOWN'; + } + } + if (from === null) return null; + 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 +
+
+ +

Pick a scenario to see the faithful model in play.

+
+ +
+
+
+
+
+
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'), + 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 }; + + const STOPS = config.buildStops({}); + + 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 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 = ''; }); + els.brkB.className = 'box'; els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; + 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)' : ''; + const down = !!(lastFault && !lastFault.ok); + els.latA.textContent = down ? (A.if > 4 ? '12s+' : formatMs(lastFault.ms)) : '40ms'; + els.latA.style.color = down ? 'var(--hw-bad)' : ''; + els.latB.textContent = '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' + (down ? ' down' : ''); + els.srvA.textContent = down ? ('server ✗' + (lastFault.label ? ' ' + lastFault.label : '')) : 'server ✓'; + els.srvB.className = 'box' + (down ? ' down' : ''); + els.srvB.textContent = down ? 'server ✗' : 'server ✓'; + els.laneA.classList.toggle('hot', down && A.if > 8); + els.laneB.classList.toggle('safe', st === 'CLOSED' && !down); + els.badgeA.className = 'badge' + (down ? ' bad' : ''); + els.badgeA.textContent = down ? 'drowning' : 'no protection'; + const okBadge = st === 'CLOSED' && brk && !down; + 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; + resetVisual(); + brk = scenario.chainB && scenario.chainB.circuitBreaker + ? makeCircuitBreaker(scenario.chainB.circuitBreaker) : null; + rnd = mulberry(SEED); + els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; + + timer = setInterval(() => { + if (paused) return; + const now = tick * TICK / 1000; + const state = { now, A, B, mw: brk }; + 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.if--; L.pend.splice(i, 1); + if (L === B && brk) brk.res(p.ok, now); + if (p.ok) L.ok++; else L.bad++; + } + } + } + + 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 }); + spawnDot(dotsA, els.trackA, f.ok ? 'ok' : 'bad'); + if (brk && !brk.allow(now)) { + B.rej++; spawnDot(dotsB, els.trackB, 'rej'); + } else { + B.if++; B.pend.push({ land: tick + landTicks, ok: f.ok }); + 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; + setupOutageBar(selectedScenario); + resetVisual(); + }); + }); + els.play.addEventListener('click', run); + els.replay.addEventListener('click', run); + } + + return { mount, _models: { makeCircuitBreaker }, _util: { mulberry }, REAL, TICK, ADV }; +})(); diff --git a/docs/demos/index.md b/docs/demos/index.md new file mode 100644 index 0000000..adfb75b --- /dev/null +++ b/docs/demos/index.md @@ -0,0 +1,3 @@ +# Demos + +Interactive, in-browser walk-throughs of httpware's resilience patterns under simulated load — start with [Circuit Breaker](circuit-breaker.md). diff --git a/mkdocs.yml b/mkdocs.yml index 93782a5..e5b5b24 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -7,6 +7,9 @@ edit_uri: edit/main/docs/ nav: - Quick-Start: index.md - Resilience: resilience.md + - Demos: + - Overview: demos/index.md + - Circuit Breaker: demos/circuit-breaker.md - Errors: errors.md - Observability: observability.md - Decoders: decoders.md @@ -49,6 +52,10 @@ theme: extra_css: - css/brand.css + - demos/demos.css + +extra_javascript: + - demos/engine.js validation: omitted_files: warn From ca1f989c2a02e6dd533525e0755571e355b22712 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 22:28:46 +0300 Subject: [PATCH 02/18] docs: make demo breaker OPEN-state results no-ops to match httpware --- docs/demos/engine.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index d770e57..0341bdf 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -5,6 +5,9 @@ * 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 = { @@ -30,6 +33,10 @@ window.HttpwareDemo = (function () { 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; return { state: 'CLOSED', fails: 0, succ: 0, openedAt: 0, probe: false, @@ -47,6 +54,7 @@ window.HttpwareDemo = (function () { 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; } } } }; From 593bd4fb7b69853c01f6350ef78040fcaaef14d6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 18 Jul 2026 22:32:46 +0300 Subject: [PATCH 03/18] docs: gate circuit-breaker recovery callout on observed state --- docs/demos/circuit-breaker.md | 2 +- docs/demos/engine.js | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/demos/circuit-breaker.md b/docs/demos/circuit-breaker.md index dd4fbc2..943bc37 100644 --- a/docs/demos/circuit-breaker.md +++ b/docs/demos/circuit-breaker.md @@ -29,7 +29,7 @@ HttpwareDemo.mount('#cb-demo', { 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.' }, { when: (s) => s.now >= 5.6, spot: ['ifA', 'latA', 'ifB', 'latB'], title: 'The gap — this is the point', body: 'Plain client: in-flight high AND p99 blown to 12s — drowning. Protected client: in-flight flat, p99 still 40ms. Same outage, two outcomes.' }, - { when: (s) => s.now >= 8.4, spot: ['brkB'], title: 'Recovery via one probe', + { when: (s) => s.mw.recovered, spot: ['brkB'], title: 'Recovery via one probe', body: 'Backend is back. The breaker admits exactly ONE probe, sees success, and closes — no thundering herd.' }, ], }); diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 0341bdf..f3a338e 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -39,7 +39,11 @@ window.HttpwareDemo = (function () { // 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; - return { state: 'CLOSED', fails: 0, succ: 0, openedAt: 0, probe: false, + // `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; } @@ -50,7 +54,7 @@ window.HttpwareDemo = (function () { }, 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; } } + 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; } From 6a03a5a8a8ac84edabc3d7378388e03f08515eee Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 09:24:20 +0300 Subject: [PATCH 04/18] docs: show faithful-model disclaimer before interaction --- docs/demos/engine.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index f3a338e..838c9fe 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -174,7 +174,7 @@ window.HttpwareDemo = (function () { -

Pick a scenario to see the faithful model in play.

+

Faithful model of httpware, not httpware running in your browser. Pick a scenario above to begin.

From 7af56bcad9fdc2cf2351aafb1179dd8ea313f85f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 09:35:44 +0300 Subject: [PATCH 05/18] docs: add retry + budget demo page --- docs/demos/engine.js | 67 ++++++++++++++++++++++++++++++++++++++------ docs/demos/retry.md | 35 +++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 docs/demos/retry.md diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 838c9fe..ba7a00d 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -64,10 +64,27 @@ window.HttpwareDemo = (function () { } }; } + // Finagle-style token bucket: caps retries to a fraction of traffic, with a floor. + // Mirrors budget.py (ttl, min_retries_per_sec, percent_can_retry). Deposits on each + // request, purges entries older than ttl, floor = int(minRetriesPerSec * ttl). + function makeRetryBudget(cfg) { + const ttl = cfg.ttl, floor = Math.floor(cfg.minRetriesPerSec * ttl), pct = cfg.percentCanRetry; + let deposits = []; // timestamps of requests in window + let debt = 0; // retries taken (each costs 1 token) + function purge(now) { const cut = now - ttl; deposits = deposits.filter((t) => t >= cut); } + return { + deposit(now) { deposits.push(now); }, + tryWithdraw(now) { purge(now); + const balance = floor + Math.floor(deposits.length * pct) - debt; + if (balance >= 1) { debt += 1; return true; } + return false; }, + }; + } + const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - const MW_LABELS = { circuitBreaker: 'CircuitBreaker', retry: 'Retry', bulkhead: 'Bulkhead', timeout: 'Timeout' }; + const MW_LABELS = { circuitBreaker: 'CircuitBreaker', retry: 'Retry', budget: 'RetryBudget', bulkhead: 'Bulkhead', timeout: 'Timeout' }; function esc(s) { return String(s).replace(/[&<>"']/g, (c) => ({ @@ -91,6 +108,14 @@ window.HttpwareDemo = (function () { : `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(', '); } @@ -215,6 +240,7 @@ window.HttpwareDemo = (function () { let A = { ok: 0, bad: 0, if: 0, pend: [] }; let B = { ok: 0, bad: 0, rej: 0, if: 0, pend: [] }; let brk = null; + let retryCfg = null, budget = null, budgetExhausted = false; let rnd = mulberry(SEED); let selectedScenario = null; @@ -369,13 +395,18 @@ window.HttpwareDemo = (function () { resetVisual(); brk = scenario.chainB && scenario.chainB.circuitBreaker ? makeCircuitBreaker(scenario.chainB.circuitBreaker) : null; + retryCfg = scenario.chainB && scenario.chainB.retry ? scenario.chainB.retry : null; + budget = scenario.chainB && scenario.chainB.budget ? makeRetryBudget(scenario.chainB.budget) : null; + budgetExhausted = false; rnd = mulberry(SEED); els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; timer = setInterval(() => { if (paused) return; const now = tick * TICK / 1000; - const state = { now, A, B, mw: brk }; + const mw = { state: brk ? brk.state : 'CLOSED', recovered: brk ? brk.recovered : false, + budgetExhausted, rejected: B.rej, cb: brk }; + const state = { now, A, B, mw }; if (stopIdx < STOPS.length && STOPS[stopIdx].when(state)) { showStop(STOPS[stopIdx]); return; } advDots(dotsA); advDots(dotsB); @@ -384,9 +415,28 @@ window.HttpwareDemo = (function () { 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.if--; L.pend.splice(i, 1); + const p = L.pend[i]; L.pend.splice(i, 1); if (L === B && brk) brk.res(p.ok, now); - if (p.ok) L.ok++; else L.bad++; + let retried = false; + // 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. + 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)); + L.pend.push({ land: tick + landTicks, ok: f2.ok, attempt: nextAttempt }); + spawnDot(dotsB, els.trackB, f2.ok ? 'ok' : 'bad'); + retried = true; + } else if (budget) { + budgetExhausted = true; + } + } + if (!retried) { L.if--; if (p.ok) L.ok++; else L.bad++; } } } } @@ -397,12 +447,13 @@ window.HttpwareDemo = (function () { 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 }); + A.if++; A.pend.push({ land: tick + landTicks, ok: f.ok, attempt: 0 }); spawnDot(dotsA, els.trackA, f.ok ? 'ok' : 'bad'); if (brk && !brk.allow(now)) { B.rej++; spawnDot(dotsB, els.trackB, 'rej'); } else { - B.if++; B.pend.push({ land: tick + landTicks, ok: f.ok }); + if (budget) budget.deposit(now); + B.if++; B.pend.push({ land: tick + landTicks, ok: f.ok, attempt: 0 }); spawnDot(dotsB, els.trackB, f.ok ? 'ok' : 'bad'); } } @@ -421,7 +472,7 @@ window.HttpwareDemo = (function () { 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; + brk = null; retryCfg = null; budget = null; budgetExhausted = false; setupOutageBar(selectedScenario); resetVisual(); }); @@ -430,5 +481,5 @@ window.HttpwareDemo = (function () { els.replay.addEventListener('click', run); } - return { mount, _models: { makeCircuitBreaker }, _util: { mulberry }, REAL, TICK, ADV }; + return { mount, _models: { makeCircuitBreaker, makeRetryBudget }, _util: { mulberry }, REAL, TICK, ADV }; })(); diff --git a/docs/demos/retry.md b/docs/demos/retry.md new file mode 100644 index 0000000..157b1f8 --- /dev/null +++ b/docs/demos/retry.md @@ -0,0 +1,35 @@ +# 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/mkdocs.yml b/mkdocs.yml index e5b5b24..55b181d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,6 +9,7 @@ nav: - Resilience: resilience.md - Demos: - Overview: demos/index.md + - Retry: demos/retry.md - Circuit Breaker: demos/circuit-breaker.md - Errors: errors.md - Observability: observability.md From d04e6df657c77f3c5b3612d3d09f338230fb88dd Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 09:39:55 +0300 Subject: [PATCH 06/18] docs: make demo tour stops scenario-specific --- docs/demos/engine.js | 5 ++++- docs/demos/retry.md | 9 ++++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index ba7a00d..d67776d 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -233,7 +233,9 @@ window.HttpwareDemo = (function () { }; const ELS = { ifA: els.ifA, latA: els.latA, ifB: els.ifB, latB: els.latB, brkB: els.brkB }; - const STOPS = config.buildStops({}); + // Rebuilt per-run from the selected scenario (see run()) — pages with one shared + // stop list across scenarios (buildStops ignoring its arg) keep working unchanged. + let STOPS = config.buildStops(null); let timer = null, paused = false, tick = 0, stopIdx = 0; let dotsA = [], dotsB = []; @@ -392,6 +394,7 @@ window.HttpwareDemo = (function () { function run() { if (!selectedScenario) return; const scenario = selectedScenario; + STOPS = config.buildStops(scenario); resetVisual(); brk = scenario.chainB && scenario.chainB.circuitBreaker ? makeCircuitBreaker(scenario.chainB.circuitBreaker) : null; diff --git a/docs/demos/retry.md b/docs/demos/retry.md index 157b1f8..bf46929 100644 --- a/docs/demos/retry.md +++ b/docs/demos/retry.md @@ -21,7 +21,7 @@ HttpwareDemo.mount('#retry-demo', { chainB: { retry: { maxAttempts: 3, baseDelay: 0.1, maxDelay: 5.0 }, budget: { ttl: 10.0, minRetriesPerSec: 10.0, percentCanRetry: 0.2 } } }, ], - buildStops: () => [ + buildStops: (scenario) => scenario.id === 'sustained' ? [ { when: (s) => s.now >= 1.2, spot: ['ifA', 'ifB'], title: 'A backend blip appears', body: 'Both clients hit the same transient errors. Watch how each responds.' }, { when: (s) => s.now >= 2.4, spot: ['ifB'], title: 'httpware retries the blip', @@ -30,6 +30,13 @@ HttpwareDemo.mount('#retry-demo', { body: 'On a SUSTAINED outage, blind retries would multiply load. The budget is spent — httpware STOPS retrying and fails fast, protecting the dying backend instead of hammering it.' }, { when: (s) => s.now >= 10.0, spot: ['ifA', 'ifB'], title: 'Blip: recovered. Outage: contained', body: 'Retry rescues transient errors without turning a real outage into a storm. That cap is the whole reason the budget exists.' }, + ] : [ + { when: (s) => s.now >= 1.2, spot: ['ifA', 'ifB'], title: 'A backend blip appears', + body: 'Both clients hit the same transient errors. Watch how each responds.' }, + { when: (s) => s.now >= 2.4, spot: ['ifB'], title: 'httpware retries the blip', + body: 'The plain client surfaces the error immediately. httpware retries with backoff — most of these recover on attempt 2 or 3, invisibly to the caller.' }, + { when: (s) => s.now >= 10.0, spot: ['ifA', 'ifB'], title: 'Blip: recovered', + body: 'The backend healed and so did both clients — but compare the ✗ counts: httpware surfaced far fewer failures to the caller. That is what retry buys you on a transient blip.' }, ], }); From c97a1c01047585b7140b9d76f199dea4be0e44a9 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 09:53:03 +0300 Subject: [PATCH 07/18] docs: fix demo mount crash and align RetryBudget with budget.py --- docs/demos/engine.js | 37 ++++++++++++++++++++++++------------- 1 file changed, 24 insertions(+), 13 deletions(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index d67776d..d274a41 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -65,19 +65,21 @@ window.HttpwareDemo = (function () { } // Finagle-style token bucket: caps retries to a fraction of traffic, with a floor. - // Mirrors budget.py (ttl, min_retries_per_sec, percent_can_retry). Deposits on each - // request, purges entries older than ttl, floor = int(minRetriesPerSec * ttl). + // 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 = []; // timestamps of requests in window - let debt = 0; // retries taken (each costs 1 token) - function purge(now) { const cut = now - ttl; deposits = deposits.filter((t) => t >= cut); } + 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) { deposits.push(now); }, + deposit(now) { purge(now); deposits.push(now); }, tryWithdraw(now) { purge(now); - const balance = floor + Math.floor(deposits.length * pct) - debt; - if (balance >= 1) { debt += 1; return true; } - return false; }, + const ceiling = Math.ceil(deposits.length * pct) + floor; + if (withdrawn.length >= ceiling) return false; + withdrawn.push(now); return true; }, }; } @@ -233,9 +235,14 @@ window.HttpwareDemo = (function () { }; const ELS = { ifA: els.ifA, latA: els.latA, ifB: els.ifB, latB: els.latB, brkB: els.brkB }; - // Rebuilt per-run from the selected scenario (see run()) — pages with one shared - // stop list across scenarios (buildStops ignoring its arg) keep working unchanged. - let STOPS = config.buildStops(null); + // 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 = []; @@ -399,7 +406,11 @@ window.HttpwareDemo = (function () { brk = scenario.chainB && scenario.chainB.circuitBreaker ? makeCircuitBreaker(scenario.chainB.circuitBreaker) : null; retryCfg = scenario.chainB && scenario.chainB.retry ? scenario.chainB.retry : null; - budget = scenario.chainB && scenario.chainB.budget ? makeRetryBudget(scenario.chainB.budget) : 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; rnd = mulberry(SEED); els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; From c9df08c4afff5c712d20b02338b92af0189df479 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 10:04:31 +0300 Subject: [PATCH 08/18] docs: add bulkhead demo page --- docs/demos/bulkhead.md | 28 ++++++++++++++++++ docs/demos/engine.js | 66 ++++++++++++++++++++++++++++++++---------- mkdocs.yml | 1 + 3 files changed, 79 insertions(+), 16 deletions(-) create mode 100644 docs/demos/bulkhead.md diff --git a/docs/demos/bulkhead.md b/docs/demos/bulkhead.md new file mode 100644 index 0000000..dc6b170 --- /dev/null +++ b/docs/demos/bulkhead.md @@ -0,0 +1,28 @@ +# 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/engine.js b/docs/demos/engine.js index d274a41..09d187f 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -83,6 +83,16 @@ window.HttpwareDemo = (function () { }; } + // 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--; } }; + } + const prefersReduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; @@ -197,6 +207,7 @@ window.HttpwareDemo = (function () { 0 0 ⏻ fast-failed 0 + pool p99 40ms @@ -229,11 +240,12 @@ window.HttpwareDemo = (function () { 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'), 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 }; + const ELS = { ifA: els.ifA, latA: els.latA, ifB: els.ifB, latB: els.latB, brkB: els.brkB, poolB: els.poolB }; // 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 @@ -249,6 +261,7 @@ window.HttpwareDemo = (function () { 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 rnd = mulberry(SEED); let selectedScenario = null; @@ -363,6 +376,8 @@ window.HttpwareDemo = (function () { els.latA.textContent = '40ms'; els.latB.textContent = '40ms'; [els.latA, els.latB, els.ifA, els.ifB].forEach((n) => { n.style.color = ''; }); els.brkB.className = 'box'; els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; + els.poolWrap.style.display = bulk ? '' : 'none'; + els.poolB.textContent = bulk ? (bulk.inUse + '/' + bulk.max) : '—'; els.srvA.className = 'box'; els.srvA.textContent = 'server ✓'; els.srvB.className = 'box'; els.srvB.textContent = 'server ✓'; els.laneA.className = 'lane'; els.laneB.className = 'lane'; @@ -377,23 +392,30 @@ window.HttpwareDemo = (function () { 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; + // "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); - els.latA.textContent = down ? (A.if > 4 ? '12s+' : formatMs(lastFault.ms)) : '40ms'; - els.latA.style.color = down ? 'var(--hw-bad)' : ''; + const slow = !!(lastFault && lastFault.ok && lastFault.ms >= 1.0); + const stressed = down || slow; + els.latA.textContent = stressed ? (A.if > 4 ? '12s+' : formatMs(lastFault.ms)) : '40ms'; + els.latA.style.color = stressed ? 'var(--hw-bad)' : ''; els.latB.textContent = '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' + (down ? ' down' : ''); - els.srvA.textContent = down ? ('server ✗' + (lastFault.label ? ' ' + lastFault.label : '')) : 'server ✓'; - els.srvB.className = 'box' + (down ? ' down' : ''); - els.srvB.textContent = down ? 'server ✗' : 'server ✓'; - els.laneA.classList.toggle('hot', down && A.if > 8); - els.laneB.classList.toggle('safe', st === 'CLOSED' && !down); - els.badgeA.className = 'badge' + (down ? ' bad' : ''); - els.badgeA.textContent = down ? 'drowning' : 'no protection'; - const okBadge = st === 'CLOSED' && brk && !down; + 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', stressed && A.if > 8); + els.laneB.classList.toggle('safe', st === 'CLOSED' && !stressed); + els.badgeA.className = 'badge' + (stressed ? ' bad' : ''); + els.badgeA.textContent = stressed ? '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); } @@ -405,6 +427,8 @@ window.HttpwareDemo = (function () { resetVisual(); 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. @@ -414,6 +438,8 @@ window.HttpwareDemo = (function () { budgetExhausted = false; rnd = mulberry(SEED); els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; + els.poolWrap.style.display = bulk ? '' : 'none'; + els.poolB.textContent = bulk ? (bulk.inUse + '/' + bulk.max) : '—'; timer = setInterval(() => { if (paused) return; @@ -450,7 +476,11 @@ window.HttpwareDemo = (function () { budgetExhausted = true; } } - if (!retried) { L.if--; if (p.ok) L.ok++; else L.bad++; } + // 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. + if (!retried) { L.if--; if (p.ok) L.ok++; else L.bad++; + if (L === B && bulk && p.bh) bulk.release(); } } } } @@ -465,9 +495,13 @@ window.HttpwareDemo = (function () { 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); - B.if++; B.pend.push({ land: tick + landTicks, ok: f.ok, attempt: 0 }); + B.if++; B.pend.push({ land: tick + landTicks, ok: f.ok, attempt: 0, bh: !!bulk }); spawnDot(dotsB, els.trackB, f.ok ? 'ok' : 'bad'); } } @@ -486,7 +520,7 @@ window.HttpwareDemo = (function () { 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; retryCfg = null; budget = null; budgetExhausted = false; + brk = null; bulk = null; retryCfg = null; budget = null; budgetExhausted = false; setupOutageBar(selectedScenario); resetVisual(); }); @@ -495,5 +529,5 @@ window.HttpwareDemo = (function () { els.replay.addEventListener('click', run); } - return { mount, _models: { makeCircuitBreaker, makeRetryBudget }, _util: { mulberry }, REAL, TICK, ADV }; + return { mount, _models: { makeCircuitBreaker, makeRetryBudget, makeBulkhead }, _util: { mulberry }, REAL, TICK, ADV }; })(); diff --git a/mkdocs.yml b/mkdocs.yml index 55b181d..b1d7582 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,6 +11,7 @@ nav: - Overview: demos/index.md - Retry: demos/retry.md - Circuit Breaker: demos/circuit-breaker.md + - Bulkhead: demos/bulkhead.md - Errors: errors.md - Observability: observability.md - Decoders: decoders.md From db2fc21a1ef47ad3c06fccd9f58d83bf10a9710c Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 10:16:04 +0300 Subject: [PATCH 09/18] docs: hide bulkhead pool stat by default and hold slot across retries --- docs/demos/engine.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 09d187f..f9b2125 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -207,7 +207,7 @@ window.HttpwareDemo = (function () { 0 0 ⏻ fast-failed 0 - pool + p99 40ms @@ -469,7 +469,12 @@ window.HttpwareDemo = (function () { 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)); - L.pend.push({ land: tick + landTicks, ok: f2.ok, attempt: nextAttempt }); + // 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. + L.pend.push({ land: tick + landTicks, ok: f2.ok, attempt: nextAttempt, bh: p.bh }); spawnDot(dotsB, els.trackB, f2.ok ? 'ok' : 'bad'); retried = true; } else if (budget) { From 9cbf53ab3190c489887cba36d1ae312a4b9e751f Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 10:28:36 +0300 Subject: [PATCH 10/18] docs: add timeout demo page --- docs/demos/engine.js | 54 +++++++++++++++++++++++++++++++++++++------ docs/demos/timeout.md | 31 +++++++++++++++++++++++++ mkdocs.yml | 1 + 3 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 docs/demos/timeout.md diff --git a/docs/demos/engine.js b/docs/demos/engine.js index f9b2125..c9d6a77 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -93,6 +93,17 @@ window.HttpwareDemo = (function () { 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; @@ -208,6 +219,7 @@ window.HttpwareDemo = (function () { 0 ⏻ fast-failed 0 + p99 40ms @@ -241,11 +253,12 @@ window.HttpwareDemo = (function () { 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 }; + 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 @@ -263,6 +276,7 @@ window.HttpwareDemo = (function () { 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; @@ -378,6 +392,8 @@ window.HttpwareDemo = (function () { els.brkB.className = 'box'; els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; els.poolWrap.style.display = bulk ? '' : 'none'; els.poolB.textContent = bulk ? (bulk.inUse + '/' + bulk.max) : '—'; + els.elapsedWrap.style.display = tmoCfg ? '' : 'none'; + els.elapsedB.textContent = tmoCfg ? ('0.0s / ' + tmoCfg.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'; @@ -393,6 +409,10 @@ window.HttpwareDemo = (function () { 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; + if (tmoCfg) { + const 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. @@ -436,16 +456,20 @@ window.HttpwareDemo = (function () { ? makeRetryBudget(scenario.chainB.budget || REAL.retryBudget) : null; budgetExhausted = false; + tmoCfg = scenario.chainB && scenario.chainB.timeout ? scenario.chainB.timeout : null; + timedOutCount = 0; rnd = mulberry(SEED); els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; els.poolWrap.style.display = bulk ? '' : 'none'; els.poolB.textContent = bulk ? (bulk.inUse + '/' + bulk.max) : '—'; + els.elapsedWrap.style.display = tmoCfg ? '' : 'none'; + els.elapsedB.textContent = tmoCfg ? ('0.0s / ' + tmoCfg.timeout.toFixed(1) + 's') : '—'; 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 }; + 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; } @@ -458,10 +482,17 @@ window.HttpwareDemo = (function () { const p = L.pend[i]; L.pend.splice(i, 1); if (L === B && brk) brk.res(p.ok, now); 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. - if (L === B && retryCfg && !p.ok && (p.attempt + 1) < retryCfg.maxAttempts) { + } 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 @@ -473,8 +504,12 @@ window.HttpwareDemo = (function () { // 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. - L.pend.push({ land: tick + landTicks, ok: f2.ok, attempt: nextAttempt, bh: p.bh }); + // 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) { @@ -484,7 +519,7 @@ window.HttpwareDemo = (function () { // 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. - if (!retried) { L.if--; if (p.ok) L.ok++; else L.bad++; + if (!retried) { L.if--; if (L === B && p.timedOut) L.bad++; else if (p.ok) L.ok++; else L.bad++; if (L === B && bulk && p.bh) bulk.release(); } } } @@ -506,7 +541,11 @@ window.HttpwareDemo = (function () { B.rej++; spawnDot(dotsB, els.trackB, 'rej'); } else { if (budget) budget.deposit(now); - B.if++; B.pend.push({ land: tick + landTicks, ok: f.ok, attempt: 0, bh: !!bulk }); + // 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'); } } @@ -526,6 +565,7 @@ window.HttpwareDemo = (function () { 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(); }); diff --git a/docs/demos/timeout.md b/docs/demos/timeout.md new file mode 100644 index 0000000..f33bc24 --- /dev/null +++ b/docs/demos/timeout.md @@ -0,0 +1,31 @@ +# 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/mkdocs.yml b/mkdocs.yml index b1d7582..774a3ed 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,6 +12,7 @@ nav: - Retry: demos/retry.md - Circuit Breaker: demos/circuit-breaker.md - Bulkhead: demos/bulkhead.md + - Timeout: demos/timeout.md - Errors: errors.md - Observability: observability.md - Decoders: decoders.md From 2a158fc6d3200d9f2e1bee4f68c77e3d0b8c1205 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 10:50:03 +0300 Subject: [PATCH 11/18] 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/demos/engine.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index c9d6a77..81d7b97 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -480,7 +480,6 @@ window.HttpwareDemo = (function () { 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); - if (L === B && brk) brk.res(p.ok, now); let retried = false; // AsyncTimeout bounds the WHOLE operation (outermost: timeout -> // circuitBreaker -> bulkhead -> retry -> terminal). An entry clamped @@ -519,8 +518,17 @@ window.HttpwareDemo = (function () { // 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. - if (!retried) { L.if--; if (L === B && p.timedOut) L.bad++; else if (p.ok) L.ok++; else L.bad++; - if (L === B && bulk && p.bh) bulk.release(); } + // 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, not a counted outcome — + // AsyncTimeout is OUTERMOST, so a deadline-cancelled operation surfaces as + // the outer TimeoutError and the inner breaker never sees it. + 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(); + } } } } From d90ebff67b4a466194dc3d4f8b8c0871210bec95 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 10:50:03 +0300 Subject: [PATCH 12/18] 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/demos/full-stack.md | 37 +++++++++++++++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 38 insertions(+) create mode 100644 docs/demos/full-stack.md diff --git a/docs/demos/full-stack.md b/docs/demos/full-stack.md new file mode 100644 index 0000000..fab3155 --- /dev/null +++ b/docs/demos/full-stack.md @@ -0,0 +1,37 @@ +# 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/mkdocs.yml b/mkdocs.yml index 774a3ed..a932049 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,6 +13,7 @@ nav: - 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 From ec178376c32a83901c5946ce013a1d88b264bcb6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 11:24:38 +0300 Subject: [PATCH 13/18] 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). --- docs/demos/engine.js | 8 +- docs/demos/index.md | 16 +++- docs/resilience.md | 4 + .../2026-07-18.02-resilience-demo-pages.md | 87 +++++++++++++++++++ 4 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 planning/changes/2026-07-18.02-resilience-demo-pages.md diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 81d7b97..0e2a4df 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -520,9 +520,11 @@ window.HttpwareDemo = (function () { // 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, not a counted outcome — - // AsyncTimeout is OUTERMOST, so a deadline-cancelled operation surfaces as - // the outer TimeoutError and the inner breaker never sees it. + // 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++; } diff --git a/docs/demos/index.md b/docs/demos/index.md index adfb75b..0add4a5 100644 --- a/docs/demos/index.md +++ b/docs/demos/index.md @@ -1,3 +1,15 @@ -# Demos +# Resilience demos -Interactive, in-browser walk-throughs of httpware's resilience patterns under simulated load — start with [Circuit Breaker](circuit-breaker.md). +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/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/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. From be988e121076787883aa7275686cee1e225bbaf0 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 11:26:37 +0300 Subject: [PATCH 14/18] chore: gitignore .superpowers scratch (plans, ledger, brainstorm) Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index db79492..6802009 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ wheels/ .venv uv.lock site/ +.superpowers/ From b6f95f2f663121fd58b95b3deb5609437e1d400d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 11:37:33 +0300 Subject: [PATCH 15/18] 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/demos/engine.js | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 0e2a4df..d454201 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -153,16 +153,29 @@ window.HttpwareDemo = (function () { 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); - if (!f.ok) { + // 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 (!label) label = f.label || 'DOWN'; + 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 }; } @@ -409,8 +422,9 @@ window.HttpwareDemo = (function () { 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) { - const maxElapsed = B.pend.reduce((m, p) => Math.max(m, now - p.start), 0); + 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 @@ -421,7 +435,11 @@ window.HttpwareDemo = (function () { const stressed = down || slow; els.latA.textContent = stressed ? (A.if > 4 ? '12s+' : formatMs(lastFault.ms)) : '40ms'; els.latA.style.color = stressed ? 'var(--hw-bad)' : ''; - els.latB.textContent = '40ms'; + // 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' : ''); From 6f47ae89a3f75cd8f62d6be8eea443129123b222 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 11:40:53 +0300 Subject: [PATCH 16/18] docs: distinguish demo dots by shape and de-dup breaker stat write --- docs/demos/demos.css | 4 ++-- docs/demos/engine.js | 7 +------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/docs/demos/demos.css b/docs/demos/demos.css index f301821..5388149 100644 --- a/docs/demos/demos.css +++ b/docs/demos/demos.css @@ -92,8 +92,8 @@ .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); } -.hw-demo .dot.rej { background: var(--hw-reject); } +.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; } diff --git a/docs/demos/engine.js b/docs/demos/engine.js index d454201..69d8f1a 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -462,7 +462,6 @@ window.HttpwareDemo = (function () { if (!selectedScenario) return; const scenario = selectedScenario; STOPS = config.buildStops(scenario); - resetVisual(); brk = scenario.chainB && scenario.chainB.circuitBreaker ? makeCircuitBreaker(scenario.chainB.circuitBreaker) : null; bulk = scenario.chainB && scenario.chainB.bulkhead @@ -477,11 +476,7 @@ window.HttpwareDemo = (function () { tmoCfg = scenario.chainB && scenario.chainB.timeout ? scenario.chainB.timeout : null; timedOutCount = 0; rnd = mulberry(SEED); - els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; - els.poolWrap.style.display = bulk ? '' : 'none'; - els.poolB.textContent = bulk ? (bulk.inUse + '/' + bulk.max) : '—'; - els.elapsedWrap.style.display = tmoCfg ? '' : 'none'; - els.elapsedB.textContent = tmoCfg ? ('0.0s / ' + tmoCfg.timeout.toFixed(1) + 's') : '—'; + resetVisual(); timer = setInterval(() => { if (paused) return; From ea659153a2de26e5d562f03a7372852db5812d2a Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 11:49:26 +0300 Subject: [PATCH 17/18] docs: defer demo mount() to DOMContentLoaded so engine.js loads first --- docs/demos/bulkhead.md | 4 +++- docs/demos/circuit-breaker.md | 4 +++- docs/demos/full-stack.md | 4 +++- docs/demos/retry.md | 4 +++- docs/demos/timeout.md | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/demos/bulkhead.md b/docs/demos/bulkhead.md index dc6b170..18e0fe1 100644 --- a/docs/demos/bulkhead.md +++ b/docs/demos/bulkhead.md @@ -7,7 +7,8 @@ calls fail fast with `BulkheadFullError` instead of piling up and exhausting the
diff --git a/docs/demos/circuit-breaker.md b/docs/demos/circuit-breaker.md index 943bc37..cd98ebb 100644 --- a/docs/demos/circuit-breaker.md +++ b/docs/demos/circuit-breaker.md @@ -8,7 +8,8 @@ and probing for recovery.
diff --git a/docs/demos/full-stack.md b/docs/demos/full-stack.md index fab3155..77aab6a 100644 --- a/docs/demos/full-stack.md +++ b/docs/demos/full-stack.md @@ -7,7 +7,8 @@ Here a nasty multi-phase incident hits both clients; watch the layers interlock.
diff --git a/docs/demos/retry.md b/docs/demos/retry.md index bf46929..c58eb30 100644 --- a/docs/demos/retry.md +++ b/docs/demos/retry.md @@ -8,7 +8,8 @@ a dead backend can't be amplified.
diff --git a/docs/demos/timeout.md b/docs/demos/timeout.md index f33bc24..486888a 100644 --- a/docs/demos/timeout.md +++ b/docs/demos/timeout.md @@ -7,7 +7,8 @@ including every retry and every backoff sleep, so one call can't blow your laten
From 884b8e6f99e476f013d0bf40f789035338fd6a78 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 12:26:49 +0300 Subject: [PATCH 18/18] 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) --- docs/demos/demos.css | 6 ++---- docs/demos/engine.js | 37 ++++++++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/docs/demos/demos.css b/docs/demos/demos.css index 5388149..27365c1 100644 --- a/docs/demos/demos.css +++ b/docs/demos/demos.css @@ -31,10 +31,8 @@ .hw-demo, .hw-demo *, .hw-demo *::before, .hw-demo *::after { box-sizing: border-box; } .hw-demo .hw-wrap { - max-width: 760px; - margin: 0 auto; color: var(--hw-fg); - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-family: inherit; line-height: 1.5; } .hw-demo h2 { font-size: 1.25rem; margin: 0 0 4px; } @@ -63,7 +61,7 @@ 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: 6px; left: 8px; color: 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 { diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 69d8f1a..d5a764d 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -402,11 +402,15 @@ window.HttpwareDemo = (function () { 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 = ''; }); - els.brkB.className = 'box'; els.brkB.textContent = brk ? 'breaker CLOSED' : 'no breaker'; - els.poolWrap.style.display = bulk ? '' : 'none'; - els.poolB.textContent = bulk ? (bulk.inUse + '/' + bulk.max) : '—'; - els.elapsedWrap.style.display = tmoCfg ? '' : 'none'; - els.elapsedB.textContent = tmoCfg ? ('0.0s / ' + tmoCfg.timeout.toFixed(1) + 's') : '—'; + // 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'; @@ -433,8 +437,15 @@ window.HttpwareDemo = (function () { const down = !!(lastFault && !lastFault.ok); const slow = !!(lastFault && lastFault.ok && lastFault.ms >= 1.0); const stressed = down || slow; - els.latA.textContent = stressed ? (A.if > 4 ? '12s+' : formatMs(lastFault.ms)) : '40ms'; - els.latA.style.color = stressed ? 'var(--hw-bad)' : ''; + // 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 @@ -449,10 +460,10 @@ window.HttpwareDemo = (function () { : 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', stressed && A.if > 8); + els.laneA.classList.toggle('hot', plainStress && A.if > 8); els.laneB.classList.toggle('safe', st === 'CLOSED' && !stressed); - els.badgeA.className = 'badge' + (stressed ? ' bad' : ''); - els.badgeA.textContent = stressed ? 'drowning' : 'no protection'; + 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); @@ -554,7 +565,7 @@ window.HttpwareDemo = (function () { 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 }); + 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'); @@ -595,6 +606,10 @@ window.HttpwareDemo = (function () { }); 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 };