diff --git a/README.md b/README.md
index 59702cf4..84d710d9 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@
- Zero runtime dependencies · ~24 kB min+gzip — a typical import { stitch } tree-shakes to ~21 kB, and with no transitive tree there is nothing else to install or audit. The size is an enforced budget in CI, not an aspiration.
+ Zero runtime dependencies · ~24 kB min+gzip — a typical import { stitch } tree-shakes to ~22 kB, and with no transitive tree there is nothing else to install or audit. The size is an enforced budget in CI, not an aspiration.
@@ -164,7 +164,7 @@ No server, no codegen, no config files, no implicit inheritance — **only expli
- **Pluggable state store** — throttle counters and sessions behind a 3-method store; swap in Redis/Postgres to go distributed.
- **Zero-infra observability** — tracing is **off by default**; opt in per stitch or via `STITCH_TRACE_*` env vars. No collector, no dashboard.
- **Four front doors, one definition** — in-process function, CLI (`stitch run`), HTTP (`stitch serve`), and MCP (`stitch mcp`).
-- **Zero runtime dependencies** — `"dependencies": {}`, built on global `fetch`, tree-shakeable; **~24 kB min+gzip** for the whole entry, **~21 kB** for a typical `import { stitch }`.
+- **Zero runtime dependencies** — `"dependencies": {}`, built on global `fetch`, tree-shakeable; **~24 kB min+gzip** for the whole entry, **~22 kB** for a typical `import { stitch }`.
## Install
diff --git a/apps/docs/app/(home)/components/metrics.tsx b/apps/docs/app/(home)/components/metrics.tsx
index 88db573c..b01ea559 100644
--- a/apps/docs/app/(home)/components/metrics.tsx
+++ b/apps/docs/app/(home)/components/metrics.tsx
@@ -10,7 +10,7 @@ const metrics = [
body: 'The whole stitchapi entry, tree-shaken — and it is an enforced budget in CI, not an aspiration.',
},
{
- value: '~21 kB',
+ value: '~22 kB',
unit: 'import { stitch }',
body: 'Pay only for what you import: every surface beyond http lives behind its own subpath, so the core trims down.',
},
diff --git a/apps/docs/content/docs/concepts/principles.mdx b/apps/docs/content/docs/concepts/principles.mdx
index ec2ff9ef..5f36fc9f 100644
--- a/apps/docs/content/docs/concepts/principles.mdx
+++ b/apps/docs/content/docs/concepts/principles.mdx
@@ -107,7 +107,7 @@ package practices with your bundle.
Concretely, the whole `stitch` entry is **~24 kB minified + gzipped**
(≈61 kB raw), and because every surface beyond `http` is a separate subpath
-import, a typical `import { stitch }` tree-shakes to **~21 kB**. With zero
+import, a typical `import { stitch }` tree-shakes to **~22 kB**. With zero
runtime dependencies, that figure is the entire cost — not the tip of a
transitive tree.
diff --git a/apps/docs/content/docs/getting-started/installation.mdx b/apps/docs/content/docs/getting-started/installation.mdx
index 04ed85aa..3937b7eb 100644
--- a/apps/docs/content/docs/getting-started/installation.mdx
+++ b/apps/docs/content/docs/getting-started/installation.mdx
@@ -7,7 +7,7 @@ Install the package, import `stitch`, and turn your first endpoint into a typed,
callable function. `stitchapi` has zero dependencies and runs anywhere `fetch`
does — Node, the browser, and edge runtimes. The whole entry is **~24 kB
minified + gzipped** — and with no dependencies, there is no transitive tree
-behind it (a typical `import { stitch }` tree-shakes to ~21 kB).
+behind it (a typical `import { stitch }` tree-shakes to ~22 kB).
**Validators are bring-your-own.** Because `stitchapi` ships with zero
diff --git a/apps/docs/lib/source.ts b/apps/docs/lib/source.ts
index e8ff2e49..f9a4b195 100644
--- a/apps/docs/lib/source.ts
+++ b/apps/docs/lib/source.ts
@@ -25,7 +25,7 @@ Search these docs instead of loading the whole file: this site is also a hosted
- Capability, not credential: an agent invokes a stitch and gets structured, validated, traceable data; the secret stays behind the boundary.
- One context-frugal **code-mode** tool (run_stitch + list_stitches + describe_stitch), not one tool per endpoint — adding APIs never floods the context window.
- No server, no codegen, no config files — a URL and one example response is enough; only explicit composition (no ambient/global config a stitch silently inherits).
-- Zero-dependency core, ~24 kB min+gzip for the whole entry (~21 kB for a tree-shaken import { stitch }), validator-agnostic (bring your own Standard Schema / Zod), and it runs in the browser.
+- Zero-dependency core, ~24 kB min+gzip for the whole entry (~22 kB for a tree-shaken import { stitch }), validator-agnostic (bring your own Standard Schema / Zod), and it runs in the browser.
- Composes with your data layer: a stitch is the queryFn for TanStack Query / SWR — it owns the call's resilience; your query layer owns view state.
## Quickstart
diff --git a/packages/core/README.md b/packages/core/README.md
index 00fd280b..b8c586b2 100644
--- a/packages/core/README.md
+++ b/packages/core/README.md
@@ -120,7 +120,7 @@ No server, no codegen, no config files, no implicit inheritance — **only expli
- **CLI, HTTP & MCP surfaces** - the definition your code imports is also runnable from the shell (`stitch run ` streams JSONL events), served over HTTP (`stitch serve`), or exposed to agents over MCP (`stitch mcp`) — the same stitch behind every front door.
- **Typed URLs** - full [RFC 6570](https://datatracker.ietf.org/doc/html/rfc6570) URI templates (`{id}`, `{+path}`, `{?q,sort}`, explode `*`, prefix `:n`), and a `qs`-style query builder that serializes nested objects (`a[b]=c`) and arrays — both dependency-free.
- **Pluggable transport** - `fetch` by default; drop in the shipped `axiosAdapter`, or any `Adapter` function, to route requests through axios or another HTTP client.
-- **Zero runtime dependencies** - `"dependencies": {}`; built on the platform's global `fetch`; tree-shakeable. The whole entry is **~24 kB min+gzip**; a typical `import { stitch }` trims to **~21 kB** — and with no transitive tree, that is the entire cost.
+- **Zero runtime dependencies** - `"dependencies": {}`; built on the platform's global `fetch`; tree-shakeable. The whole entry is **~24 kB min+gzip**; a typical `import { stitch }` trims to **~22 kB** — and with no transitive tree, that is the entire cost.
## Documentation
@@ -162,7 +162,7 @@ const { stitch } = require("stitchapi");
The runtime ships with zero dependencies. Schema validation is bring-your-own — pass a [Zod](https://zod.dev) schema or any [Standard Schema](https://standardschema.dev) validator ([Valibot](https://valibot.dev), [ArkType](https://arktype.io), …); none of them is bundled. The examples below use Zod for familiarity.
-**Bundle size.** The whole `stitchapi` entry is **~24 kB minified + gzipped** (66 kB raw, ~21 kB brotli); because the package is side-effect-free and every surface beyond `http` lives behind its own subpath import, a typical `import { stitch }` tree-shakes to **~21 kB min+gzip**. With zero dependencies, that is the _whole_ cost — there is no transitive tree to install or audit.
+**Bundle size.** The whole `stitchapi` entry is **~24 kB minified + gzipped** (66 kB raw, ~22 kB brotli); because the package is side-effect-free and every surface beyond `http` lives behind its own subpath import, a typical `import { stitch }` tree-shakes to **~22 kB min+gzip**. With zero dependencies, that is the _whole_ cost — there is no transitive tree to install or audit.
## Quick start
diff --git a/packages/core/scripts/bundle-size.mjs b/packages/core/scripts/bundle-size.mjs
index 55a338af..ac0da9c1 100644
--- a/packages/core/scripts/bundle-size.mjs
+++ b/packages/core/scripts/bundle-size.mjs
@@ -273,19 +273,42 @@ const KB = 1024;
// are UNCHANGED at 24 / 21 this time — 23.91 still rounds to 24 — so no README or docs figure
// moves; verified against the `bundle-advertised-size` tether rather than assumed, which is the
// mistake the ADR 0024 raise made.
+// Budgets raised for the abort-reason fix (24.10→24.30 / 21.50→21.70; measured 24.08 / 21.51
+// against a `main` at 24.08 / 21.50, so the fix is ±0.00 / +0.01). A caller's `abort(reason)`
+// came back as a minted `Error('aborted')` whenever the abort landed in a retry-backoff sleep,
+// and a mid-flight abort still emitted a `retry` progress event (and fired onRetry) before dying
+// there — a phantom retry for a deliberately cancelled call. The bytes buy the attempt-loop
+// guard (a cancelled call rethrows instead of entering the retry path). The reason-preserving
+// rejection itself came out MINIFIED-SMALLER (−0.09 KB: `sleep`, the engine's abort paths and
+// `withTimeout`'s link now share one `abortReason` where each had a private copy), but gzip
+// charges +0.01 for it — the deleted copies were near-free backreferences, and keeping them
+// duplicated MEASURED no better (21.51 either way, with +0.19 KB minified), so the dedup stays.
+//
+// It cannot move behind a subpath: this is `sleep` and the attempt loop — the resilience chain
+// `stitch()` IS — on the core path by construction.
+//
+// The conventional ~0.2 KB step rather than a minimum one: `main` had run down to 0.02 / 0.00
+// headroom, which is why a +0.01 KB fix tripped the gate at all — the same shape #620 records.
+// Sized to the headroom the gate is meant to hold, so the next small core-path fix is not gated
+// on a budget PR of its own; headroom lands at 0.22 / 0.19.
+//
+// The ADVERTISED figure moves with it: `import { stitch }` crosses its rounding boundary at
+// 21.505 KB measured, so every site quoting it goes ~21 → ~22 kB under the
+// `bundle-advertised-size` tether (the whole entry stays ~24) — both READMEs, the installation
+// and principles pages, the home-page metrics component, and the docs' source blurb.
// `advertised: true` means the READMEs/docs quote this scenario's rounded gzip kB — see the
// `--json` note below for why that flag, not the row's presence, drives the drift tether.
const SCENARIOS = [
{
name: 'stitchapi — whole entry',
code: `export * from './index.mjs';`,
- budget: 24.1 * KB,
+ budget: 24.3 * KB,
advertised: true,
},
{
name: 'import { stitch }',
code: `export { stitch } from './index.mjs';`,
- budget: 21.5 * KB,
+ budget: 21.7 * KB,
advertised: true,
},
{
diff --git a/packages/core/src/engine.ts b/packages/core/src/engine.ts
index 481fd349..cd1739a4 100644
--- a/packages/core/src/engine.ts
+++ b/packages/core/src/engine.ts
@@ -40,6 +40,7 @@ import type {
} from './types';
import { StitchError } from './types';
import {
+ abortReason,
appendQueryString,
buildQuery,
expandPath,
@@ -565,15 +566,6 @@ async function acquireWithin(
}
}
-// The Error to reject with when a caller's signal is already/just aborted — its own `reason` when
-// that is an Error (the default AbortError, or a caller-supplied one), else a generic abort Error.
-function abortReason(signal: AbortSignal): Error {
- const reason: unknown = signal.reason;
- return reason instanceof Error
- ? reason
- : new Error('the operation was aborted');
-}
-
// Materialize a streaming-path error body for StitchError.body. A streaming adapter hands back the
// live `ReadableStream` unparsed (so it can be decoded into deltas); on the error branch the stream
// is never decoded, so read it to text and best-effort JSON-parse it — the same shape the buffered
@@ -701,6 +693,9 @@ async function* attemptLoop(
attempt,
error: err,
});
+ // A caller's abort is a deliberate cancel, not a failed attempt to try again: no
+ // `retry` event, no onRetry, no backoff — the run ends here with the abort error.
+ if (baseReq.signal?.aborted) throw err;
if (attempt < max) {
yield {
type: 'progress',
diff --git a/packages/core/src/resilience.ts b/packages/core/src/resilience.ts
index efb92d87..133855c7 100644
--- a/packages/core/src/resilience.ts
+++ b/packages/core/src/resilience.ts
@@ -16,7 +16,7 @@ import type {
// extends `StitchError` per CONTRACT.md P10. `types.ts` imports nothing at runtime — its own
// imports are all type-only — so this edge adds no cycle.
import { StitchError } from './types';
-import { parseDuration, parseRate, systemClock } from './util';
+import { abortReason, parseDuration, parseRate, systemClock } from './util';
export class TimeoutError extends Error {}
@@ -193,15 +193,6 @@ export function createThrottle(
/** Internal: keys the non-enumerable per-key state Map probe used by the resource-leak suite. */
export const THROTTLE_STATES = Symbol('stitch.throttle.states');
-// The Error to reject with when a linked signal is already aborted — its own `reason` when that is
-// an Error (the default AbortError, or a caller-supplied one), else a generic abort Error.
-function abortError(signal: AbortSignal): Error {
- const reason: unknown = signal.reason;
- return reason instanceof Error
- ? reason
- : new Error('the operation was aborted');
-}
-
/**
* Run `fn` with an AbortSignal that aborts after `ms`. On timeout, reject with TimeoutError and
* ensure the signal is aborted. If `ms` is undefined, just run `fn` with a non-aborting signal.
@@ -218,7 +209,7 @@ export function withTimeout(
const controller = new AbortController();
let unlink: (() => void) | undefined;
if (linkSignal) {
- if (linkSignal.aborted) return Promise.reject(abortError(linkSignal));
+ if (linkSignal.aborted) return Promise.reject(abortReason(linkSignal));
const onAbort = () => {
controller.abort(linkSignal.reason);
};
diff --git a/packages/core/src/test-clock.ts b/packages/core/src/test-clock.ts
index 5a12de9c..34dd844b 100644
--- a/packages/core/src/test-clock.ts
+++ b/packages/core/src/test-clock.ts
@@ -3,6 +3,7 @@
// resolve with zero real waiting — drive them with `advance(ms)`. (Per ADR 0010, `timeout.total`
// and event `at`/`ms` timestamps stay on wall-clock.) Browser-safe: no `node:*`.
import type { Clock, TimerHandle } from './types';
+import { abortReason } from './util';
/** A {@link Clock} whose time only moves when you call {@link ManualClock.advance}. */
export interface ManualClock extends Clock {
@@ -63,19 +64,25 @@ export function manualClock(start = 0): ManualClock {
clearTimer: cancel,
sleep: (ms, signal) =>
new Promise((resolve, reject) => {
+ // Reject with the signal's reason, exactly as `systemClock.sleep` does — a test
+ // that aborts with a custom reason must see it through the injected clock too.
if (signal?.aborted) {
- reject(new Error('aborted'));
+ reject(abortReason(signal));
+ return;
+ }
+ if (!signal) {
+ schedule(resolve, ms);
return;
}
const onAbort = () => {
cancel(handle);
- reject(new Error('aborted'));
+ reject(abortReason(signal));
};
const handle = schedule(() => {
- signal?.removeEventListener('abort', onAbort);
+ signal.removeEventListener('abort', onAbort);
resolve();
}, ms);
- signal?.addEventListener('abort', onAbort, { once: true });
+ signal.addEventListener('abort', onAbort, { once: true });
}),
async advance(ms: number): Promise {
const target = current + Math.max(0, ms);
diff --git a/packages/core/src/test-mock.ts b/packages/core/src/test-mock.ts
index df767adf..fd613e5b 100644
--- a/packages/core/src/test-mock.ts
+++ b/packages/core/src/test-mock.ts
@@ -11,7 +11,7 @@ import type {
AdapterResponse,
AtLeastOne,
} from './types';
-import { parseDuration } from './util';
+import { abortReason, parseDuration } from './util';
/** One canned response. Omitted fields default sensibly (`status` 200, empty headers). */
export interface MockResponse {
@@ -105,11 +105,12 @@ const matches = (req: AdapterRequest, m: MockMatch | undefined): boolean => {
const at = (arr: T[], i: number): T => arr[Math.min(i, arr.length - 1)] as T;
// An abortable delay: rejects the moment `signal` aborts, so a stitch `timeout` (which aborts the
-// per-attempt signal) cancels a slow mock response exactly as it would a real socket.
+// per-attempt signal) cancels a slow mock response exactly as it would a real socket. Rejects with
+// the signal's reason — real `fetch` surfaces `signal.reason`, so a caller's custom abort must too.
const sleep = (ms: number, signal?: AbortSignal): Promise =>
new Promise((resolve, reject) => {
if (signal?.aborted) {
- reject(new Error('aborted'));
+ reject(abortReason(signal));
return;
}
const t = setTimeout(resolve, ms);
@@ -117,7 +118,7 @@ const sleep = (ms: number, signal?: AbortSignal): Promise =>
'abort',
() => {
clearTimeout(t);
- reject(new Error('aborted'));
+ reject(abortReason(signal));
},
{ once: true },
);
@@ -166,7 +167,7 @@ export function mockAdapter(
// request — every real transport refuses it, and a cancellation test written against a
// mock that answered was asserting the opposite of production. Nothing was sent, so the
// spy records no call and the route's response sequence keeps its place.
- if (req.signal?.aborted) throw new Error('aborted');
+ if (req.signal?.aborted) throw abortReason(req.signal);
log.push(req);
const idx = list.findIndex(
(r) =>
diff --git a/packages/core/src/util.ts b/packages/core/src/util.ts
index ca65c69c..268f56a2 100644
--- a/packages/core/src/util.ts
+++ b/packages/core/src/util.ts
@@ -39,7 +39,7 @@ export function newRunContext(parent?: {
export function sleep(ms: number, signal?: AbortSignal): Promise {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
- reject(new Error('aborted'));
+ reject(abortReason(signal));
return;
}
const t = setTimeout(resolve, ms);
@@ -47,13 +47,27 @@ export function sleep(ms: number, signal?: AbortSignal): Promise {
'abort',
() => {
clearTimeout(t);
- reject(new Error('aborted'));
+ reject(abortReason(signal));
},
{ once: true },
);
});
}
+/**
+ * The Error an aborted wait rejects with: the signal's own `reason` when that is an Error (the
+ * default AbortError, or a caller-supplied `abort(reason)`), else a generic abort Error. The one
+ * spelling of "which error does an abort surface?" — shared by {@link sleep}, the engine's abort
+ * paths, and `withTimeout`'s signal link, so a caller's custom reason survives no matter where in
+ * the resilience chain the abort lands.
+ */
+export function abortReason(signal: AbortSignal): Error {
+ const reason: unknown = signal.reason;
+ return reason instanceof Error
+ ? reason
+ : new Error('the operation was aborted');
+}
+
/**
* The default {@link Clock}: wall-clock time and the platform's global timers. The behaviour the
* engine has always had — injecting a different `Clock` (e.g. `manualClock()`) is opt-in.
diff --git a/packages/core/test/abort-reason.spec.ts b/packages/core/test/abort-reason.spec.ts
new file mode 100644
index 00000000..cc0b5fa2
--- /dev/null
+++ b/packages/core/test/abort-reason.spec.ts
@@ -0,0 +1,120 @@
+// A caller's abort must surface the caller's OWN error, and a deliberate cancel must never read
+// as a retry. Two engine guarantees pinned here, plus the clock primitive they rest on:
+// • an abort DURING a retry backoff rejects with the signal's `reason` — `sleep` (systemClock
+// and manualClock alike) rejects with the reason when it is an Error, so a custom
+// `abort(reason)` (or the default AbortError) rides out of the engine instead of a generic
+// `Error('aborted')` minted by the sleep;
+// • an abort MID-FLIGHT ends the run at the attempt-loop catch: no `retry` progress event, no
+// `onRetry` hook, no backoff — before the guard, a cancelled call emitted a phantom retry and
+// only then died in the backoff sleep.
+import { stitch, systemClock } from '../src';
+import { manualClock, mockAdapter } from '../src/testing';
+import type { StitchEvent } from '../src/types';
+
+describe('abort during a retry backoff preserves the reason', () => {
+ test('a custom abort(reason) surfaces as the call error, not a generic abort', async () => {
+ const clock = manualClock();
+ const api = mockAdapter({
+ match: '/flaky',
+ respond: [{ status: 503 }, { body: { ok: true } }],
+ });
+ const ac = new AbortController();
+ const call = stitch({
+ baseUrl: 'https://api.test',
+ path: '/flaky',
+ adapter: api,
+ retry: {
+ attempts: 2,
+ on: [503],
+ backoff: { curve: 'fixed', base: 10_000 },
+ },
+ clock,
+ });
+
+ const p = call({ signal: ac.signal }).safe();
+ await clock.advance(0); // first attempt → 503 → the backoff sleep is armed
+ expect(api.callCount()).toBe(1);
+ expect(clock.pending()).toBe(1); // parked on the backoff
+
+ ac.abort(new Error('user closed the panel')); // mid-backoff, with a custom reason
+
+ const res = await p; // settles on the abort alone — no advance needed
+ expect(res.ok).toBe(false);
+ expect(res.error?.message).toBe('user closed the panel');
+ expect(api.callCount()).toBe(1); // the second attempt never ran
+ expect(clock.pending()).toBe(0); // the backoff timer was dropped, not leaked
+ });
+});
+
+describe('abort mid-flight is a cancel, not a retry', () => {
+ test('no retry event, no onRetry hook, and the reason still surfaces', async () => {
+ const api = mockAdapter({
+ match: '/slow',
+ // Long enough that only the abort can end the attempt; the delay is abortable, so the
+ // cancel cuts it short like a real socket.
+ respond: { body: { ok: true }, delay: 5_000 },
+ });
+ const ac = new AbortController();
+ const retried: unknown[] = [];
+ const call = stitch({
+ baseUrl: 'https://api.test',
+ path: '/slow',
+ adapter: api,
+ retry: { attempts: 3, on: [503], backoff: { base: 5 } },
+ hooks: {
+ onRetry: (ctx) => {
+ retried.push(ctx);
+ },
+ },
+ });
+
+ const events: StitchEvent[] = [];
+ const drained = (async () => {
+ for await (const ev of call({ signal: ac.signal }).stream())
+ events.push(ev);
+ })();
+
+ // Let the attempt reach the transport, then cancel deliberately.
+ await new Promise((r) => setTimeout(r, 10));
+ expect(api.callCount()).toBe(1); // in flight
+ ac.abort(new Error('nevermind, user cancelled'));
+ await drained;
+
+ expect(
+ events.filter((e) => e.type === 'progress' && e.phase === 'retry'),
+ ).toHaveLength(0);
+ expect(retried).toHaveLength(0);
+ expect(events.find((e) => e.type === 'error')).toMatchObject({
+ type: 'error',
+ message: 'nevermind, user cancelled',
+ });
+ expect(api.callCount()).toBe(1); // no second attempt after the cancel
+ });
+});
+
+// The primitive the backoff guarantee rests on: `systemClock.sleep` rejects with the signal's
+// reason — the SAME instance, so `instanceof`/`cause` chains the caller built stay intact.
+describe('systemClock.sleep rejects with the abort reason', () => {
+ test('an abort mid-sleep rejects with the caller-supplied Error instance', async () => {
+ const ac = new AbortController();
+ const reason = new Error('caller reason');
+ const p = systemClock.sleep(60_000, ac.signal);
+ ac.abort(reason);
+ await expect(p).rejects.toBe(reason);
+ });
+
+ test('a pre-aborted signal rejects with its existing reason', async () => {
+ const ac = new AbortController();
+ const reason = new RangeError('already gone');
+ ac.abort(reason);
+ await expect(systemClock.sleep(60_000, ac.signal)).rejects.toBe(reason);
+ });
+
+ test('a non-Error reason falls back to a generic abort Error', async () => {
+ const ac = new AbortController();
+ ac.abort('just a string');
+ await expect(systemClock.sleep(0, ac.signal)).rejects.toThrow(
+ 'the operation was aborted',
+ );
+ });
+});
diff --git a/packages/core/test/manual-clock.spec.ts b/packages/core/test/manual-clock.spec.ts
index a0104093..fe37b169 100644
--- a/packages/core/test/manual-clock.spec.ts
+++ b/packages/core/test/manual-clock.spec.ts
@@ -106,4 +106,14 @@ describe('manualClock: sleep', () => {
await expect(p).rejects.toThrow('aborted');
expect(c.pending()).toBe(0);
});
+
+ test('sleep rejects with the caller-supplied abort reason (mirrors systemClock)', async () => {
+ const c = manualClock();
+ const ac = new AbortController();
+ const reason = new Error('deliberate cancel');
+ const p = c.sleep(100, ac.signal);
+ ac.abort(reason);
+ await expect(p).rejects.toBe(reason); // the same instance, not a re-minted Error
+ expect(c.pending()).toBe(0);
+ });
});