Skip to content

fix(core): an unusable backoff throws at construction instead of vanishing - #666

Merged
rejifald merged 1 commit into
mainfrom
claude/backoff-construction-validation
Aug 5, 2026
Merged

fix(core): an unusable backoff throws at construction instead of vanishing#666
rejifald merged 1 commit into
mainfrom
claude/backoff-construction-validation

Conversation

@rejifald

@rejifald rejifald commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Closes §3 only of #651backoff as a function typechecks-then-vanishes. §1 and §2 are untouched; see What remains.

Root cause

backoff accepts BackoffCurve | AtLeastOne<BackoffOptions> — there is no function form — so backoff: () => 6000 is correctly a type error. Casting past it (which people do when they believe a feature exists) constructed clean and then did nothing.

expandShorthand folds the bare form of the slot into its dominant field (P12), so the function landed on curve:

if (retry?.backoff !== undefined)
    cfg.retry = { ...retry, backoff: envelope(retry.backoff, 'curve') };

envelope treats anything that isn't a plain object as the bare form, so () => 6000 became { curve: () => 6000 }. Then backoffDelay dispatches on curve:

if (kind === 'fixed') delay = base;
else {
    const computed = base * 2 ** exp;
    delay = kind === 'expo-jitter' ? Math.random() * computed : computed;
}

A function matches neither arm, so it fell through the else to the plain expo branch on the default 100ms base — the issue's measured gaps of 100, 200 ms where the config asked for 6000. No throw, no event, nothing in the trace that reads as wrong.

The important half is the direction: the quiet fallback is the permissive one. A typo made the client wait less than asked, i.e. retry harder against an endpoint the author was trying to back off from. That is the same failure ADR 0023 names ("quietly more permissive than requested") and the reason parseRate fails loud.

The fix, and how it mirrors the throttle.rate precedent

parseRate (packages/core/src/util.ts) throws bad rate: `${r}` for an unreadable rate, and reaches the caller at construction because createThrottle runs inside makeStitch. This copies all three properties of that precedent:

throttle.rate (existing) retry.backoff (this PR)
message bad rate: fast bad backoff (see size)
lifecycle point stitch() / seam() construction stitch() / seam() construction
why not fall back undefined would mean no limit at all the fallback curve is shorter than any curve you'd set

Implementation is one comparison in packages/core/src/stitch.ts, placed at the P12 fold itself — on the value envelope has just produced:

const backoff = envelope(retry.backoff, 'curve') as { curve?: string };
if (
    backoff.curve !== undefined &&
    backoff.curve !== 'expo-jitter' &&
    backoff.curve !== 'expo' &&
    backoff.curve !== 'fixed'
)
    throw new Error('bad backoff');
cfg.retry = { ...retry, backoff } as AtLeastOne<RetryOptions>;

Three things fall out of that placement:

  • One check covers every authoring spelling. The fold has just run, and everything a cast can let through is non-object, so a function, a bare 6000, 'exponential', ['expo'] and null all arrive as { curve: <that> }. Guarded on !== undefined, so an envelope that sets only base/max keeps the expo-jitter default.
  • It names the offending fragment, not the merged result — expandShorthand runs per layer, so a bad backoff in an extends chain is reported where it was written.
  • It reuses the accessor the fold already needs, which is what pays for it inside the byte budget (see below).

compose is reached from makeStitch, the single construction chokepoint, so this covers stitch, seam, llm, sse, download, graphql and postmessage alike.

Two details in that snippet are load-bearing for size and carry comments saying so, because they look like things to tidy:

  • 'expo-jitter' is compared first so the 'expo' literal after it compresses as a back-reference into the one already emitted — 4 B.
  • the !== chain rather than ['expo', …].includes(…) — the array measured 4 B worse. The as { curve?: string } read is what keeps that chain lint-clean: narrowed against BackoffCurve the last comparison is never, and no-unnecessary-condition (correctly) calls that dead code.
  • the message stops at the slot instead of quoting the value — 5 B. parseRate can afford bad rate: ${r}; this could not. It is the one concession I am unhappy about and it should be reverted at the next budget step (one line, +5 B).

