Skip to content

fix(core): OAuth2 expiry rides the injected clock; two mocking-kit bugs - #664

Merged
rejifald merged 3 commits into
mainfrom
claude/oauth2-clock-and-mock-kit-fixes
Aug 5, 2026
Merged

fix(core): OAuth2 expiry rides the injected clock; two mocking-kit bugs#664
rejifald merged 3 commits into
mainfrom
claude/oauth2-clock-and-mock-kit-fixes

Conversation

@rejifald

@rejifald rejifald commented Aug 5, 2026

Copy link
Copy Markdown
Owner

Refs #650. Not Closes — §2 is deliberately left open (below).

Three of the issue's findings, plus the documentation half of §1. §2 and SigV4 are untouched by design.


§1(a) — OAuth2 token expiry rides the injected clock

Before: 600,000 virtual ms past a 60s expires_in refetched nothing. The test people actually want to write — advance past the expiry, assert the second token fetch — passed while asserting nothing, because the cached token was still fresh by a clock the test could not move.

After: advance(600_000) past a 60s token refetches, and the refresh.skew boundary is exact on virtual time (pinned to the millisecond: fresh at 49,999, stale at 50,000 for a 10s skew).

The plumbing, and why it matches ADR 0010

ADR 0010's load-bearing sentence is that the clock owns control-flow time, not bookkeeping. Token freshness is control flow — it decides whether the next call fetches — so it belongs on the seam, and §4's deliberate exclusions (timeout.total, event timestamps, store/cache TTL) are all bookkeeping. This is not an exception to the ADR's scope; it is a case that fell inside it and was never wired.

The issue is right that this was never a one-line misreach: auth.ts contained zero occurrences of clock and AuthContext carried none.

AuthContext now carries the stitch's resolved clock. That is the ADR's own threading pattern applied one level further — the engine already resolves shared?.clock ?? cfg.clock ?? systemClock once in makeStitch and hands it to makeRuntime, which is the same function that builds the authCtx. The strategy receives it exactly the way it already receives store, vault, principal and run: engine-provided, never caller-supplied.

// engine.ts — makeRuntime
const clock = opts?.clock ?? systemClock;
const authCtx: AuthContext = { store, vault: , clock, emit:  };
return {, clock, authCtx };
// auth.ts
const clockNow = (ctx: AuthContext): number => ctx.clock?.now() ?? now();

Both halves of the freshness math read it — expiresAt at fetch time and the refresh.skew window at read time. Fixing only one leaves the test still failing, which is why the test covers both.

Three properties kept this design-free rather than an architecture decision:

  1. The field is optional. Purely additive: no existing user code breaks, and a hand-built AuthContext in a custom strategy's unit test still type-checks and falls back to the wall clock. It sits alongside principal? and run?, the other two engine-provided optionals on the same interface.
  2. No behaviour change under systemClock — ADR 0010's own guarantee. The capability activates only when a clock is injected.
  3. emitInto spreads ...authCtx, so the per-call view carries the clock with no change.

The vault's own TTL stays wall-clock, which is the ADR 0010 §4 decision and is correct here: it means a virtual-time test still finds the cached entry and isFresh gets to be the thing that decides.

§3 — BUG: stubStitch(...).safe() threw on a synchronous throw

Before: stub, impl throws synchronously -> THREW boom, while stub, impl rejects asynchronously -> ok=false and REAL stitch, adapter throws sync -> ok=false. The never-throws accessor was the only one of the three that threw.

After: all three agree on { ok: false, error: StitchError('boom') }.

resolve() evaluated impl(input) as an argument to Promise.resolve, so the throw escaped before there was a chain to catch it. resolve is now an async function, whose body turns the throw into a rejection. .unwrap() and the awaited call result are fixed by the same change; .stream() was never affected (an async generator already caught it).

§4 — BUG: mockAdapter failed the library's own adapter contract

