diff --git a/CHANGELOG.md b/CHANGELOG.md
index 198ac711..cbc0b623 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -566,6 +566,28 @@ npm release are grouped under the in-development version that introduced them.
### Fixed
+- **Adding `cache: { ttl }` no longer turns a handled vendor failure into a process exit.**
+ ([#670](https://github.com/rejifald/StitchAPI/issues/670)) A cached stitch whose vendor returned
+ `503` emitted an **unhandled promise rejection**, which under Node's default
+ `--unhandled-rejections=throw` terminates the process — on a failure the caller had handled
+ correctly, with `.safe()` returning an honest `ok: false`. The same failure with no `cache` block
+ produced none.
+
+ The coalescer's leader rejects one shared promise to release its waiters. With no concurrent
+ caller there are no waiters, so nothing ever attached a handler and the rejection went
+ unobserved. That made the bug **invisible in the shape a test takes and fatal in the shape
+ production has**: a test exercises coalescing with a concurrent burst, and a follower's `await`
+ catches the rejection by accident; a webhook backlog or retry drain arrives staggered, where
+ every call is its own leader. Measured against a failing vendor, 20 staggered calls produced 20
+ unhandled rejections; the same 20 as a burst produced none.
+
+ The shared promise now carries a terminal no-op handler from the moment it is created, so being
+ unobserved is never fatal. **A follower still receives the leader's failure unchanged** — same
+ tick, same error identity — because the handler is attached to a derived promise and discarded;
+ only the coalescer's own liability is retired. Failure is still not _shared_ (a follower re-runs
+ independently, as before); [#653](https://github.com/rejifald/StitchAPI/issues/653) tracks
+ whether it should be, and this leaves that channel intact for it.
+
- **`@stitchapi/aws-sigv4` stamps `x-amz-date` from the injected clock, so SigV4 is testable on
virtual time.** ([#658](https://github.com/rejifald/StitchAPI/issues/658)) The signer called
`amzDateOf(new Date())`, so 600 **virtual** seconds moved the shipped stamp **0** seconds and a
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..3ae98034 100644
--- a/packages/core/scripts/bundle-size.mjs
+++ b/packages/core/scripts/bundle-size.mjs
@@ -273,6 +273,33 @@ 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.
+//
+// `import { stitch }` raised for the coalescer's unhandled-rejection guard — #670 (21.50→21.55 KB;
+// measured 21.51 = 22026 B against a `main` at 22015 B, so the fix is +11 B). A cached stitch whose
+// vendor fails, with no concurrent follower, rejected the coalescer's shared promise with nobody
+// attached to it: a handled failure (`.safe()` returning `ok: false`) killed the process under
+// Node's default `--unhandled-rejections=throw`. The bytes are one terminal `.catch`, attached
+// where that promise is created.
+//
+// It cannot move behind a subpath: the coalescer is reached from `stitch()` whenever `cache` is
+// configured, and it is the fix for a crash, not a capability that could be opted into. There is no
+// cheaper spelling — resolving a sentinel instead of rejecting would be smaller and would remove
+// the hazard outright, but it would throw away the rejection channel #653 wants to hand to
+// followers. Dropping the leader claim's unread `promise` field was measured too: 3 B, which pays
+// for none of this and is a public type change on `stitchapi/cache`, so it is not taken here.
+//
+// A MINIMUM step, not the ~0.2 KB this gate usually restores — matching #477/#524/#485: this is a
+// fix squeezing past a full ceiling, not a new capability, and `main` had run down to 1 byte.
+// Headroom lands at 41 B here and 14 B on the whole entry (unchanged at 24.10), so the next
+// core-path byte trips this gate again; sizing that step is the maintainer's call, not a bug fix's.
+//
+// The ADVERTISED figure moves, and NOT because of this change: 21.5 KB is both the budget and a
+// rounding boundary (22016 B), and `main` measured 22015 B — one byte below both. Any core-path
+// byte at all takes `import { stitch }` from ~21 → ~22 kB. Nine figures across the six sites under
+// the `bundle-advertised-size` tether — both READMEs, the installation and principles pages, the
+// home-page metrics component, and the docs' source blurb — propagated by hand and verified with
+// the tether. (The core README's "~21 kB brotli" moves with them and is more accurate for it: the
+// whole entry's brotli is 21.6 KB, which rounds to 22, not 21.)
// `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 = [
@@ -285,7 +312,7 @@ const SCENARIOS = [
{
name: 'import { stitch }',
code: `export { stitch } from './index.mjs';`,
- budget: 21.5 * KB,
+ budget: 21.55 * KB,
advertised: true,
},
{
diff --git a/packages/core/src/cache.ts b/packages/core/src/cache.ts
index 60ff709c..174f39cc 100644
--- a/packages/core/src/cache.ts
+++ b/packages/core/src/cache.ts
@@ -193,12 +193,16 @@ export interface CoalesceJoinOptions {
onCancel?: () => void;
}
+/** The WRITE end of a shared run: the leader runs the chain and reports the real error to its own
+ * caller. `promise` is here for symmetry only — a leader must never await it (doing so before
+ * `settle`/`fail` deadlocks on itself), which is why rejecting it must be safe with no audience. */
export interface LeaderClaim {
leader: true;
promise: Promise;
settle: (value: T) => void;
fail: (err: unknown) => void;
}
+/** The READ end: a follower has nothing to run, only the leader's one result to await. */
export interface FollowerClaim {
leader: false;
promise: Promise;
@@ -227,6 +231,17 @@ export class InflightCoalescer {
resolve = res;
reject = rej;
});
+ // The shared promise is an OFFER a follower may take up, not a result anyone is
+ // obliged to consume: the leader never awaits it (it owns and throws the real
+ // error itself), so with no follower a `fail()` rejects a promise nobody observes
+ // — an unhandled rejection that kills the process under Node's default
+ // `--unhandled-rejections=throw` (#670). Marking it handled in the same breath as
+ // creating it makes that structural rather than dependent on who happens to join.
+ // This attaches to a DERIVED promise and discards it; `promise` is untouched, so a
+ // follower's `await` still sees the same rejection, same tick, same error identity.
+ promise.catch(() => {
+ /* an audience of nobody is not an error */
+ });
entry = { promise, resolve, reject, refs: 0 };
if (opts?.onCancel) entry.onCancel = opts.onCancel;
this.map.set(key, entry);
diff --git a/packages/core/test/cache-internals.spec.ts b/packages/core/test/cache-internals.spec.ts
index 06912ca2..287fdbae 100644
--- a/packages/core/test/cache-internals.spec.ts
+++ b/packages/core/test/cache-internals.spec.ts
@@ -229,6 +229,28 @@ describe('InflightCoalescer', () => {
expect(c.size).toBe(0);
});
+ test('a LONE leader failure is not an unhandled rejection (#670)', async () => {
+ // The coalescer's own half of the engine-level guard in cache.spec.ts: with no follower,
+ // nothing awaits the shared promise, so rejecting it would go unobserved and terminate the
+ // process under Node's default `--unhandled-rejections=throw`. The promise carries a
+ // terminal handler from construction, so `fail()` stays safe with an audience of nobody.
+ const unhandled: unknown[] = [];
+ const onUnhandled = (reason: unknown): void => {
+ unhandled.push(reason);
+ };
+ process.on('unhandledRejection', onUnhandled);
+ try {
+ const c = new InflightCoalescer();
+ const a = c.join('k');
+ if (a.leader) a.fail(new Error('boom'));
+ expect(c.size).toBe(0);
+ await new Promise((r) => setTimeout(r, 0));
+ } finally {
+ process.off('unhandledRejection', onUnhandled);
+ }
+ expect(unhandled).toEqual([]);
+ });
+
test('distinct keys do not coalesce', () => {
const c = new InflightCoalescer();
expect(c.join('a').leader).toBe(true);
diff --git a/packages/core/test/cache.spec.ts b/packages/core/test/cache.spec.ts
index 63a964ef..1a5cf2ea 100644
--- a/packages/core/test/cache.spec.ts
+++ b/packages/core/test/cache.spec.ts
@@ -9,6 +9,12 @@ import { clearFingerprinters, registerFingerprinter } from '../src/fingerprint';
import type { SchemaFingerprinter } from '../src/fingerprint';
import type { StandardSchemaV1 } from '../src/standard-schema';
+import { spawn } from 'node:child_process';
+import { mkdtemp, rm, writeFile } from 'node:fs/promises';
+import { createRequire } from 'node:module';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
const sleep = (ms: number): Promise =>
new Promise((resolve) => setTimeout(resolve, ms));
@@ -186,6 +192,39 @@ describe('cache — in-process coalescing', () => {
expect(calls()).toBe(3); // leader + two independent re-runs
});
+ test('a LONE failing leader leaves no unhandled rejection (#670)', async () => {
+ // The leader rejects the coalescer's shared promise to release its waiters — but with no
+ // concurrent caller there ARE no waiters, so nothing attaches a handler and the rejection
+ // goes unobserved → Node's default `--unhandled-rejections=throw` kills the process, on a
+ // failure the caller HANDLED. This must be a single sequential call: a burst (the shape
+ // every other test in this block uses) has a follower whose `await` catches the rejection
+ // and hides the bug, which is why the leader-failure test above never caught it.
+ const unhandled: unknown[] = [];
+ const onUnhandled = (reason: unknown): void => {
+ unhandled.push(reason);
+ };
+ process.on('unhandledRejection', onUnhandled);
+ let ok: boolean;
+ try {
+ const { adapter, calls } = counting({ failCall: 1 });
+ const s = stitch({
+ url: URL,
+ adapter,
+ trace: false,
+ cache: { ttl: '60s', tenancy: 'app' },
+ });
+ ok = (await s.safe()).ok;
+ expect(calls()).toBe(1);
+ // Let the leader's rejected shared promise reach the end of a turn, which is where an
+ // unobserved rejection is reported.
+ await sleep(0);
+ } finally {
+ process.off('unhandledRejection', onUnhandled);
+ }
+ expect(ok).toBe(false); // the caller handled it, honestly
+ expect(unhandled).toEqual([]); // …and paid nothing for having handled it
+ });
+
test('coalesce:false disables collapsing (still caches)', async () => {
const { adapter, calls } = counting({ delay: 40 });
const s = stitch({
@@ -201,6 +240,78 @@ describe('cache — in-process coalescing', () => {
});
});
+describe('cache — a handled failure does not kill the process (#670)', () => {
+ // The in-process guard above asserts that no `unhandledRejection` EVENT fires. What a caller
+ // actually experiences is the exit code — and a test runner installs handlers of its own, so
+ // an in-process assertion can pass while the same code would still terminate a real program.
+ // This settles the claim where it is made: a bare `node`, no flags, so the default
+ // `--unhandled-rejections=throw` is in force, running the issue's reproduction verbatim.
+ //
+ // The library is bundled from `src/` with esbuild — already a devDependency, and already how
+ // `scripts/bundle-size.mjs` measures — so the child runs the WORKING TREE and the test needs
+ // no `pnpm build` to have happened first.
+ test('the issue’s reproduction exits 0 and reaches the line after the call', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'stitch-cache-670-'));
+ try {
+ const esbuild = createRequire(import.meta.url)(
+ 'esbuild',
+ ) as typeof import('esbuild');
+ esbuild.buildSync({
+ entryPoints: [
+ join(import.meta.dirname, '..', 'src', 'index.ts'),
+ ],
+ outfile: join(dir, 'stitchapi.mjs'),
+ bundle: true,
+ format: 'esm',
+ platform: 'node',
+ external: ['node:*'],
+ define: { __PKG_VERSION__: '"0.0.0-test"' },
+ logLevel: 'silent',
+ });
+ await writeFile(
+ join(dir, 'repro.mjs'),
+ `import { stitch } from './stitchapi.mjs';\n` +
+ `const failing = async () => ({ status: 503, headers: {}, body: {} });\n` +
+ `const getThing = stitch({\n` +
+ ` url: 'https://api.vendor.test/v1/things/1',\n` +
+ ` adapter: failing,\n` +
+ ` trace: false,\n` +
+ ` cache: { ttl: '60s' },\n` +
+ `});\n` +
+ `const r = await getThing.safe();\n` +
+ `console.log('handled: ok=' + r.ok);\n` +
+ `await new Promise((res) => setTimeout(res, 50));\n` +
+ `console.log('STILL ALIVE');\n`,
+ 'utf8',
+ );
+
+ // Hermetic: a NODE_OPTIONS inherited from the runner could set
+ // `--unhandled-rejections=warn` and make this pass for the wrong reason.
+ const env = { ...process.env };
+ delete env['NODE_OPTIONS'];
+
+ const child = spawn(process.execPath, [join(dir, 'repro.mjs')], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ env,
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (c: Buffer) => (stdout += c.toString()));
+ child.stderr.on('data', (c: Buffer) => (stderr += c.toString()));
+ const code = await new Promise((resolve) =>
+ child.on('close', resolve),
+ );
+
+ expect(stdout).toContain('handled: ok=false'); // `.safe()` was honest…
+ expect(stdout).toContain('STILL ALIVE'); // …and the program outlived it
+ expect(stderr).not.toContain('cache: leader run failed');
+ expect(code).toBe(0);
+ } finally {
+ await rm(dir, { recursive: true, force: true });
+ }
+ }, 60_000);
+});
+
describe('cache — invalidation', () => {
test('handle.invalidate(input) is an exact eviction', async () => {
const { adapter, calls } = counting();