RetryOptions.backoff's JSDoc gains the fail-loud sentence, matching the one ThrottleOptions.rate already carries, plus a pointer to the seam that does do a computed wait (SurfaceOutcome.after, #609) — which is the feature people were reaching for.

The sweep: every resilience slot, and what I found

I swept retry / circuit / timeout / throttle / backoff for the same defect. Only the curve check shipped, and the reason is the bundle-size budget, not the analysis — numbers below. Everything found is recorded here so the next PR does not re-derive it.

Slot Value cast past the type Behaviour today Verdict
retry.backoff (curve) fn / 6000 / 'exponential' / array / null folds to { curve }, falls through to plain expo on the 100 ms default FIXED
throttle.rate 'fast', '0/s', '1/25d' already throws bad rate: … at construction left alone — already validates
circuit.cooldown 'soon' parseDurationundefinedcreateCircuit throws (at call time, under the P15 "requires failures and cooldown" message) left alone — does not degrade silently. The message is misleading for a present-but-unparseable value and the throw is at call rather than construction; worth a follow-up, but it is not this defect
circuit.failures 'five' failures >= max is NaN-false ⇒ the breaker never trips in class, not fixed (budget)
retry.attempts '3 times' attempt < max is NaN-false ⇒ retry silently off in class, not fixed (budget)
retry.backoff.base / .max '6 seconds' parseDurationundefined ⇒ the 100 ms / 10 s defaults in class, not fixed (budget)
timeout.total / .each 'five seconds' parseDurationundefinedno deadline at all — the unbounded direction, closest of the lot to rate's reasoning in class, not fixed (budget)
throttle.lease '30 seconds' → the 30 s default. Bounded either way, so this is the one whose fallback is genuinely safe in class but lowest value
throttle.concurrency 'two' inFlight < limit is false forever ⇒ the limiter never grants a slot (a hang, not a degrade) in class, not fixed (budget)
retry.on, throttle.pool, retry.respect, throttle.delegate acceptsStatus normalises; a bad pool falls to the 'stitch' default; the booleans read by truthiness left alone — no silent policy degradation of the kind §3 describes

No semantic validation was added anywhere: no range checks, no "this looks too large", no cross-field rules. The curve is a closed three-value enum the dispatcher switches on, so rejecting a fourth value is structural, not a judgement about what a reasonable backoff is.

Why the sweep stopped where it did — measured

Budget headroom on main is 100 B (whole entry) and 89 B (import { stitch }). I measured several encodings of the full sweep; the numbers are gzip deltas vs main, entry / import { stitch }:

Scope Encoding Δ gzip Fits?
curve only !== chain at the fold (shipped) +28 / +33 ✓ by 1 B
curve + backoff.base + .max three near-identical ifs (best of 4 tried) +99 / +105 ✗ over by 16 on stitch
curve + 5 duration slots dotted-path table + getPath +136 / +142
full 9-slot sweep dotted-path table + getPath, curve folded in +141 / +149
full 9-slot sweep array-literal table / per-slot helpers / structural walk +145…+219

The table forms carry a ~48 B machinery premium and then cost ~6 B per slot; the direct forms start at ~46 B and cost ~19 B per slot. Neither shape reaches even the next two slots inside 89 B. The budget was not raised, per CONTRIBUTING and the gate's own comment.

Anyone picking up the rest should expect to need a budget PR of its own, and timeout.total/.each is the highest-value pair to take first: its silent fallback is unbounded, which is exactly the shape parseRate fails loud for.

Bundle size — exact bytes

pnpm --filter stitchapi check:sizepasses, by one byte.