Before: verifyAdapterContract(mockAdapter(…)) passed 8 of 9 and failed "abort: a pre-aborted signal rejects""adapter resolved although the signal was already aborted". req.signal was consulted only inside the delay branch, so any route without a delay answered a cancelled request, and a cancellation test written against a delay-less route asserted the opposite of production.

After: the signal is checked before anything else happens, so every route honours it.

One judgement call worth flagging: a pre-aborted request records no spy entry and consumes no slot of a route's response sequence. Rationale — a real transport sends nothing, so callCount() keeps meaning "requests that reached the wire", which is what expect(api.callCount()).toBe(0) after a cancellation is asking. Documented in the guide.

§1(b) — docs

The mocking guide gains the full 12-feature map of what manualClock drives and what reads wall-clock, plus a worked token-expiry example. ADR 0010 §4 and a types.ts JSDoc are not where someone writing a test looks.

The four ADR-0010 §4 cases are presented as decisions, not gaps, with the reasoning: timeout.total's budget is wall-anchored so virtual sleeps never drain it (and the guide says what to assert instead); store/cache TTL is expiry rather than scheduling; event timestamps are cosmetic and would cost ~30 threaded builders for no determinism.

§5 — assessed, nothing fixed

Every item needs a decision, so all five are left. Reporting what I found rather than guessing:

Item Why it is not design-free
mockAdapter fixture validation (wireShape) A new opt-in public API and a policy on what "wire shape" means.
stubStitch runs no input schemas Needs a new slot on StubStitchOptionsconfig is Partial<RedactedStitchConfig>, which carries no schema. Public API addition.
Retry backoff absent from the event stream The progress{phase:'retry'} event is emitted before the sleep, so populating waited would put a not-yet-elapsed duration under a past-tense name that the throttle and reconnect paths use for a genuinely elapsed one. Event-contract decision.
.with() returns a fresh spy Two defensible semantics (a derived stitch's calls are the parent's calls / a bound stub is its own subject). A call, not a bug.
Circuit breaker has no distinct error name Partly stale, and the remaining half is a breaking change. CircuitOpenError already exists (resilience.ts:255) with name = 'CircuitOpenError' and is what engine.ts:877 throws. It is flattened on the way out: asStitchError (stitch.ts:749) rebuilds anything that is not already a StitchError into a plain one, so the caller measures name: 'StitchError', message: 'circuit open', status: 503 — verified on this branch. The fix is exactly the shape of #662 (RateLimitError extends StitchError), i.e. a refactor(core)! of its own.

Deliberately NOT done

  • §2 — Retry-After as an HTTP-date. Its ask is an either/or — default manualClock() to a realistic epoch, or warn when a parsed Retry-After exceeds a sane ceiling. Both change published behaviour in different directions (one alters every existing manualClock() test's now(); the other adds a runtime warning and a threshold to pick), and choosing is a maintainer call. Left open — hence Refs, not Closes. The guide now warns about the trap and shows the two workarounds (retryAfterSeconds as a number, or manualClock(Date.now())); that documents the sharp edge without picking either arm.
  • AWS SigV4's signing date. The other undocumented wall-clock case, but it lives in @stitchapi/aws-sigv4 and is tracked in SigV4 signs with new Date(), and a skew 403 opens the dependency's breaker #658. Behaviour untouched; it appears as a row in the doc table so a reader is not misled into writing a signing-date test that silently passes.
  • The four deliberate ADR-0010 §4 behaviours. Documented as intentional, not changed.

Tests

Written first, and confirmed failing against unmodified src: 8 failed, 24 passed. After the fix: 32 passed.

  • clock-seam.spec.ts — 3 tests: advance past expires_in refetches; the refresh.skew boundary is exact on virtual time; and a control proving expiry still rides systemClock with no clock injected. (2 of 3 failed before; the control passed, as it should.)
  • stub-stitch-extras.spec.ts — 5 tests: .safe(), the call result's .safe(), .unwrap() rejecting rather than throwing, plus controls that the async twin and .stream() already agreed. (3 of 5 failed before.)
  • mock-adapter-extras.spec.ts — 5 tests: pre-aborted rejects with no delay; rejects before route matching; no spy entry and no sequence slot consumed; plus controls that a live signal and mid-flight delay abort still work. (3 of 5 failed before.)

No regression to the six that already worked

Re-measured against the issue's "First, what works", all exact:

  • retry backoff at virtual 0 / 1000 / 3000; advance(0) → 1 call, 1 pending; advance(5000) → 3 calls
  • throttle rate advance(3000) → 3 calls; waited = 500 then 1000 for '2/s'
  • circuit trace 1, 2, 2 (blocked: circuit open), 3, 4
  • per-attempt timeout: advance(2000) past a 1s timeout → error
  • Retry-After honoured on the clock (advance(5000) → 2nd attempt at virtual 5000)

Full suite: core 1460 tests / 137 files, all workspace packages green.

Bundle size — no budget raise

Measured on this branch vs. its merge base (521011f), gzip bytes:

Scenario Before After Δ Budget Headroom
whole entry 24578 24583 +5 B 24678 95 B
import { stitch } 21927 21931 +4 B 22016 85 B
stitchapi/auth 5283 5309 +26 B 5478 169 B

Two things kept this cheap. ADR 0021 already moved oauth2 behind stitchapi/auth, so the clockNow helper is charged to the subpath, not the core path. And makeRuntime computed opts?.clock ?? systemClock once for Runtime.clock; hoisting it into a const shared with authCtx paid for most of the new property — the core path grew 4–5 bytes rather than the ~0.1 KB a naive threading would have cost. bundle-size.mjs is unmodified.

Verification

Every command run from the repo root, on the final tree:

Command Result
pnpm check:lint ✅ pass (36 packages, no suppressions added)
pnpm check:types ✅ pass
pnpm test ✅ pass
pnpm check:format ✅ pass (needed one pnpm format for the new MDX table; it does cover CHANGELOG.md)
pnpm check:contract ✅ pass
pnpm check:unknown-keys ✅ pass
pnpm check:types-d ✅ pass
pnpm check:size ✅ pass
pnpm check:changelog ✅ pass
pnpm check:docs-links ✅ pass
docs build (twoslash) ✅ pass

The pre-push hook's full sequence — format, lint, contract, unknown-keys, changelog, media-fresh, typecheck, typecheck-d, test, exports, build-docs, yakir — passed on the pushed commit. Nothing was failing on origin/main beforehand, so there is nothing to stash-prove. No drive-by refactors; no eslint-disable; no baseline entries.

🤖 Generated with Claude Code

Refs #650.

ADR 0010 scoped the `Clock` to CONTROL-FLOW time, and OAuth2 token freshness is
control flow — it decides whether the next call fetches — but the seam did not
reach it. 600,000 virtual ms past a 60s `expires_in` refetched nothing, so "does
my client refresh before the token expires" could not be tested on a virtual
clock. `auth.ts` held zero occurrences of `clock` and `AuthContext` carried none,
so this was never one line pointing at the wrong function.

`AuthContext` now carries the stitch's resolved `clock`, threaded by the engine
from the same place `Runtime.clock` comes from — arriving the way `store`,
`vault`, `principal` and `run` already do. Both halves of the freshness math
(`expiresAt` at fetch time, the `refresh.skew` window at read time) read it. The
field is OPTIONAL, so a hand-built context in a custom strategy's unit test still
type-checks and falls back to the wall clock; nothing changes under the default
`systemClock`, which is ADR 0010's own guarantee.

Two bugs in the same kit:

- `stubStitch(...).safe()` threw on a SYNCHRONOUS throw. `.safe()` is the
  never-throws accessor; `resolve()` evaluated `impl(input)` as an ARGUMENT to
  `Promise.resolve`, so the throw escaped before there was a chain to catch it.
  Making `resolve` `async` turns it into a rejection, matching both the async
  twin and the real stitch. `.unwrap()` and the awaited result are fixed by the
  same change.

- `mockAdapter` failed `verifyAdapterContract`'s "a pre-aborted signal rejects"
  rule (8 of 9 passing). `req.signal` was consulted only inside the `delay`
  branch, so a delay-less route answered a cancelled request and a cancellation
  test asserted the opposite of production. The signal is now checked first;
  nothing is sent, so no spy entry and no response-sequence slot is consumed.

The mocking guide gains the full map of which time-driven features `manualClock`
drives and which read wall-clock. The four ADR 0010 §4 cases (`timeout.total`,
event `at`/`done.ms`, store/cache TTL) are presented as the decisions they are.

Bundle: whole entry 24578 -> 24583 B gzip (+5, 95 B headroom), `import { stitch }`
21927 -> 21931 (+4, 85 B), `stitchapi/auth` 5283 -> 5309 (+26, 169 B). No budget
raise — hoisting the clock resolution in `makeRuntime` paid for most of the core
path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@rejifald
rejifald enabled auto-merge (squash) August 5, 2026 18:43
…nd-mock-kit-fixes

# Conflicts:
#	CHANGELOG.md
…nd-mock-kit-fixes

# Conflicts:
#	CHANGELOG.md
@rejifald
rejifald merged commit 48aa7ee into main Aug 5, 2026
12 checks passed
@rejifald
rejifald deleted the claude/oauth2-clock-and-mock-kit-fixes branch August 5, 2026 19:09
rejifald added a commit that referenced this pull request Aug 5, 2026
…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 added a commit that referenced this pull request Aug 5, 2026
…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 added a commit that referenced this pull request Aug 5, 2026
…nishing (#666)

`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 added a commit that referenced this pull request Aug 5, 2026
The signer stamped `x-amz-date` from `amzDateOf(new Date())`, so SigV4 could
not be tested on virtual time: 600 virtual seconds moved the shipped stamp 0
seconds, and a default `manualClock()` (which starts at epoch 0) still produced
a real-time stamp. Every test wanting to assert anything about signing time had
to inject its own clock-reading signer to measure it.

#664 put the stitch's resolved `clock` on `AuthContext` for `oauth2`; this is
the companion package taking the same seam. The signing timestamp is
control-flow time by ADR 0010's own definition — `x-amz-date` is inside the
string-to-sign and AWS refuses a stamp more than ~5 minutes out with
`RequestTimeTooSkewed` — so it belongs on the clock that already drives retry,
throttle, timeout, circuit and token freshness. It reads it through the
identical `ctx.clock?.now() ?? Date.now()` fallback core's `auth.ts` uses, so a
hand-built `AuthContext` in a custom strategy's unit test still type-checks.

Nothing changes on the wire. The engine threads `systemClock` unless a clock was
injected, and `systemClock.now()` IS `Date.now()`. Pinned by a test that signs
on the wall clock, reads back the instant it stamped, re-signs on a clock pinned
to that instant, and asserts the `Authorization` header is byte-identical, plus
a golden signature cross-checked against an independent SigV4 implementation
that reproduces the official `get-vanilla` vector. Payload hashing and the
`signBody` branches are untouched.

The mocking guide's clock map moves the SigV4 row from wall-clock to virtual,
and the integrations page gains a "Signing time" section. Both note that an
unseeded `manualClock()` signs `19700101T000000Z`.

The package's `no-empty-function` suppression is dropped rather than raised: the
two test contexts now share one non-empty no-op `emit`.

Refs #658 (§1 hooks.onRequest ordering and §3 skew-403 circuit accounting are
maintainer calls and remain open).

Co-authored-by: Claude Opus 5 <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