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();