Skip to content

test(hedge): branch coverage >=80% on src/hedge + fix dangling rejection#119

Merged
productdevbook merged 2 commits into
mainfrom
test/hedge-coverage
May 3, 2026
Merged

test(hedge): branch coverage >=80% on src/hedge + fix dangling rejection#119
productdevbook merged 2 commits into
mainfrom
test/hedge-coverage

Conversation

@productdevbook
Copy link
Copy Markdown
Owner

@productdevbook productdevbook commented May 3, 2026

Summary

Lifts branch coverage on src/hedge/index.ts from 65.71% to 91.42% (target was >=80%) AND fixes a real unhandled-rejection bug uncovered while writing the tests.

Coverage delta

Metric Before After
Statements 82.14% 98.24%
Branches 65.71% 91.42%
Functions 91.66% 100%
Lines 88.88% 100%

Bug fix (fix(hedge): commit)

When the external options.signal aborted during the await new Promise(...) for delayMs, the launch loop's timer-await rejected before Promise.any(winners) was reached. The in-flight primary's promise (rejected because misina sees the composed signal abort via AbortSignal.any) had no subscriber yet, so Node logged it as unhandledRejection.

The fix attaches 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.

The regression test (external signal aborts the delay timer between firings) now asserts no unhandledRejection fires (previously it merely swallowed the dangling rejection).

Scenarios added

  • Loser AbortController fires when the winner settles first.
  • max cap truncates the endpoint pool to the requested count.
  • External signal aborting during the delayMs wait propagates AND no longer leaks an unhandled rejection.
  • delayMs > 0 with primary settling in-window hits the if (settled) break short-circuit.
  • delayMs > 0 with primary still in-flight at deadline fires the backup.
  • Single endpoint degrades to a plain misina call.
  • init (headers) forwards to each dispatched request.
  • composeOptional(undefined, internalAc) branch (no external signal).
  • Pre-aborted external signal rejects the entire hedge.
  • joinUrl covers all four cases: trailing-slash + leading-slash, no slashes, base-only-slash, absolute URL bypass.
  • HedgeLoserError constructor exposes name and message.
  • Network error on first endpoint, second wins.
  • Late-winner path: a slow request that resolves after settled flips throws HedgeLoserError("not-the-winner").
  • All-fail aggregate retains existing baseline test.
  • Loser abort signal observable on the underlying driver request.

Unreachable branches (remaining ~8.6%)

Three defensive code paths cannot be reached without modifying src/hedge/index.ts:

  1. nonLoser ?? aggregate.errors[0] fallback. Only fires when every aggregated error is a HedgeLoserError. But HedgeLoserError("not-the-winner") is only thrown from dispatchAt when settled is already true — meaning some other dispatch resolved successfully and Promise.any resolves rather than rejects.
  2. primary instanceof Error false branch. All fetch/misina rejections are Error subclasses by contract.
  3. composeOptional's if (!b) return a. b is always ac.signal (an internal AbortController signal), never undefined at the call site.

Test plan

  • pnpm vitest run test/hedge.test.ts — 20/20 pass
  • Coverage on src/hedge/** measured before/after
  • pnpm vitest run — 910/910 pass
  • pnpm typecheck clean
  • pnpm lint clean for test/hedge.test.ts (5 pre-existing warnings elsewhere untouched)
  • Bug reproducer (external abort during delayMs) confirms unhandledRejection count: 1 before fix, 0 after
  • audit-fixes.test.ts > TimeoutError carries the configured timeout flake filed as Flaky test: audit-fixes 'TimeoutError carries the configured timeout' fails ~1/N runs #120 (unrelated)

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>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 3, 2026

Bench results

⚠️ 2 misina row(s) >5% slower than base.

benchmark base avg head avg Δ
createMisina cold start :: createMisina() 484.2 ns 596.3 ns 🔴 +23.2%
createMisina cold start :: createMisina({ use: [bearer] }) 536.0 ns 573.1 ns 🔴 +6.9%
createMisina cold start :: createMisina({ use: [bearer, tracing, breaker] }) 1.83 µs 1.87 µs · +2.5%
steady-state GET (200 OK, JSON parse) :: misina 296.04 µs 302.28 µs · +2.1%
POST JSON body :: native fetch 357.28 µs 363.09 µs · +1.6%
steady-state GET (200 OK, JSON parse) :: axios 310.12 µs 308.74 µs · -0.4%
steady-state GET (200 OK, JSON parse) :: native fetch 324.39 µs 319.60 µs · -1.5%
steady-state GET (200 OK, JSON parse) :: ofetch 266.75 µs 261.07 µs · -2.1%
POST JSON body :: axios 322.81 µs 313.63 µs · -2.8%
plugin overhead (no plugins / 1 hook plugin / 3 plugins inc. wrapping) :: misina — bearer + tracing + breaker 305.20 µs 295.46 µs · -3.2%
POST JSON body :: misina 523.39 µs 500.97 µs · -4.3%
hooks overhead (no hooks vs 5 hooks) :: misina — no hooks 291.91 µs 278.26 µs · -4.7%
steady-state GET (200 OK, JSON parse) :: ky 299.00 µs 284.36 µs · -4.9%
plugin overhead (no plugins / 1 hook plugin / 3 plugins inc. wrapping) :: misina — no plugins 281.20 µs 266.15 µs 🟢 -5.4%
hooks overhead (no hooks vs 5 hooks) :: misina — 5 hooks 294.29 µs 277.92 µs 🟢 -5.6%
plugin overhead (no plugins / 1 hook plugin / 3 plugins inc. wrapping) :: misina — bearer 282.64 µs 266.27 µs 🟢 -5.8%
POST JSON body :: ofetch 353.06 µs 332.39 µs 🟢 -5.9%
POST JSON body :: ky 480.01 µs 451.25 µs 🟢 -6.0%
retry on 503 → 200 :: misina — retry 1× then 200 1.84 ms 1.72 ms 🟢 -6.6%

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).
@productdevbook productdevbook changed the title test(hedge): branch coverage >=80% on src/hedge test(hedge): branch coverage >=80% on src/hedge + fix dangling rejection May 3, 2026
@productdevbook
Copy link
Copy Markdown
Owner Author

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 seen sequences, exact host strings, instanceof checks), no loose expect.anything(). The flagged dangling-rejection bug was real and reproducible (Node logged unhandledRejection) — fixed in a separate fix(hedge): commit by attaching a sibling .catch(()=>{}) to dispatched promises. Regression test now asserts no unhandled rejection. Pre-existing audit-fixes flake filed as #120.

@productdevbook productdevbook merged commit b93c284 into main May 3, 2026
5 checks passed
@productdevbook productdevbook deleted the test/hedge-coverage branch May 3, 2026 09:19
productdevbook added a commit that referenced this pull request May 3, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant