Scenario: cancelled-request
Proofs: docs/scenarios/proofs/cancelled-request/ (8 scripts, 135 checks)
Found while measuring what happens when a caller passes input.signal and aborts — a search box, a
navigation away mid-upload, an Escape on a slow report.
First, what works
- Every resource comes back. A pre-dispatch abort sends 0 requests and opens 0 sockets; an abort
while queued on concurrency: 1 leaves the limiter working across 3 rounds — acquireWithin's
handBack() does exactly what its comment claims. Mid-request and mid-body-read aborts each close
exactly one socket and never hand the caller a partial body.
- The signal genuinely propagates — into the request (
engine.ts:255), linked with the
per-attempt timeout, and into clock.sleep, so an abort interrupts a backoff instead of waiting.
idempotency.keyOf with a caller-owned intent id works: 1 key, 1 charge, and a deduped
second response that resolves the ambiguity after the fact.
1. An abort is a circuit failure — including one that never opened a socket
engine.ts:896-904 catches everything except CircuitOpenError and calls circuit.onFailure(). An
abort throws abortReason(signal), so it counts.
| what the user did |
requests sent |
circuit.failures after 3 |
breaker |
| cancelled mid-flight |
3 |
3 |
open |
| cancelled before dispatch |
0 |
3 |
open |
| three genuine 503s |
3 |
3 |
open |
The stored record is {"failures":3,"tripped":true} in all three cases — byte-identical. A fourth
caller with no signal at all then gets StitchError "circuit open", status: 503, having sent 0
requests. A search box where a user types eight characters opens the breaker on the third keystroke,
and the users who get fast-failed are the ones who cancelled nothing.
The pre-dispatch case is the sharpest: the vendor was never contacted, so no evidence about its
health was gathered, and the breaker opened anyway.
Ask: onFailure(): Promise<boolean> (resilience.ts:359) takes no argument, so this cannot be
fixed at the call site without a signature change. Two options: give onFailure a reason parameter
and skip the count when the run ended in the caller's own abort; or check signal?.aborted at
engine.ts:904 and skip onFailure() entirely. The second is a two-line change and covers the case
that matters.
2. retry replaces the caller's cancellation reason with a generic one
With retry: { attempts: 3 }, a mid-flight abort sends 1 request and records attempts: 1 —
correctly, no extra traffic. But it gets there by accident: the retry branch is entered
(hooks.onError and hooks.onRetry both fire, and a progress: 'retry' event is emitted for a user
cancellation), and the backoff sleep then sees the already-aborted signal and rejects with a fresh
generic Error('aborted') thrown from inside the catch.
The consequence is visible to callers:
controller.abort(new Error('user navigated away'));
// no retry block → StitchError: user navigated away
// retry: {attempts: 3} → StitchError: aborted
Same stitch, same caller code, two different answers depending on whether the request had left the
process. Emitting progress: 'retry' for a cancellation is also misleading to any trace consumer.
Ask: short-circuit the retry branch when the signal is aborted, before onError/onRetry fire
and before the backoff sleep — then the reason survives and no cancellation is reported as a retry.
3. The coalescer's ref-counted cancellation is implemented, documented, and unreachable
Coalescer.join (cache.ts:221-223) accepts opts?: CoalesceJoinOptions with signal and
onCancel, refs increments at :250 and decrements at :253, and the comment at :176 describes
the intent: "Aborts are ref-counted: the shared run is dropped — and onCancel …".
But CacheController.join(key) (cache.ts:357) takes only key, and engine.ts:1679 calls
ctl.join(key) with one argument. Nothing on the engine path can pass a signal, so refs only
ever increments.
Measured, both directions wrong:
- A follower that aborts is ignored. It stayed pending through its own
abort() and then
received ok: true with the leader's data. input.signal silently does nothing.
- A leader that aborts strands the follower into an uncoalesced re-run: 2 vendor requests for
1 logical read, paid by the caller who cancelled nothing.
With coalesce: false the identical caller code cancels correctly. Identical on origin/main.
Ask: widen CacheController.join to forward CoalesceJoinOptions and pass the run's signal at
engine.ts:1679. The mechanism is already written and tested-looking; it is just not wired up.
4. Nothing anywhere names cancellation
- All seven failure kinds arrive as class
StitchError, name: 'StitchError', own keys
message,status,attempts,body,url,name, no cause — so e.name === 'AbortError', the fix
every tutorial gives, can never be true, and the original AbortError is unreachable. A 503 does
carry a cause; the cancellation does not.
timeout.total and timeout.each are byte-identical to each other at the same duration.
.report() has no cancellation flag (source: 'live', attempts: 1, status: 0).
.stream() and a TraceSink emit exactly start → progress:request → error → done for a
cancelled run and for a 503, same types, same order. The only difference is a status field on
the outage's error event. An exporter can classify a cancellation only by substring-matching a
message.
Ask: the cheapest useful change is a cause on the StitchError — the abort reason is in hand at
the throw site, and it would make one if at the call site possible again.
5. idempotency: true does not make a cancelled write re-issuable
A POST cancelled after the vendor received it leaves the caller with
StitchError "This operation was aborted" and data: null while the vendor commits the charge —
and that error is byte-identical to a cancel that never dispatched. Re-issuing under
idempotency: true produces 2 charges: the key is randomUUID() minted once per logical call,
so it collapses only duplicates the engine created, never a caller's retry.
Ask: mostly documentation — make it explicit that idempotency: true is engine-scoped, and that
crossing a process or a user action requires idempotency.keyOf.
Reproduction
npx tsx docs/scenarios/proofs/cancelled-request/c1-circuit.ts
npx tsx docs/scenarios/proofs/cancelled-request/c5-coalescer.ts
A real node:http vendor on 127.0.0.1 whose slow handler is released by an explicit promise, never
a timer, with a ledger recording each request and whether the socket was hung up before the handler
finished. Every abort is sequenced on the vendor's own arrival promise, so no assertion depends on
wall-clock duration. C5 measures the coalesce precondition (requests === 1) before each abort, so a
flush that failed to coalesce fails loudly rather than silently changing the arm.
Which engine
Every load-bearing block was diffed against origin/main and is byte-identical; only line
numbers move. Two differences, neither affecting a result: cache.ts gains promise.catch(() => {})
(#670/#676 — irrelevant here, every C5 arm has a follower awaiting), and rebuildError drops the
RateLimitError clause (aborts have source: undefined on both trees, so "no cause" holds).
No script declares a schema, so the worktree's zod 3.25.76 (against a declared ^4.4.3) is not
load-bearing for any claim.
Source references (verified against origin/main)
engine.ts:255 — if (input.signal) req.signal = input.signal;
engine.ts:896-904 — the try/catch around attemptLoop; :904 const opened = await circuit.onFailure();
engine.ts:513-517 — acquireWithin and the handBack() comment
engine.ts:1679 — const claim = ctl.join(key);
resilience.ts:358-359 — onSuccess(): Promise<void>; / onFailure(): Promise<boolean>;
cache.ts:176 — the ref-counted-abort comment · :185-186 — refs / onCancel
cache.ts:191-193 — export interface CoalesceJoinOptions
cache.ts:221-223 — Coalescer.join(… opts?: CoalesceJoinOptions) · :250/:253 — refs += 1 / refs -= 1
cache.ts:357 — join(key: string): LeaderClaim<CacheHit> | FollowerClaim<CacheHit>;
Found while writing scenario 51 ("the request the user walked away from") for the docs. Every claim is backed by a runnable offline proof in docs/scenarios/proofs/cancelled-request/.
Scenario: cancelled-request
Proofs:
docs/scenarios/proofs/cancelled-request/(8 scripts, 135 checks)Found while measuring what happens when a caller passes
input.signaland aborts — a search box, anavigation away mid-upload, an Escape on a slow report.
First, what works
while queued on
concurrency: 1leaves the limiter working across 3 rounds —acquireWithin'shandBack()does exactly what its comment claims. Mid-request and mid-body-read aborts each closeexactly one socket and never hand the caller a partial body.
engine.ts:255), linked with theper-attempt timeout, and into
clock.sleep, so an abort interrupts a backoff instead of waiting.idempotency.keyOfwith a caller-owned intent id works: 1 key, 1 charge, and adedupedsecond response that resolves the ambiguity after the fact.
1. An abort is a circuit failure — including one that never opened a socket
engine.ts:896-904catches everything exceptCircuitOpenErrorand callscircuit.onFailure(). Anabort throws
abortReason(signal), so it counts.circuit.failuresafter 3The stored record is
{"failures":3,"tripped":true}in all three cases — byte-identical. A fourthcaller with no signal at all then gets
StitchError "circuit open",status: 503, having sent 0requests. A search box where a user types eight characters opens the breaker on the third keystroke,
and the users who get fast-failed are the ones who cancelled nothing.
The pre-dispatch case is the sharpest: the vendor was never contacted, so no evidence about its
health was gathered, and the breaker opened anyway.
Ask:
onFailure(): Promise<boolean>(resilience.ts:359) takes no argument, so this cannot befixed at the call site without a signature change. Two options: give
onFailurea reason parameterand skip the count when the run ended in the caller's own abort; or check
signal?.abortedatengine.ts:904and skiponFailure()entirely. The second is a two-line change and covers the casethat matters.
2.
retryreplaces the caller's cancellation reason with a generic oneWith
retry: { attempts: 3 }, a mid-flight abort sends 1 request and recordsattempts: 1—correctly, no extra traffic. But it gets there by accident: the retry branch is entered
(
hooks.onErrorandhooks.onRetryboth fire, and aprogress: 'retry'event is emitted for a usercancellation), and the backoff
sleepthen sees the already-aborted signal and rejects with a freshgeneric
Error('aborted')thrown from inside thecatch.The consequence is visible to callers:
Same stitch, same caller code, two different answers depending on whether the request had left the
process. Emitting
progress: 'retry'for a cancellation is also misleading to any trace consumer.Ask: short-circuit the retry branch when the signal is aborted, before
onError/onRetryfireand before the backoff sleep — then the reason survives and no cancellation is reported as a retry.
3. The coalescer's ref-counted cancellation is implemented, documented, and unreachable
Coalescer.join(cache.ts:221-223) acceptsopts?: CoalesceJoinOptionswithsignalandonCancel,refsincrements at:250and decrements at:253, and the comment at:176describesthe intent: "Aborts are ref-counted: the shared run is dropped — and
onCancel…".But
CacheController.join(key)(cache.ts:357) takes onlykey, andengine.ts:1679callsctl.join(key)with one argument. Nothing on the engine path can pass asignal, sorefsonlyever increments.
Measured, both directions wrong:
abort()and thenreceived
ok: truewith the leader's data.input.signalsilently does nothing.1 logical read, paid by the caller who cancelled nothing.
With
coalesce: falsethe identical caller code cancels correctly. Identical onorigin/main.Ask: widen
CacheController.jointo forwardCoalesceJoinOptionsand pass the run's signal atengine.ts:1679. The mechanism is already written and tested-looking; it is just not wired up.4. Nothing anywhere names cancellation
StitchError,name: 'StitchError', own keysmessage,status,attempts,body,url,name, nocause— soe.name === 'AbortError', the fixevery tutorial gives, can never be true, and the original
AbortErroris unreachable. A 503 doescarry a
cause; the cancellation does not.timeout.totalandtimeout.eachare byte-identical to each other at the same duration..report()has no cancellation flag (source: 'live',attempts: 1,status: 0)..stream()and aTraceSinkemit exactlystart → progress:request → error → donefor acancelled run and for a 503, same types, same order. The only difference is a
statusfield onthe outage's
errorevent. An exporter can classify a cancellation only by substring-matching amessage.
Ask: the cheapest useful change is a
causeon theStitchError— the abort reason is in hand atthe throw site, and it would make one
ifat the call site possible again.5.
idempotency: truedoes not make a cancelled write re-issuableA POST cancelled after the vendor received it leaves the caller with
StitchError "This operation was aborted"anddata: nullwhile the vendor commits the charge —and that error is byte-identical to a cancel that never dispatched. Re-issuing under
idempotency: trueproduces 2 charges: the key israndomUUID()minted once per logical call,so it collapses only duplicates the engine created, never a caller's retry.
Ask: mostly documentation — make it explicit that
idempotency: trueis engine-scoped, and thatcrossing a process or a user action requires
idempotency.keyOf.Reproduction
A real
node:httpvendor on 127.0.0.1 whose slow handler is released by an explicit promise, nevera timer, with a ledger recording each request and whether the socket was hung up before the handler
finished. Every abort is sequenced on the vendor's own arrival promise, so no assertion depends on
wall-clock duration. C5 measures the coalesce precondition (
requests === 1) before each abort, so aflush that failed to coalesce fails loudly rather than silently changing the arm.
Which engine
Every load-bearing block was diffed against
origin/mainand is byte-identical; only linenumbers move. Two differences, neither affecting a result:
cache.tsgainspromise.catch(() => {})(#670/#676 — irrelevant here, every C5 arm has a follower awaiting), and
rebuildErrordrops theRateLimitErrorclause (aborts havesource: undefinedon both trees, so "nocause" holds).No script declares a schema, so the worktree's zod 3.25.76 (against a declared
^4.4.3) is notload-bearing for any claim.
Source references (verified against
origin/main)engine.ts:255—if (input.signal) req.signal = input.signal;engine.ts:896-904— the try/catch aroundattemptLoop;:904const opened = await circuit.onFailure();engine.ts:513-517—acquireWithinand thehandBack()commentengine.ts:1679—const claim = ctl.join(key);resilience.ts:358-359—onSuccess(): Promise<void>;/onFailure(): Promise<boolean>;cache.ts:176— the ref-counted-abort comment ·:185-186—refs/onCancelcache.ts:191-193—export interface CoalesceJoinOptionscache.ts:221-223—Coalescer.join(… opts?: CoalesceJoinOptions)·:250/:253—refs += 1/refs -= 1cache.ts:357—join(key: string): LeaderClaim<CacheHit> | FollowerClaim<CacheHit>;Found while writing scenario 51 ("the request the user walked away from") for the docs. Every claim is backed by a runnable offline proof in
docs/scenarios/proofs/cancelled-request/.