Skip to content

Commit e5d98a2

Browse files
lesnik512claude
andcommitted
fix(demos): flapping herd outage for faithful spikes-with-gaps
The herd's sustained outage produced a smooth rising ramp for independent, un-synchronized naive clients, not the spikes-with-gaps the tour copy claims. Switch the model input to a flapping backend (down/recover x3) so unbounded retries genuinely surge on each dip and clear on recovery -- real spikes-with-gaps, faithfully sourced from backend recovery rather than client synchronization. The existing simulateHerd algorithm already produces this shape once the fault flaps; this is a scenario + rendering + copy change. Add computeOutageBands so the rate strip shades each dip separately, leaving recovery gaps visibly unshaded between them (falls back to the single outage window for callers that don't pass bands, so the full-stack macro strip is unaffected). Re-measure the naive multiplier against the flapping scenario (~18x, down from the sustained model's ~70x since each dip is shorter) and reconcile retry.md's copy and the change-file's motivation/design/payoff language to match. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 84a7fe0 commit e5d98a2

3 files changed

Lines changed: 84 additions & 54 deletions

File tree

‎docs/demos/engine.js‎

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,24 @@ window.HttpwareDemo = (function () {
184184
return { from, to: Math.min(scenario.dur, to + scenario.dur / steps), label };
185185
}
186186

187+
// All contiguous "backend in trouble" intervals (down or slow), for a strip that
188+
// shades each outage window separately — so a flapping backend's recovery GAPS stay
189+
// visibly unshaded between the shaded dips. Decorative; never drives the sim.
190+
function computeOutageBands(scenario) {
191+
const steps = 400, worst = () => 0;
192+
const bands = [];
193+
let start = null;
194+
for (let i = 0; i <= steps; i++) {
195+
const t = (scenario.dur * i) / steps;
196+
const f = scenario.fault(t, worst);
197+
const down = !f.ok || (f.ok && f.ms >= 1.0);
198+
if (down && start === null) start = t;
199+
else if (!down && start !== null) { bands.push({ from: start, to: t }); start = null; }
200+
}
201+
if (start !== null) bands.push({ from: start, to: scenario.dur });
202+
return bands;
203+
}
204+
187205
// ---- shared guided-tour driver: spotlight cutout + side-placed coach ----
188206
// Extracted from mount() so a second mount mode (herd) reuses one implementation.
189207
// `els` supplies the tour DOM (dimT/dimB/dimL/dimR/ring/coach/cArrow/cStep/
@@ -254,15 +272,17 @@ window.HttpwareDemo = (function () {
254272
}
255273

256274
// ---- thundering-herd simulation (faithful; reuses the real RetryBudget model) ----
257-
// N independent clients hit ONE backend through a sustained outage. Every backend
258-
// attempt (initial + each retry) is counted into a per-bucket series for each lane.
259-
// naive: FIXED backoff, NO jitter, UNBOUNDED retries -> same-bucket failures retry
260-
// into the same later bucket => synchronized spikes with dead gaps.
275+
// N independent clients hit ONE backend through a FLAPPING outage (down, recover,
276+
// down, recover...). Every backend attempt (initial + each retry) is counted into a
277+
// per-bucket series for each lane.
278+
// naive: FIXED backoff, NO jitter, UNBOUNDED retries -> during each outage dip a
279+
// client retries until the backend recovers, so retries ACCUMULATE into a
280+
// surge that clears the instant the dip ends (its retry succeeds) -> a spike
281+
// per dip with a recovery gap between. The gaps are the backend healing, not
282+
// client synchronization (arrivals are phase-staggered, never lockstep).
261283
// httpware: FULL-JITTER backoff + per-client RetryBudget + maxAttempts cap ->
262284
// retry timing decorrelates into a near-constant rate; the budget and the
263285
// attempt cap bound amplification. The budget is PER CLIENT, never shared.
264-
// Arrivals are phase-staggered (not synchronized): the clustering that follows is
265-
// caused by the retry policy, not by synchronized arrivals.
266286
function simulateHerd(scenario, opts) {
267287
const N = opts.clients, cfg = opts.retry, budgetCfg = opts.budget;
268288
const dur = scenario.dur, dt = HERD.dt, ri = HERD.reqInterval;
@@ -308,7 +328,7 @@ window.HttpwareDemo = (function () {
308328
return { series: arr, peak, total, mult: baseline > 0 ? peak / baseline : 0 };
309329
};
310330
return { naive: stat(naive), hw: stat(hw), buckets, dt, baseline,
311-
outage: computeOutageWindow(scenario) };
331+
outage: computeOutageWindow(scenario), outageBands: computeOutageBands(scenario) };
312332
}
313333

314334
// Render a call-rate strip as an SVG of vertical bars into `el`. Buckets [0,
@@ -319,9 +339,10 @@ window.HttpwareDemo = (function () {
319339
const n = series.length, W = 100, H = 100, bw = W / n;
320340
const scale = opts.peakScale > 0 ? H / opts.peakScale : 0;
321341
let body = '';
322-
if (opts.outage) {
323-
const ox = (opts.outage.from / opts.dur) * W;
324-
const ow = ((opts.outage.to - opts.outage.from) / opts.dur) * W;
342+
const bands = opts.outageBands || (opts.outage ? [opts.outage] : []);
343+
for (const b of bands) {
344+
const ox = (b.from / opts.dur) * W;
345+
const ow = ((b.to - b.from) / opts.dur) * W;
325346
body += `<rect x="${ox.toFixed(2)}" y="0" width="${ow.toFixed(2)}" height="${H}" class="herd-band"/>`;
326347
}
327348
const upto = Math.min(revealUpTo, n);
@@ -416,7 +437,7 @@ window.HttpwareDemo = (function () {
416437

417438
function paint() {
418439
const peakScale = Math.max(sim.naive.peak, sim.hw.peak, 1);
419-
const stripOpts = (cls) => ({ peakScale, baseline: sim.baseline, outage: sim.outage, dur: scenario.dur, cls });
440+
const stripOpts = (cls) => ({ peakScale, baseline: sim.baseline, outage: sim.outage, outageBands: sim.outageBands, dur: scenario.dur, cls });
420441
renderRateStrip(els.naiveStrip, sim.naive.series, reveal, stripOpts('herd-bar-naive'));
421442
renderRateStrip(els.hwStrip, sim.hw.series, reveal, stripOpts('herd-bar-hw'));
422443
// running peak/total over revealed buckets so the numbers climb with the bars
@@ -430,7 +451,7 @@ window.HttpwareDemo = (function () {
430451
els.naiveTotalN.textContent = String(runTot(sim.naive.series));
431452
els.hwTotalN.textContent = String(runTot(sim.hw.series));
432453
// httpware is bounded by max_attempts, not a hard multiplier cap — it settles
433-
// near ~3x for this scenario, never near naive's unbounded ~70x. Thresholds
454+
// near ~3x for this scenario, never near naive's per-dip ~18x. Thresholds
434455
// reflect that measured gap, not the placeholder "<=2x is good" assumption.
435456
els.naiveMultN.style.color = nm > 5 ? 'var(--hw-bad)' : '';
436457
els.hwMultN.style.color = hm <= 4 ? 'var(--hw-ok)' : '';

‎docs/demos/retry.md‎

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,27 @@ document.addEventListener('DOMContentLoaded', function () {
4747

4848
HttpwareDemo.mountHerd('#retry-herd', {
4949
clients: 20,
50+
intro: 'A real backend rarely dies cleanly — it <b>flaps</b>: fails, recovers, fails again. These strips show <b>backend call-rate over time</b> for twenty clients through three dips. Press play and watch the shape.',
5051
scenario: { id: 'storm', dur: 12.5,
51-
fault: (now) => (now >= 2.0 && now < 9.0)
52-
? { ok: false, ms: 0.05, label: 'DOWN' } : { ok: true, ms: 0.05 } },
52+
fault: (now) => {
53+
const down = (now >= 2.0 && now < 4.0) || (now >= 5.5 && now < 7.5) || (now >= 9.0 && now < 11.0);
54+
return down ? { ok: false, ms: 0.05, label: 'DOWN' } : { ok: true, ms: 0.05 };
55+
} },
5356
retry: { maxAttempts: 3, baseDelay: 0.1, maxDelay: 5.0 },
5457
budget: { ttl: 10.0, minRetriesPerSec: 10.0, percentCanRetry: 0.2 },
5558
buildStops: (sim) => [
5659
{ when: (s) => s.revealed >= Math.round(2.2 / sim.dt), spot: ['naiveStrip', 'hwStrip'],
57-
title: 'The outage hits both herds',
58-
body: 'Twenty naive clients and twenty httpware clients face the same sustained outage. Every failed request wants to retry. Watch what each herd does to the backend call-rate.' },
59-
{ when: (s) => s.revealed >= Math.round(4.0 / sim.dt), spot: ['naiveStrip'],
60-
title: 'Naive: synchronized spikes',
61-
body: 'All twenty naive clients back off the SAME fixed delay, so a batch that fails together retries together — the failures never spread out. Tall spikes with dead gaps between: the retry storm hammering a backend that is already down.' },
62-
{ when: (s) => s.revealed >= Math.round(6.5 / sim.dt), spot: ['hwStrip'],
63-
title: 'httpware: a near-flat rate',
64-
body: 'Full jitter scatters each client’s retries across the window instead of stacking them, and each client’s own budget + max_attempts cap how much it can add — so the aggregate holds at a low, steady few-times-baseline instead of exploding.' },
60+
title: 'A flapping backend',
61+
body: 'The backend drops, recovers, drops again — three dips (shaded). Every failed request wants to retry. Watch what each herd does to the backend call-rate through the dips.' },
62+
{ when: (s) => s.revealed >= Math.round(4.6 / sim.dt), spot: ['naiveStrip'],
63+
title: 'Naive: a surge on every dip',
64+
body: 'On each dip, twenty clients retry unbounded — the load piles up into a surge that keeps climbing until the backend recovers, then clears. Three dips, three spikes, with recovery gaps between: the retry storm hitting a backend every time it tries to come back.' },
65+
{ when: (s) => s.revealed >= Math.round(8.2 / sim.dt), spot: ['hwStrip'],
66+
title: 'httpware: flat through every dip',
67+
body: 'Full jitter spreads each client’s retries out, and each client’s own max_attempts=3 cap (with its per-client budget as the guarantee at higher volume) limits how much it can add — so httpware holds a low, steady few-times-baseline through every dip instead of spiking.' },
6568
{ when: (s) => s.revealed >= sim.buckets - 1, spot: ['naiveMult', 'hwMult'],
6669
title: 'Peak load: the whole point',
67-
body: 'The naive herd peaked at about 70× the healthy load — and keeps climbing the longer the outage runs, because its retries are unbounded. httpware peaked at about 3×: at this client count, that ceiling comes from each client’s own max_attempts=3 cap (jitter just spreads the retries flat instead of stacking them). The per-client budget is what holds the line as the herd grows even larger. Same outage, same client count — a flat rate also leaves the backend room to recover.' },
70+
body: 'On its worst dip the naive herd spiked to about 18× the healthy load; httpware never exceeded about 3×, capped by each client’s max_attempts. That flat rate is exactly what lets the backend recover in the gaps — instead of being knocked back down by a retry surge every time it heals.' },
6871
],
6972
});
7073
});

‎planning/changes/2026-07-19.02-resilience-demo-herd-and-enrich.md‎

Lines changed: 37 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
summary: Added a many-client thundering-herd macro view to the retry page (naive ~70x vs httpware ~3x peak backend load, httpware bounded by max_attempts at demo scale with a per-client budget) and folded three enrich techniques across the demo suite — a backoff countdown ring on timeout + circuit-breaker only (retry's sub-tick backoff makes a faithful ring there impossible, so it's dropped, not faked), a color-independent jagged failure shape everywhere, and a live backend call-rate macro strip with active-phase label on the full-stack page.
2+
summary: Added a many-client thundering-herd macro view to the retry page (naive ~18x vs httpware ~3x peak backend load through a flapping outage, httpware bounded by max_attempts at demo scale with a per-client budget) and folded three enrich techniques across the demo suite — a backoff countdown ring on timeout + circuit-breaker only (retry's sub-tick backoff makes a faithful ring there impossible, so it's dropped, not faked), a color-independent jagged failure shape everywhere, and a live backend call-rate macro strip with active-phase label on the full-stack page.
33
---
44

55
# Design: Resilience demo herd view + enrich pass
@@ -9,7 +9,7 @@ summary: Added a many-client thundering-herd macro view to the retry page (naive
99
Improve the resilience demo suite (shipped in `2026-07-18.02`) using techniques
1010
proven by the best existing visualizations. Two parts: (1) a **thundering-herd
1111
macro view** stacked below the single-client retry/budget demo — 20 naive clients
12-
vs 20 httpware clients, rate-into-the-backend over a sustained outage, so the
12+
vs 20 httpware clients, rate-into-the-backend over a flapping outage, so the
1313
retry storm is *shown* not just described; (2) an **enrich pass** folding three
1414
smaller techniques into the existing pages — a backoff/wait **countdown ring**, a
1515
color-independent **jagged failure shape**, and a small **macro strip** on the
@@ -24,11 +24,11 @@ post](https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/)
2424
exposes one real gap and a few cheap wins:
2525

2626
- **The thundering herd is invisible.** Our retry/budget demo is single-client, so
27-
the one visual that makes "retry storm" and "why jitter" click — many clients
28-
synchronizing into traffic spikes, jitter smoothing them into a flat rate — we
29-
only assert in prose. AWS's canonical framing is a call-rate-over-time chart:
30-
without jitter, "clusters of calls" with dead gaps; with jitter, an approximately
31-
constant rate. We don't draw it.
27+
the one visual that makes "retry storm" and "why jitter" click — a flapping backend
28+
driving unbounded retries into spikes-with-gaps, jitter smoothing them into a flat
29+
rate — we only assert in prose. AWS's canonical framing is a call-rate-over-time
30+
chart: without jitter, "clusters of calls" with dead gaps; with jitter, an
31+
approximately constant rate. We don't draw it.
3232
- **The backoff wait isn't shown.** A dot just eventually resolves; Encore renders a
3333
countdown timer *while a client waits between attempts*, making the delay tangible.
3434
- **Failures lean on color.** A failed request is a flat square distinguished mainly
@@ -41,17 +41,19 @@ single-client side-by-side, a distinct "Now scale it to 20 clients" section with
4141
own play button and mini-tour, preserving a micro → macro arc down the page (one
4242
client's retries, then the herd). A new engine **rate-strip render mode** (isolated
4343
from the existing lane renderer, selected by mount config) draws two stacked strips
44-
plotting calls-into-the-backend per tick over a *sustained* outage window:
45-
46-
- **Naive lane:** 20 clients, fixed backoff, no jitter, unbounded retries → their
47-
retries stay clustered into spikes separated by gaps (AWS's finding; we model
48-
clustering, not literal lockstep — arrivals are spread, fixed backoff *preserves*
49-
the clustering).
44+
plotting calls-into-the-backend per tick over a **flapping** outage — the backend
45+
goes down, recovers, goes down again, three dips with real recovery gaps between:
46+
47+
- **Naive lane:** 20 clients, fixed backoff, no jitter, unbounded retries → on each
48+
dip a client retries until the backend recovers, so retries accumulate into a
49+
surge that clears the instant the dip ends — a spike per dip with a genuine
50+
recovery gap between (AWS's finding; the gaps come from the backend healing, not
51+
from client synchronization — arrivals are phase-staggered, never lockstep).
5052
- **httpware lane:** 20 clients, full-jitter backoff + per-client budget → jitter
51-
decorrelates retry timing into a near-constant rate; at this client count each
52-
client's `max_attempts=3` cap is what bounds its amplification (the per-client
53-
budget's floor doesn't bind until far higher per-client volume — it's the guarantee
54-
that holds as the herd grows).
53+
decorrelates retry timing into a near-constant rate through every dip; at this
54+
client count each client's `max_attempts=3` cap is what bounds its amplification
55+
(the per-client budget's floor doesn't bind until far higher per-client volume —
56+
it's the guarantee that holds as the herd grows).
5557

5658
Both lanes reuse the *real* retry + budget models from the engine's source-of-truth
5759
constants block, one model instance per simulated client. **Fidelity framing that
@@ -63,15 +65,16 @@ imply a shared/global budget or that the budget (rather than `max_attempts`) doe
6365
capping at this scale.
6466

6567
**Payoff metric.** The mini-tour spotlights the **peak load multiplier** — "backend
66-
saw N× baseline". Measured against the fixed seed: **naive ~70×** (unbounded retries
67-
explode and keep climbing with outage length) **vs httpware ~3×** (bounded by
68-
`max_attempts` at this client count) — the number reads directly off the tallest
69-
spike the strip already draws (spotlight number == visible fact). **Total calls**
70-
during the outage is a secondary, un-spotlighted counter (volume story). "The flat
71-
rate leaves the backend room to recover" is the qualitative closing beat, *not* a
72-
spotlighted stat — httpware's code models no backend-recovery dynamic, so no invented
73-
recovery number. ~4 beats: outage hits → naive's first clustered spike → httpware
74-
stays flat (jitter + the `max_attempts` cap) → peak-multiplier payoff.
68+
saw N× baseline". Measured against the fixed seed on the flapping scenario: **naive
69+
~18×** on its worst dip (unbounded retries surge each time the backend drops) **vs
70+
httpware ~3×** (bounded by `max_attempts` at this client count) — the number reads
71+
directly off the tallest spike the strip already draws (spotlight number == visible
72+
fact). **Total calls** during the outage is a secondary, un-spotlighted counter
73+
(volume story). "The flat rate leaves the backend room to recover" is the qualitative
74+
closing beat, grounded in the scenario itself (the backend genuinely recovers between
75+
dips) rather than an invented recovery number. ~4 beats: the flapping backend →
76+
naive's first dip-spike → httpware stays flat (jitter + the `max_attempts` cap) →
77+
peak-multiplier payoff.
7578

7679
**Enrich pass.**
7780

@@ -109,18 +112,21 @@ size. Jagged failure shape is one `demos.css` change.
109112
`node --check docs/demos/engine.js`. Scratchpad jsdom harnesses extended: the
110113
existing `verify-demos.js` / `verify-real-page.js` drive every stop including the new
111114
herd mini-tour to completion without stalling; a herd trace asserts naive peak
112-
multiplier ≫ httpware peak (dramatic gap, ~70× vs ~3×), httpware bounded near the
113-
`max_attempts` cap, and a fine bucket count for a smooth strip. Manual: retry page
115+
multiplier ≫ httpware peak (dramatic gap, ~18× vs ~3×) with the spikes-with-gaps
116+
shape itself (multiple separated naive spikes, one per flapping dip, with
117+
near-baseline recovery gaps between), httpware bounded near the `max_attempts` cap,
118+
and a fine bucket count for a smooth strip. Manual: retry page
114119
reads micro → macro top-to-bottom under mkdocs-material light and dark; countdown
115120
ring animates and empties on CB / timeout; failure shape distinguishable in
116121
grayscale; `prefers-reduced-motion` degrades to stepped updates.
117122

118123
## Risk
119124

120125
- **Herd fidelity overclaim (medium × medium).** Easy to imply lockstep
121-
synchronization or a shared budget. Mitigation: model clustering (not lockstep),
122-
per-client budget instances, and copy reviewed specifically against the fidelity
123-
framing above; reuse the real models rather than a bespoke herd approximation.
126+
synchronization or a shared budget. Mitigation: spikes-with-gaps come from the
127+
flapping backend's dip/recovery cycle (not lockstep), per-client budget instances,
128+
and copy reviewed specifically against the fidelity framing above; reuse the real
129+
models rather than a bespoke herd approximation.
124130
- **Rate-strip renderer as new surface (low × medium).** A second render mode adds
125131
engine branching and bug surface. Mitigation: isolate it from the lane renderer
126132
behind mount config; the full-stack macro strip reuses it, so one renderer serves

0 commit comments

Comments
 (0)