Both PRs that were in flight landed while this was in review (#663 as d133d86, #664 as 48aa7ee), so the headroom this was sized against is gone. Baseline below is main at 48aa7ee. Local and CI agree exactly on these numbers — verified against CI's own size job on 48aa7ee (24.05 / 21.47 / 5.18 KB).

scenario baseline (48aa7ee) this PR delta budget headroom
stitchapi — whole entry 24 628 B 24 656 B +28 B 24 678.4 B 22.4 B
import { stitch } 21 982 B 22 015 B +33 B 22 016 B 1.0 B
stitchapi/auth 5 309 B 5 309 B +0 B 5 478.4 B 169.4 B

Advertised rounded kB stay at 24 / 21, which is the harder of the two constraints: Math.round(22016 / 1024) is 22, so import { stitch } had 33 B — not 34 — before the figure the READMEs and installation.mdx quote flips and the blocking bundle-advertised-size tether fails. That is not theoretical; it is how the first push of this PR failed CI. npx yakir check --tier executable now reports 6/6 ok, agreeing on "21, 24".

The honest read: this no longer fits, and I did not raise the budget

Every encoding I could find is in the table above; the correct-message version (bad backoff: <value>) is +38 B, five over what is available. What shipped is the same check with the value dropped from the message — the only variant that fits, and it fits by one byte.

I want to be plain that a 1 B margin is not a good place to leave the gate. It means the next core-path change of any size — including the follow-ups this very issue needs — has to open a budget PR anyway. My recommendation is to take the conventional ~0.2 KB step in bundle-size.mjs (the same step the file's own comments describe, restoring the ~0.2 KB headroom the gate is meant to hold) and put the value back in the message in the same PR. I have not done it here because the gate's comment is explicit that bumping it must be a deliberate act in the PR that needs it, and this PR can be merged without one.

Is it breaking? — no !, and why

Shipped as fix(core): without the ! marker:

  • Only a value tsc already rejects can reach the throw. BackoffCurve is a closed union and backoff has no function arm, so every newly-throwing config was already a compile error. Reaching it needs an as never, an as any, a @ts-ignore, or plain JS.
  • Nothing valid changes. All three curve names in both spellings, { base }/{ max }-only envelopes, the scalar shorthands, the positional circuit tuple, and every number | string duration construct exactly as before — pinned by the second describe block.
  • The precedent classifies it this way. throttle.rate: '0/s' — a value that used to parse and silently mean no limit, now throwing bad rate at construction — shipped under Fixed, not as a breaking change (ADR 0023, Found while implementing). Same shape, same call.
  • The counter-argument, stated for the record: a plain-JS caller who passed a bogus curve had working (if wrong) code and now gets a hard failure at import time. I think that is the point — its previous behaviour was to ignore what they wrote — but a maintainer who weighs the rc channel differently can add the ! without touching the diff.

Tests

New file packages/core/test/resilience-config-guard.spec.ts (11 tests). Written first and confirmed failing against unmodified src: 5 failed / 6 passed before the fix (the 6 passes are the "still constructs" regression guards plus the existing bad rate precedent), 11/11 after.

  • the reported reproduction — backoff: () => 6000 cast past its type
  • 'exponential', { curve: 'linear' }, a bare 6000, ['expo'], null
  • it throws at construction, asserted by proving the statement after stitch(...) never runs
  • the throttle.rate precedent, pinned next to it so the two stay the same shape
  • regression block, deliberately uncast so it typechecks too: all three curves in both spellings; a backoff envelope with no curve; a config exercising retry/timeout/throttle/circuit with both numbers and duration tokens; every scalar shorthand and the positional circuit tuple; omitted slots; and circuit: { key } with neither required field, which must still reach createCircuit's P15 call-time throw rather than being pre-empted here

No existing test was edited. The full workspace suite passes untouched.

Verification

All run from the repo root after rebasing onto 48aa7ee (current main, #663 and #664 included), all PASS: check:format, check:lint, check:types, check:types-d, test, check:size, check:contract, check:unknown-keys, check:changelog, check:docs-links. Core coverage also re-run: statements 91.21 / branches 84.06 / functions 93.05 / lines 92.81, all above the configured floors.

Plus npx yakir check --tier executable (the blocking drift-executable job): 6 tethers, 6 ok.

No eslint-disable and no eslint-suppressions.json entry was added, the budget was not raised, and there are no drive-by refactors — the diff touches only the retry.backoff fold, that slot's JSDoc, the CHANGELOG and the new spec.

What remains

  • §1 — verdict.flag returns ok: true with an error envelope. Not touched: its ask is an either/or (treat an absent flag path as a failure / a distinct unknown, or emit a drift-health signal), and picking the arm is a maintainer call.
  • §2 — .safe() downgrades RateLimitError and drops the body. Not touched, same reason: the ask offers documentation as an alternative to preserving body/retryAfter. Worth connecting before anyone starts it: this is the same asStitchError flattening at packages/core/src/stitch.ts:738-745 that a recent review found also flattens CircuitOpenError, and it is the same shape as the already-merged refactor(core)!: RateLimitError extends StitchError #662 (RateLimitError extends StitchError) — so §2, the CircuitOpenError finding, and refactor(core)!: RateLimitError extends StitchError #662 look like one fix, not three.
  • The rest of the §3 class — the six slots in the sweep table above that are in class but did not fit the byte budget.

Hence Refs #651, not Closes.

🤖 Generated with Claude Code

@rejifald
rejifald force-pushed the claude/backoff-construction-validation branch 2 times, most recently from 6bdde83 to 5cc50dc Compare August 5, 2026 19:24
…nishing

`backoff` takes a curve or the `{ curve, base, max }` envelope — never a
function — so `backoff: () => 6000` is correctly a type error. Casting past it
(which people do when they believe a feature exists) constructed clean and then
did nothing: the function was never invoked, and the waits fell back to the
default curve on the default 100ms base. Measured gaps of 100, 200ms where the
config asked for 6000, with no throw, no event, and nothing in the trace that
reads as wrong.

`expandShorthand` folds the bare form of the slot into `{ curve }` (P12), so
every value a cast can let through — a function, a bare `6000`, a misremembered
`'exponential'`, an array, `null` — lands on `curve`, where `backoffDelay`
matched neither `'fixed'` nor `'expo-jitter'` and fell through to the plain
`expo` branch.

The guard sits at that fold, on the value it just produced, so one comparison
covers every authoring form, names the offending fragment rather than the merged
result, and reuses the accessor the fold already needs. It throws `bad backoff`,
mirroring the `bad rate: …` an unparseable `throttle.rate` has thrown at
construction since #618 — same lifecycle point, same reasoning: the quiet path
here is the PERMISSIVE one, so a typo shortened the wait instead of lengthening
it. Silently degrading a resilience policy is the one place a fallback is worse
than a crash.

Size is the binding constraint and it is now severe. #663 and #664 both landed
while this was in review, leaving 34 B free on `import { stitch }` — 33 before
the advertised kB rounds 21 → 22 and breaks the `bundle-advertised-size` tether.
This check is +33 (22 015 B, one byte inside the gate; entry +28 at 24 656 B,
22 B free). Getting there cost the interpolated value in the error message
(5 B); the curve-comparison order and the `!==` chain are worth another 8. All
three carry comments saying so, because they read as things to tidy. The value
should go back into the message at the next budget step.

Refs #651

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rejifald
rejifald force-pushed the claude/backoff-construction-validation branch from 5cc50dc to 520180b Compare August 5, 2026 19:28
@rejifald
rejifald merged commit 3e698d2 into main Aug 5, 2026
12 checks passed
@rejifald
rejifald deleted the claude/backoff-construction-validation branch August 5, 2026 22:14
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