test(hedge): branch coverage >=80% on src/hedge + fix dangling rejection#119
Conversation
Increases src/hedge/index.ts branch coverage from 65.71% to 91.42% by adding 15 scenarios: loser-abort propagation, max cap enforcement, external signal aborting the delayMs wait, settled-break short-circuit, single-endpoint degrade, init forwarding, joinUrl combinations, late winner after settlement, network-error failover, and the HedgeLoserError constructor surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Bench results
Local Node HTTP server, Apple-class GitHub runner. Numbers fluctuate ±2-3% from runner heat alone — only sustained >5% deltas are signal. |
…Ms wait
When the external `options.signal` aborts during the `await` between
firing endpoints, `launch`'s timer-await rejects before
`Promise.any(winners)` is reached. The in-flight primary's promise
(rejected because misina sees the composed signal abort) had no
subscriber yet, so Node logged it as `unhandledRejection`.
Attach a no-op `.catch(() => {})` to each dispatched promise at
creation time. The original promise still rejects normally for
`Promise.any` downstream; the sibling handler just satisfies Node's
unhandled-rejection requirement.
Update the regression test to assert no `unhandledRejection` fires
(previously it merely swallowed the dangling rejection).
|
LGTM. Coverage lifted to branches 91.42% / lines 100% / funcs 100%. Spot-checked test assertions: mocks are tight (specific URLs, specific status codes, response bodies), assertions are specific (exact |
…ion (#119) * test(hedge): cover concurrent abort / failover / timeout branches Increases src/hedge/index.ts branch coverage from 65.71% to 91.42% by adding 15 scenarios: loser-abort propagation, max cap enforcement, external signal aborting the delayMs wait, settled-break short-circuit, single-endpoint degrade, init forwarding, joinUrl combinations, late winner after settlement, network-error failover, and the HedgeLoserError constructor surface. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hedge): silence dangling rejection on external abort during delayMs wait When the external `options.signal` aborts during the `await` between firing endpoints, `launch`'s timer-await rejects before `Promise.any(winners)` is reached. The in-flight primary's promise (rejected because misina sees the composed signal abort) had no subscriber yet, so Node logged it as `unhandledRejection`. Attach a no-op `.catch(() => {})` to each dispatched promise at creation time. The original promise still rejects normally for `Promise.any` downstream; the sibling handler just satisfies Node's unhandled-rejection requirement. Update the regression test to assert no `unhandledRejection` fires (previously it merely swallowed the dangling rejection). --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Lifts branch coverage on
src/hedge/index.tsfrom 65.71% to 91.42% (target was >=80%) AND fixes a real unhandled-rejection bug uncovered while writing the tests.Coverage delta
Bug fix (
fix(hedge):commit)When the external
options.signalaborted during theawait new Promise(...)fordelayMs, the launch loop's timer-await rejected beforePromise.any(winners)was reached. The in-flight primary's promise (rejected because misina sees the composed signal abort viaAbortSignal.any) had no subscriber yet, so Node logged it asunhandledRejection.The fix attaches a no-op
.catch(() => {})to each dispatched promise at creation time. The original promise still rejects normally forPromise.anydownstream; the sibling handler just satisfies Node's unhandled-rejection requirement.The regression test (
external signal aborts the delay timer between firings) now asserts nounhandledRejectionfires (previously it merely swallowed the dangling rejection).Scenarios added
maxcap truncates the endpoint pool to the requested count.delayMswait propagates AND no longer leaks an unhandled rejection.delayMs > 0with primary settling in-window hits theif (settled) breakshort-circuit.delayMs > 0with primary still in-flight at deadline fires the backup.init(headers) forwards to each dispatched request.composeOptional(undefined, internalAc)branch (no external signal).joinUrlcovers all four cases: trailing-slash + leading-slash, no slashes, base-only-slash, absolute URL bypass.HedgeLoserErrorconstructor exposesnameand message.settledflips throwsHedgeLoserError("not-the-winner").Unreachable branches (remaining ~8.6%)
Three defensive code paths cannot be reached without modifying
src/hedge/index.ts:nonLoser ?? aggregate.errors[0]fallback. Only fires when every aggregated error is aHedgeLoserError. ButHedgeLoserError("not-the-winner")is only thrown fromdispatchAtwhensettledis already true — meaning some other dispatch resolved successfully andPromise.anyresolves rather than rejects.primary instanceof Errorfalse branch. Allfetch/misina rejections areErrorsubclasses by contract.composeOptional'sif (!b) return a.bis alwaysac.signal(an internalAbortControllersignal), never undefined at the call site.Test plan
pnpm vitest run test/hedge.test.ts— 20/20 passsrc/hedge/**measured before/afterpnpm vitest run— 910/910 passpnpm typecheckcleanpnpm lintclean fortest/hedge.test.ts(5 pre-existing warnings elsewhere untouched)unhandledRejectioncount: 1 before fix, 0 afteraudit-fixes.test.ts > TimeoutError carries the configured timeoutflake filed as Flaky test: audit-fixes 'TimeoutError carries the configured timeout' fails ~1/N runs #120 (unrelated)