diff --git a/docs/demos/demos.css b/docs/demos/demos.css index 27365c1..38e6b46 100644 --- a/docs/demos/demos.css +++ b/docs/demos/demos.css @@ -96,6 +96,7 @@ .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 .fail-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; } diff --git a/docs/demos/engine.js b/docs/demos/engine.js index d5a764d..d5dbc21 100644 --- a/docs/demos/engine.js +++ b/docs/demos/engine.js @@ -213,7 +213,7 @@ window.HttpwareDemo = (function () {
in-flight 0 0 - 0 + ✗ failed 0 p99 40ms
@@ -229,7 +229,7 @@ window.HttpwareDemo = (function () {
in-flight 0 0 - 0 + ✗ failed 0 ⏻ fast-failed 0 @@ -262,16 +262,16 @@ window.HttpwareDemo = (function () { 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'), + ifA: $('ifA'), okA: $('okA'), badA: $('badA'), badWrapA: $('badWrapA'), latA: $('latA'), laneB: $('laneB'), badgeB: $('badgeB'), srvB: $('srvB'), trackB: $('trackB'), brkB: $('brkB'), - ifB: $('ifB'), okB: $('okB'), badB: $('badB'), rejB: $('rejB'), latB: $('latB'), + ifB: $('ifB'), okB: $('okB'), badB: $('badB'), badWrapB: $('badWrapB'), rejB: $('rejB'), latB: $('latB'), poolWrap: $('poolWrap'), poolB: $('poolB'), elapsedWrap: $('elapsedWrap'), elapsedB: $('elapsedB'), note: $('note'), dimT: $('dimT'), dimB: $('dimB'), dimL: $('dimL'), dimR: $('dimR'), ring: $('ring'), coach: $('coach'), cArrow: $('cArrow'), cStep: $('cStep'), cTitle: $('cTitle'), cBody: $('cBody'), cGo: $('cGo'), }; - const ELS = { ifA: els.ifA, latA: els.latA, ifB: els.ifB, latB: els.latB, brkB: els.brkB, poolB: els.poolB, elapsedB: els.elapsedB }; + 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 }; // 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 @@ -401,7 +401,7 @@ window.HttpwareDemo = (function () { 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.latA, els.latB, els.ifA, els.ifB, els.badA, els.badB].forEach((n) => { n.style.color = ''; }); // Flow-diagram boxes reflect the selected scenario's CHAIN config, not the runtime // middleware objects (which aren't constructed until run()). Otherwise the breaker // box reads "no breaker" on a circuit-breaker page until the first Play. @@ -425,6 +425,8 @@ 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)' : ''; + els.badA.style.color = A.bad > 0 ? 'var(--hw-bad)' : ''; + els.badB.style.color = B.bad > 0 ? 'var(--hw-bad)' : ''; if (bulk) els.poolB.textContent = bulk.inUse + '/' + bulk.max; let maxElapsed = 0; if (tmoCfg) { diff --git a/docs/demos/retry.md b/docs/demos/retry.md index c58eb30..c7cbb99 100644 --- a/docs/demos/retry.md +++ b/docs/demos/retry.md @@ -12,7 +12,7 @@ document.addEventListener('DOMContentLoaded', function () { HttpwareDemo.mount('#retry-demo', { scenarios: [ { id: 'blip', label: 'Brief blip (recovers)', dur: 12.5, - fault: (now, rnd) => (now >= 2.0 && now < 3.2) + fault: (now, rnd) => (now >= 2.0 && now < 2.4) ? { ok: false, ms: 0.05, label: 'blip' } : { ok: true, ms: 0.05 }, chainB: { retry: { maxAttempts: 3, baseDelay: 0.1, maxDelay: 5.0 }, budget: { ttl: 10.0, minRetriesPerSec: 10.0, percentCanRetry: 0.2 } } }, @@ -32,12 +32,12 @@ document.addEventListener('DOMContentLoaded', function () { { 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.' }, + { when: (s) => s.now >= 1.2, spot: ['badWrapA', 'badWrapB'], title: 'A backend blip appears', + body: 'Both clients are about to hit the same transient errors. Keep your eye on the ✗ failed counts — they start equal at zero.' }, + { when: (s) => s.now >= 2.4, spot: ['badWrapA', 'badWrapB'], title: 'Plain surfaces every error; httpware retries', + body: 'The plain client surfaces each error straight to the caller — its ✗ is climbing. httpware retries with backoff, so most of these recover on attempt 2 or 3 and its ✗ barely moves.' }, + { when: (s) => s.now >= 10.0, spot: ['badWrapA', 'badWrapB'], title: 'Blip over — mind the ✗ gap', + body: 'The backend healed. Compare the ✗ failed counts: the plain client surfaced far more failures than httpware. That gap is exactly what retry buys you on a transient blip.' }, ], }); }); diff --git a/planning/changes/2026-07-19.01-retry-demo-failures-visibility.md b/planning/changes/2026-07-19.01-retry-demo-failures-visibility.md new file mode 100644 index 0000000..3a2c70e --- /dev/null +++ b/planning/changes/2026-07-19.01-retry-demo-failures-visibility.md @@ -0,0 +1,41 @@ +--- +summary: Make the retry demo's failure count the visible payoff — prominent ✗ stat, spotlighted at the blip stops, with a punchier recover-vs-surface contrast. +--- + +# Change: Make retry demo failures count obvious + +**Lane:** lightweight — docs demo only, no capability contract moves. + +## Goal + +On the retry demo the whole point is that httpware surfaces *fewer failures* to +the caller than a plain client. But the ✗ failure count is rendered in the same +muted style as every label, the blip stops spotlight the in-flight counters +(while the copy says "compare the ✗ counts"), and the 1.2 s blip lets httpware +recover only ~5 of ~14 failures — a weak contrast. Result: the failure count, +the metric that matters, is not obvious. + +## Approach + +- Make the ✗ failure count a prominent stat (larger number, red when > 0), + wrapped in a spotlightable element, on all pages. +- Register the failure-stat wrappers in the engine's spotlight lookup so stops + can point at them. +- Retry blip stops spotlight the ✗ counts (plain rising vs httpware staying + low) instead of in-flight. +- Tighten the blip window so httpware recovers nearly all of it, making the + recover-vs-surface contrast dramatic (verified by trace). + +## Files + +- `docs/demos/engine.js` — failure-stat wrappers in the template + ELS lookup; + colour ✗ count red when > 0. +- `docs/demos/demos.css` — prominent failure-number style. +- `docs/demos/retry.md` — blip stops spotlight the ✗ counts; tighten the blip. + +## Verification + +- [ ] `node --check docs/demos/engine.js`. +- [ ] `just docs-build` clean; scratchpad `verify-real-page.js` + `verify-demos.js` green. +- [ ] Trace the blip: plain ✗ high, httpware ✗ low (dramatic gap); sustained still + hits `budgetExhausted`; every stop reachable.