From 46cbf4ad89248278ae219f2ad261b36167900db1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 17:39:26 +0300 Subject: [PATCH 1/9] docs(planning): spec resilience demo herd view + enrich pass Co-Authored-By: Claude Opus 4.8 (1M context) --- ...7-19.02-resilience-demo-herd-and-enrich.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 planning/changes/2026-07-19.02-resilience-demo-herd-and-enrich.md diff --git a/planning/changes/2026-07-19.02-resilience-demo-herd-and-enrich.md b/planning/changes/2026-07-19.02-resilience-demo-herd-and-enrich.md new file mode 100644 index 0000000..54271fc --- /dev/null +++ b/planning/changes/2026-07-19.02-resilience-demo-herd-and-enrich.md @@ -0,0 +1,124 @@ +--- +summary: Add a many-client thundering-herd macro view to the retry/budget demo and fold borrowed visualization techniques (backoff countdown ring, jagged failure shape, full-stack macro strip) across the demo suite. +--- + +# Design: Resilience demo herd view + enrich pass + +## Summary + +Improve the resilience demo suite (shipped in `2026-07-18.02`) using techniques +proven by the best existing visualizations. Two parts: (1) a **thundering-herd +macro view** stacked below the single-client retry/budget demo — 20 naive clients +vs 20 httpware clients, rate-into-the-backend over a sustained outage, so the +retry storm is *shown* not just described; (2) an **enrich pass** folding three +smaller techniques into the existing pages — a backoff/wait **countdown ring**, a +color-independent **jagged failure shape**, and a small **macro strip** on the +full-stack page. Still a faithful model, still pure static assets, still no +sandbox sliders. `architecture/` unchanged — docs only. + +## Motivation + +Surveying the strongest existing work — [Encore's interactive retries +study](https://encore.dev/blog/retries) and the [AWS exponential-backoff-and-jitter +post](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/) — +exposes one real gap and a few cheap wins: + +- **The thundering herd is invisible.** Our retry/budget demo is single-client, so + the one visual that makes "retry storm" and "why jitter" click — many clients + synchronizing into traffic spikes, jitter smoothing them into a flat rate — we + only assert in prose. AWS's canonical framing is a call-rate-over-time chart: + without jitter, "clusters of calls" with dead gaps; with jitter, an approximately + constant rate. We don't draw it. +- **The backoff wait isn't shown.** A dot just eventually resolves; Encore renders a + countdown timer *while a client waits between attempts*, making the delay tangible. +- **Failures lean on color.** A failed request is a flat square distinguished mainly + by red; Encore's "spiked edges" read without color. + +## Design + +**Herd view — stacked section on the retry/budget page.** Below the current +single-client side-by-side, a distinct "Now scale it to 20 clients" section with its +own play button and mini-tour, preserving a micro → macro arc down the page (one +client's retries, then the herd). A new engine **rate-strip render mode** (isolated +from the existing lane renderer, selected by mount config) draws two stacked strips +plotting calls-into-the-backend per tick over a *sustained* outage window: + +- **Naive lane:** 20 clients, fixed backoff, no jitter, unbounded retries → their + retries stay clustered into spikes separated by gaps (AWS's finding; we model + clustering, not literal lockstep — arrivals are spread, fixed backoff *preserves* + the clustering). +- **httpware lane:** 20 clients, full-jitter backoff + per-client budget → jitter + decorrelates retry timing into a near-constant rate; each client's own budget caps + its own amplification. + +Both lanes reuse the *real* retry + budget models from the engine's source-of-truth +constants block, one model instance per simulated client. **Fidelity framing that +must hold:** the budget is **per-client**, not shared across the herd — at demo +traffic levels the *visible* flattening is jitter decorrelating timing; the budget is +the ceiling that keeps even a *longer* storm under ~1.2×. Copy says exactly that; it +must not imply a shared/global budget. + +**Payoff metric.** The mini-tour spotlights the **peak load multiplier** — "backend +saw N× baseline" (naive ~10×, httpware ~1.2×) — because it reads directly off the +tallest spike the strip already draws (spotlight number == visible fact). **Total +calls** during the outage is a secondary, un-spotlighted counter (volume story). +"The flat rate leaves the backend room to recover" is the qualitative closing beat, +*not* a spotlighted stat — httpware's code models no backend-recovery dynamic, so no +invented recovery number. ~4 beats: outage hits → naive's first synchronized spike → +httpware stays flat (jitter + budget) → peak-multiplier payoff. + +**Enrich pass.** + +| Technique | Pages | What it shows | +|---|---|---| +| Countdown ring | circuit-breaker, timeout | CB: `reset_timeout` until OPEN→HALF_OPEN probe · timeout: the deadline ticking down | +| Jagged failure shape | all five (CSS only) | failure readable without color | +| Macro strip | full-stack only | small rate + which-stage-active sparkline | + +The countdown ring is a CSS-conic overlay on a fixed lane-B element, showing +elapsed-wait as a fraction; each page's mount config names which wait it visualizes. +Skipped on bulkhead (the `acquire_timeout` wait either grants a slot instantly or +fast-rejects — no visible dwell), on full-stack (a ring per stage is noise), and — a +fidelity call taken during planning — on **retry**: at the demo's time compression a +full-jitter `base_delay=0.1s` backoff rounds to 0–1 engine ticks (sub-tick), so a +faithful ring there would never render and inflating it would misstate the modeled +wait. Retry teaches backoff timing through the herd view's fixed-vs-jittered contrast +instead. The full-stack macro strip reuses the herd's rate-series renderer at small +size. Jagged failure shape is one `demos.css` change. + +## Non-goals + +- No shared/global budget across the herd — would misrepresent httpware; per-client only. +- No backend-recovery number — not modeled; recovery stays qualitative. +- No sandbox sliders — the herd is seeded and guided, like every other scenario. +- No per-page bespoke sparklines (CB failure-count, bulkhead slots, timeout latency) + — those signals are already live as counters/flow-box state; a macro strip goes + only where *rate* is the story (full-stack; herd covers retry). +- No JS test runner, no new Python dependency — unchanged from `2026-07-18.02`. +- `architecture/` untouched — docs only; no capability contract moves. + +## Testing + +`just docs-build --strict` clean (pages resolve, nav valid, no broken links). +`node --check docs/demos/engine.js`. Scratchpad jsdom harnesses extended: the +existing `verify-demos.js` / `verify-real-page.js` drive every stop including the new +herd mini-tour to completion without stalling; a herd trace asserts naive peak +multiplier ≫ httpware peak (dramatic gap) and that both lanes use the shared retry + +budget models. Manual: retry page reads micro → macro top-to-bottom under +mkdocs-material light and dark; countdown ring animates and empties on retry / CB / +timeout; failure shape distinguishable in grayscale; `prefers-reduced-motion` +degrades to stepped updates. + +## Risk + +- **Herd fidelity overclaim (medium × medium).** Easy to imply lockstep + synchronization or a shared budget. Mitigation: model clustering (not lockstep), + per-client budget instances, and copy reviewed specifically against the fidelity + framing above; reuse the real models rather than a bespoke herd approximation. +- **Rate-strip renderer as new surface (low × medium).** A second render mode adds + engine branching and bug surface. Mitigation: isolate it from the lane renderer + behind mount config; the full-stack macro strip reuses it, so one renderer serves + both. +- **Retry page grows long/busy (low × low).** Two stacked demos plus a tour. + Mitigation: the herd section is visually distinct with its own play control; the + single-client demo is unchanged above it. From 3f0ce5dc6638d59b319db14431edfde2f0a5566d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 17:43:20 +0300 Subject: [PATCH 2/9] refactor(demos): extract shared makeTour guided-tour driver Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/demos/engine.js | 141 +++++++++++++++++++++++-------------------- 1 file changed, 76 insertions(+), 65 deletions(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index d5dbc21..98c3e64 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -179,6 +179,75 @@ window.HttpwareDemo = (function () { return { from, to: Math.min(scenario.dur, to + scenario.dur / steps), label }; } + // ---- shared guided-tour driver: spotlight cutout + side-placed coach ---- + // Extracted from mount() so a second mount mode (herd) reuses one implementation. + // `els` supplies the tour DOM (dimT/dimB/dimL/dimR/ring/coach/cArrow/cStep/ + // cTitle/cBody/cGo). Behavior is identical to the previous inline closures. + function makeTour(els) { + // 4 dim panels leave a bright hole over the union of target rects. + 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')); + } + 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'; } + } + let onContinue = null; + els.cGo.addEventListener('click', () => { + els.coach.classList.remove('show'); hideSpot(); + if (onContinue) onContinue(); + }); + return { + show(stop, stepIdx, total, spotLookup) { + const nodes = stop.spot.map((key) => spotLookup[key]).filter(Boolean); + const hole = spotlight(nodes); + els.cStep.textContent = `Step ${stepIdx + 1} of ${total}`; + els.cTitle.textContent = stop.title; + els.cBody.textContent = stop.body; + placeCoach(hole); + }, + hideAll() { els.coach.classList.remove('show'); hideSpot(); }, + setContinue(fn) { onContinue = fn; }, + }; + } + function template(config) { const scenarioBtns = config.scenarios.map((sc) => `` @@ -272,6 +341,12 @@ window.HttpwareDemo = (function () { coach: $('coach'), cArrow: $('cArrow'), cStep: $('cStep'), cTitle: $('cTitle'), cBody: $('cBody'), cGo: $('cGo'), }; const ELS = { ifA: els.ifA, latA: els.latA, badWrapA: els.badWrapA, ifB: els.ifB, latB: els.latB, badWrapB: els.badWrapB, brkB: els.brkB, poolB: els.poolB, elapsedB: els.elapsedB }; + const tour = makeTour(els); + tour.setContinue(() => { paused = false; stopIdx++; }); + function showStop(s) { + paused = true; + tour.show(s, stopIdx, STOPS.length, ELS); + } // 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 @@ -315,70 +390,6 @@ window.HttpwareDemo = (function () { } } - // ---- 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) { @@ -417,7 +428,7 @@ window.HttpwareDemo = (function () { 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(); + tour.hideAll(); } function updateUI(now, lastFault) { From 704b0ca638d07fd287ca541c1b17ac9bb74aa699 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 17:58:21 +0300 Subject: [PATCH 3/9] feat(demos): add faithful herd simulation and rate-strip renderer Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/demos/engine.js | 90 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 89 insertions(+), 1 deletion(-) diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 98c3e64..91636d6 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -27,6 +27,11 @@ window.HttpwareDemo = (function () { // its OPEN-state stop. const SEED = 103; + // Thundering-herd sim knobs. dt = bucket width (s); reqInterval = per-client + // request spacing (s) so N clients emit a steady baseline; maxNaive is a pure + // safety cap on the unbounded naive retry loop (an outage can't outlast dur). + const HERD = { dt: 0.35, reqInterval: 0.5, maxNaive: 5000 }; + // 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; @@ -248,6 +253,88 @@ window.HttpwareDemo = (function () { }; } + // ---- thundering-herd simulation (faithful; reuses the real RetryBudget model) ---- + // N independent clients hit ONE backend through a sustained outage. Every backend + // attempt (initial + each retry) is counted into a per-bucket series for each lane. + // naive: FIXED backoff, NO jitter, UNBOUNDED retries -> same-bucket failures retry + // into the same later bucket => synchronized spikes with dead gaps. + // httpware: FULL-JITTER backoff + per-client RetryBudget + maxAttempts cap -> + // retry timing decorrelates into a near-constant rate; the budget and the + // attempt cap bound amplification. The budget is PER CLIENT, never shared. + // Arrivals are phase-staggered (not synchronized): the clustering that follows is + // caused by the retry policy, not by synchronized arrivals. + function simulateHerd(scenario, opts) { + const N = opts.clients, cfg = opts.retry, budgetCfg = opts.budget; + const dur = scenario.dur, dt = HERD.dt, ri = HERD.reqInterval; + const buckets = Math.ceil(dur / dt); + const naive = new Array(buckets).fill(0); + const hw = new Array(buckets).fill(0); + const bkt = (t) => Math.min(buckets - 1, Math.max(0, Math.floor(t / dt))); + const rnd = mulberry(SEED); // one deterministic stream drives jitter + any probabilistic fault + for (let c = 0; c < N; c++) { + const phase = (c / N) * ri; // spread arrivals across a request interval + // naive: unbounded fixed-backoff retries, no jitter + for (let t0 = phase; t0 < dur; t0 += ri) { + let t = t0, guard = 0; + while (t < dur) { + naive[bkt(t)]++; + const f = scenario.fault(t, rnd); + if (f.ok) break; + t += cfg.baseDelay; // fixed, no jitter, no attempt cap + if (++guard > HERD.maxNaive) break; + } + } + // httpware: full jitter + per-client budget + maxAttempts + const budget = makeRetryBudget(budgetCfg); + for (let t0 = phase; t0 < dur; t0 += ri) { + let t = t0, attempt = 0; + budget.deposit(t); // deposit once per operation (matches terminal) + hw[bkt(t)]++; + let f = scenario.fault(t, rnd); + while (!f.ok && (attempt + 1) < cfg.maxAttempts && budget.tryWithdraw(t)) { + const backoff = rnd() * Math.min(cfg.maxDelay, cfg.baseDelay * Math.pow(2, attempt)); + t += backoff; + if (t >= dur) break; + attempt++; + hw[bkt(t)]++; + f = scenario.fault(t, rnd); + } + } + } + const baseline = N * (dt / ri); // healthy backend calls per bucket + const stat = (arr) => { + const peak = arr.reduce((m, v) => Math.max(m, v), 0); + const total = arr.reduce((a, b) => a + b, 0); + return { series: arr, peak, total, mult: baseline > 0 ? peak / baseline : 0 }; + }; + return { naive: stat(naive), hw: stat(hw), buckets, dt, baseline, + outage: computeOutageWindow(scenario) }; + } + + // Render a call-rate strip as an SVG of vertical bars into `el`. Buckets [0, + // revealUpTo) are drawn (progressive reveal during playback). peakScale is shared + // across both strips so their heights are comparable and httpware's flatness reads + // honestly against naive's spikes. viewBox is unitless; CSS sizes the strip. + function renderRateStrip(el, series, revealUpTo, opts) { + const n = series.length, W = 100, H = 100, bw = W / n; + const scale = opts.peakScale > 0 ? H / opts.peakScale : 0; + let body = ''; + if (opts.outage) { + const ox = (opts.outage.from / opts.dur) * W; + const ow = ((opts.outage.to - opts.outage.from) / opts.dur) * W; + body += ``; + } + const upto = Math.min(revealUpTo, n); + for (let i = 0; i < upto; i++) { + const h = Math.min(H, series[i] * scale); + if (h <= 0) continue; + body += ``; + } + const by = H - Math.min(H, opts.baseline * scale); + body += ``; + el.innerHTML = `${body}`; + } + function template(config) { const scenarioBtns = config.scenarios.map((sc) => `` @@ -625,5 +712,6 @@ window.HttpwareDemo = (function () { if (firstScenarioBtn) firstScenarioBtn.click(); } - return { mount, _models: { makeCircuitBreaker, makeRetryBudget, makeBulkhead }, _util: { mulberry }, REAL, TICK, ADV }; + return { mount, _models: { makeCircuitBreaker, makeRetryBudget, makeBulkhead, simulateHerd }, + _render: { renderRateStrip }, _util: { mulberry }, REAL, TICK, ADV }; })(); From cd3368ff2860b2de02dab8f61092b377e4d6fcf1 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 18:18:13 +0300 Subject: [PATCH 4/9] feat(demos): add thundering-herd macro view to the retry page One client retrying a blip is invisible; twenty clients retrying a sustained outage is a traffic weapon unless retries are spread out and capped. Adds mountHerd (composing Task 1's tour driver and Task 2's simulateHerd/renderRateStrip) as a stacked "Now 20 clients" section below the existing single-client retry demo, with a mini-tour spotlighting the measured peak-load gap: naive ~70x (unbounded, still growing) vs httpware ~3x (capped by each client's own max_attempts, never a shared budget). Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/demos/demos.css | 11 ++++ docs/demos/engine.js | 121 ++++++++++++++++++++++++++++++++++++++++++- docs/demos/retry.md | 27 ++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/docs/demos/demos.css b/docs/demos/demos.css index 38e6b46..afc36ea 100644 --- a/docs/demos/demos.css +++ b/docs/demos/demos.css @@ -132,3 +132,14 @@ @media (prefers-reduced-motion: reduce) { .hw-demo .playhead, .hw-demo .ring, .hw-demo .box, .hw-demo .lane, .hw-demo .dot { transition: none; } } + +/* thundering-herd rate strips */ +.hw-demo .herd-wrap { margin-top: 26px; padding-top: 22px; border-top: 1px solid var(--hw-line); } +.hw-demo .herd-lane { border: 2px solid var(--hw-line); border-radius: 12px; padding: 14px 16px; margin-bottom: 14px; } +.hw-demo .herd-lane h3 { margin: 0 0 10px; font-size: .95rem; display: flex; align-items: center; gap: 8px; } +.hw-demo .herd-strip { height: 96px; } +.hw-demo .herd-svg { width: 100%; height: 96px; display: block; } +.hw-demo .herd-band { fill: rgba(229, 72, 77, .12); } +.hw-demo .herd-baseline { stroke: var(--hw-muted); stroke-width: .6; stroke-dasharray: 2 2; opacity: .7; } +.hw-demo .herd-bar-naive { fill: var(--hw-bad); } +.hw-demo .herd-bar-hw { fill: var(--hw-ok); } diff --git a/docs/demos/engine.js b/docs/demos/engine.js index 91636d6..0f7af6a 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -335,6 +335,125 @@ window.HttpwareDemo = (function () { el.innerHTML = `${body}`; } + function herdTemplate(config) { + const title = config.title || 'Now scale it to 20 clients'; + const intro = config.intro || 'One client retrying a blip is invisible. Twenty clients retrying a ' + + 'sustained outage is a traffic weapon — unless their retries are spread out and capped. ' + + 'These strips show backend call-rate over time. Press play and watch the shape.'; + return ` +
+

${esc(title)}

+

${intro}

+
+ + +
+ +
+

20 naive clients fixed backoff, no jitter, unbounded

+
+
+ peak load + calls sent 0 +
+
+ +
+

20 httpware clients full jitter + per-client budget

+
+
+ peak load + calls sent 0 +
+
+ +

Faithful model: each httpware client owns its RetryBudget (never shared). Jitter flattens the visible spikes; the budget + max_attempts cap what a longer storm could do.

+
+ +
+
+
+
+
+
Step 1 of 1
+

+ +
`; + } + + function mountHerd(selector, config) { + const container = document.querySelector(selector); + if (!container) return; + container.classList.add('hw-demo'); + container.innerHTML = herdTemplate(config); + const $ = (k) => container.querySelector('[data-el="' + k + '"]'); + const els = { + hplay: $('hplay'), hreplay: $('hreplay'), + naiveStrip: $('naiveStrip'), hwStrip: $('hwStrip'), + naiveMult: $('naiveMult'), naiveMultN: $('naiveMultN'), naiveTotal: $('naiveTotal'), naiveTotalN: $('naiveTotalN'), + hwMult: $('hwMult'), hwMultN: $('hwMultN'), hwTotal: $('hwTotal'), hwTotalN: $('hwTotalN'), + dimT: $('dimT'), dimB: $('dimB'), dimL: $('dimL'), dimR: $('dimR'), ring: $('ring'), + coach: $('coach'), cArrow: $('cArrow'), cStep: $('cStep'), cTitle: $('cTitle'), cBody: $('cBody'), cGo: $('cGo'), + }; + const SPOT = { naiveMult: els.naiveMult, hwMult: els.hwMult, naiveTotal: els.naiveTotal, + hwTotal: els.hwTotal, naiveStrip: els.naiveStrip, hwStrip: els.hwStrip }; + const tour = makeTour(els); + + let timer = null, paused = false, reveal = 0, stopIdx = 0, STOPS = []; + const scenario = config.scenario; + let sim = null; + + function paint() { + const peakScale = Math.max(sim.naive.peak, sim.hw.peak, 1); + const stripOpts = (cls) => ({ peakScale, baseline: sim.baseline, outage: sim.outage, dur: scenario.dur, cls }); + renderRateStrip(els.naiveStrip, sim.naive.series, reveal, stripOpts('herd-bar-naive')); + renderRateStrip(els.hwStrip, sim.hw.series, reveal, stripOpts('herd-bar-hw')); + // running peak/total over revealed buckets so the numbers climb with the bars + const upto = Math.min(reveal, sim.buckets); + const runPeak = (arr) => arr.slice(0, upto).reduce((m, v) => Math.max(m, v), 0); + const runTot = (arr) => arr.slice(0, upto).reduce((a, b) => a + b, 0); + const nm = sim.baseline > 0 ? runPeak(sim.naive.series) / sim.baseline : 0; + const hm = sim.baseline > 0 ? runPeak(sim.hw.series) / sim.baseline : 0; + els.naiveMultN.textContent = nm.toFixed(1) + '×'; + els.hwMultN.textContent = hm.toFixed(1) + '×'; + els.naiveTotalN.textContent = String(runTot(sim.naive.series)); + els.hwTotalN.textContent = String(runTot(sim.hw.series)); + // httpware is bounded by max_attempts, not a hard multiplier cap — it settles + // near ~3x for this scenario, never near naive's unbounded ~70x. Thresholds + // reflect that measured gap, not the placeholder "<=2x is good" assumption. + els.naiveMultN.style.color = nm > 5 ? 'var(--hw-bad)' : ''; + els.hwMultN.style.color = hm <= 4 ? 'var(--hw-ok)' : ''; + } + + function reset() { + clearInterval(timer); reveal = 0; stopIdx = 0; paused = false; + sim = simulateHerd(scenario, { clients: config.clients, retry: config.retry, budget: config.budget }); + STOPS = config.buildStops(sim); + tour.hideAll(); + paint(); + } + tour.setContinue(() => { paused = false; stopIdx++; }); + + function run() { + reset(); + timer = setInterval(() => { + if (paused) return; + const state = { bucket: reveal, revealed: reveal, sim, + naiveShown: sim.naive.series.slice(0, reveal).reduce((a, b) => a + b, 0), + hwShown: sim.hw.series.slice(0, reveal).reduce((a, b) => a + b, 0) }; + if (stopIdx < STOPS.length && STOPS[stopIdx].when(state)) { + paused = true; tour.show(STOPS[stopIdx], stopIdx, STOPS.length, SPOT); return; + } + reveal++; + paint(); + if (reveal > sim.buckets) clearInterval(timer); + }, TICK); + } + els.hplay.addEventListener('click', run); + els.hreplay.addEventListener('click', run); + reset(); // first paint shows the empty strips + baseline, ready to play + } + function template(config) { const scenarioBtns = config.scenarios.map((sc) => `` @@ -712,6 +831,6 @@ window.HttpwareDemo = (function () { if (firstScenarioBtn) firstScenarioBtn.click(); } - return { mount, _models: { makeCircuitBreaker, makeRetryBudget, makeBulkhead, simulateHerd }, + return { mount, mountHerd, _models: { makeCircuitBreaker, makeRetryBudget, makeBulkhead, simulateHerd }, _render: { renderRateStrip }, _util: { mulberry }, REAL, TICK, ADV }; })(); diff --git a/docs/demos/retry.md b/docs/demos/retry.md index c7cbb99..2ef8999 100644 --- a/docs/demos/retry.md +++ b/docs/demos/retry.md @@ -7,6 +7,10 @@ a dead backend can't be amplified.
+

That was one client. The real danger of blind retries shows up at scale — when many clients retry the same outage at once.

+ +
+ From e8e9f632c912bfff45bcc482a30c6bb03b9e3449 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sun, 19 Jul 2026 18:30:04 +0300 Subject: [PATCH 5/9] feat(demos): add countdown ring and jagged failure shape to lane pages CSS-driven conic countdown ring on the timeout and circuit-breaker pages' fixed lane-B elements (elapsedB / brkB), showing the multi-second deadline and reset-timeout waits. Retry and bulkhead get no ring per the fidelity decision (backoff is sub-tick at this demo's time compression). All pages gain a jagged clip-path failure dot so outcomes read without color. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/demos/circuit-breaker.md | 1 + docs/demos/demos.css | 15 +++++++++++++++ docs/demos/engine.js | 22 ++++++++++++++++++++++ docs/demos/timeout.md | 1 + 4 files changed, 39 insertions(+) diff --git a/docs/demos/circuit-breaker.md b/docs/demos/circuit-breaker.md index cd98ebb..d526e03 100644 --- a/docs/demos/circuit-breaker.md +++ b/docs/demos/circuit-breaker.md @@ -10,6 +10,7 @@ and probing for recovery.