Skip to content

A cancellation counts as a circuit failure even with 0 requests sent, retry overwrites the abort reason, and the coalescer's cancel path is unreachable #705

Description

@rejifald

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:255if (input.signal) req.signal = input.signal;
  • engine.ts:896-904 — the try/catch around attemptLoop; :904 const opened = await circuit.onFailure();
  • engine.ts:513-517acquireWithin and the handBack() comment
  • engine.ts:1679const claim = ctl.join(key);
  • resilience.ts:358-359onSuccess(): Promise<void>; / onFailure(): Promise<boolean>;
  • cache.ts:176 — the ref-counted-abort comment · :185-186refs / onCancel
  • cache.ts:191-193export interface CoalesceJoinOptions
  • cache.ts:221-223Coalescer.join(… opts?: CoalesceJoinOptions) · :250/:253refs += 1 / refs -= 1
  • cache.ts:357join(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/.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions