From daac2bd181a23326cb63fab1412bc3e5a4a9e55f Mon Sep 17 00:00:00 2001 From: Jonah Cohen Date: Wed, 5 Aug 2026 10:29:02 -0500 Subject: [PATCH 1/5] Type the AI plane's error codes; normalize retryAfter to a Date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bool.ai threw a BoolAiError whose `code` was a bare string, so nothing described what an app could actually receive. Ten codes come back as JSON from the plane; none of them were typed, and one of the two things an app most wants to do with a failure — wait the right amount of time and retry — had no supported shape. Adds BoolAiWireErrorCode (all ten, each documented with the status it arrives on) and BoolAiErrorCode, which adds "unknown_error" and stays OPEN. The open union is deliberate: the gateway ships independently of this package, so an app pinned to an older SDK can meet a code published after it. That's a runtime case to handle, not a compile error at the default arm. Two codes now share the 429. `rate_limited` is per-caller pacing; `app_credit_daily_cap` means this app has spent its share of the owner's credits for the day while the pool still has some. They want different copy and different retry behavior, so app code has to branch on `code`, never on `status`. The SDK already read `body.error` at both throw sites rather than mapping from the status, so the fix here is the type and the tests that pin it. New: BoolAiError.retryAfter, a Date whenever the gateway supplies a hint. The field has two incompatible wire forms — an ISO-8601 instant on the credit codes, a count of seconds on rate_limited — and no Retry-After header on any of them. Accepting both and exposing one means app code can compare or subtract it without knowing which code produced it. Absent, null, negative, and unparseable all yield undefined rather than an Invalid Date, so `if (err.retryAfter)` means what it looks like. A digits-only string takes the seconds path because new Date("45") is not an error in JavaScript — it is the year 2045 — so the alternative to a two-line guard is a plausible timestamp two decades out. Behavior change: a body with no readable code now surfaces as "unknown_error", where it previously defaulted to "ai_failed". ai_failed is a real code (502, provider request failed, credit refunded) that apps reasonably retry, and the shared plane preamble answers some 403s and 404s in text/plain with no JSON to read — so an unreadable failure was reported as a transient blip and a 404 got retried forever. A genuine 502 still arrives as ai_failed, and now only then. Also renames the 402 to out_of_app_credits, matching the gateway: the code names the credit pool rather than this plane, so every battery drawing on that pool reports the same string for the same condition. Minor bump — additive types plus the one fallback change. bool.ai stays gated server-side by the bool-ai flag, off by default, so these codes only reach apps in workspaces opted into the battery. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 45 +++++++++++++ README.md | 23 ++++++- package.json | 2 +- src/client.test.ts | 163 ++++++++++++++++++++++++++++++++++++++++++++- src/client.ts | 124 ++++++++++++++++++++++++++++++++-- src/index.ts | 2 + 6 files changed, 349 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2963d3..d3fc805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,50 @@ # Changelog +## 0.4.0 + +Types every error the AI plane can return, and normalizes the one field whose +wire shape isn't consistent. + +- `BoolAiError.code` is now typed. `BoolAiWireErrorCode` lists all ten codes the + plane returns as JSON — `out_of_app_credits`, `app_credit_daily_cap`, + `rate_limited`, `payload_too_large`, `missing_prompt`, `invalid_json`, + `method_not_allowed`, `not_found`, `ai_unavailable`, `ai_failed` — each + documented with the status it arrives on. `BoolAiErrorCode` adds + `"unknown_error"` and stays an open union: the gateway ships independently of + this package, so an app on an older SDK can meet a code published after it, + and that's a runtime case to handle rather than a compile error. + +- **Branch on `code`, not on `status`.** 429 is now two different conditions: + `rate_limited` (per-caller pacing) and `app_credit_daily_cap` (this app has + spent its share of the owner's credits for the day, while the pool itself + still has credits). An app that wants to tell "slow down" from "come back + later" has to read the code. + +- **New: `BoolAiError.retryAfter`,** a `Date` when the gateway supplies a retry + hint. The wire carries two incompatible forms — an ISO-8601 instant on the + credit codes, a count of seconds on `rate_limited` — and no `Retry-After` + header on any of them. The SDK accepts both and exposes one, so app code can + compare or subtract it without knowing which code produced it. Absent, `null`, + negative, and unparseable values all yield `undefined` rather than an + `Invalid Date`, so `if (err.retryAfter)` means what it looks like. + +- **Behavior change:** a failure whose body carries no readable code now + surfaces as `unknown_error`. It previously defaulted to `ai_failed` — a real + code meaning "the provider request failed, credit refunded," which apps + reasonably retry. The shared gateway plane preamble answers some 403s and 404s + in `text/plain`, so those have no JSON to read and were being reported as a + transient provider blip; retrying a 404 forever was the result. Apps that + branch on `ai_failed` to drive a retry should keep doing exactly that — it + still arrives on a genuine 502, and now only then. + +- Renamed to match the gateway: the 402 code is `out_of_app_credits` (was + `out_of_ai_credits`). The code names the credit pool rather than this plane, + so every battery drawing on that pool reports the same string for the same + condition. + +`bool.ai` remains gated server-side by the `bool-ai` feature flag, off by +default, so these codes only reach apps in workspaces opted into the battery. + ## 0.3.1 - **Fixes every published app that uses a live view.** `0.3.0` shipped diff --git a/README.md b/README.md index 44dde4e..3ca71d9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ tested, and upgradable independently of any one app. key in the bundle** — calls route through the gateway's AI plane (`/_bool/v1/ai`), which runs the prompt against Bool's provider credential and meters one AI credit against the app owner. Returns results directly and throws - a `BoolAiError` (with `status` + `code`, e.g. `"out_of_ai_credits"`) on failure: + a `BoolAiError` (with `status` + `code`, e.g. `"out_of_app_credits"`) on failure: ```ts const text = await bool.ai.generate("Summarize this review: " + review); @@ -93,6 +93,27 @@ tested, and upgradable independently of any one app. for await (const chunk of bool.ai.stream("Write a haiku")) setText((t) => t + chunk); ``` + On failure it throws `BoolAiError` with a machine-readable `code` + (`BoolAiWireErrorCode`) — branch on `code`, **not** on `status`, since one + status carries more than one code: + + ```ts + try { + await bool.ai.generate(prompt); + } catch (e) { + const err = e as BoolAiError; + if (err.code === "app_credit_daily_cap" && err.retryAfter) { + setNotice(`Back at ${err.retryAfter.toLocaleTimeString()}`); + } else if (err.code === "out_of_app_credits") { + setNotice("This app is out of credits."); + } + } + ``` + + `retryAfter` is a `Date` whenever the gateway supplies a retry hint, and + absent otherwise. The wire has two forms for it (an instant, or a delay in + seconds); the SDK normalizes both so app code only handles one. + Requires the workspace to be opted into the `bool-ai` server flag. - **React auth layer** (`bool-sdk/react`): ``, `useBoolAuth()`, ``, and the headless `useSignInForm()` state diff --git a/package.json b/package.json index 6219369..d0d1f8b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.3.3", + "version": "0.4.0", "description": "Client SDK for apps built on Bool \u2014 gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", diff --git a/src/client.test.ts b/src/client.test.ts index d69433b..c7ea5e2 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -370,7 +370,7 @@ describe("bool.ai battery", () => { test("generate throws BoolAiError carrying status + code on failure", async () => { respond = () => - new Response(JSON.stringify({ error: "out_of_ai_credits" }), { + new Response(JSON.stringify({ error: "out_of_app_credits" }), { status: 402, headers: { "content-type": "application/json" }, }); @@ -378,7 +378,166 @@ describe("bool.ai battery", () => { const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; expect(err).toBeInstanceOf(BoolAiError); expect(err.status).toBe(402); - expect(err.code).toBe("out_of_ai_credits"); + expect(err.code).toBe("out_of_app_credits"); + }); + + test("app_credit_daily_cap surfaces as a 429 with retryAfter as a Date", async () => { + respond = () => + new Response( + JSON.stringify({ error: "app_credit_daily_cap", retryAfter: "2026-08-06T00:00:00.000Z" }), + { status: 429, headers: { "content-type": "application/json" } }, + ); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.code).toBe("app_credit_daily_cap"); + expect(err.status).toBe(429); + expect(err.retryAfter).toBeInstanceOf(Date); + expect(err.retryAfter?.toISOString()).toBe("2026-08-06T00:00:00.000Z"); + }); + + test("app_credit_daily_cap on stream carries retryAfter too", async () => { + respond = () => + new Response( + JSON.stringify({ error: "app_credit_daily_cap", retryAfter: "2026-08-06T00:00:00.000Z" }), + { status: 429, headers: { "content-type": "application/json" } }, + ); + const client = createBoolClient(CONFIG); + const err = await (async () => { + try { + for await (const _ of client.ai.stream("hi")) void _; + } catch (e) { + return e as BoolAiError; + } + })(); + expect(err?.code).toBe("app_credit_daily_cap"); + expect(err?.retryAfter?.toISOString()).toBe("2026-08-06T00:00:00.000Z"); + }); + + test("a code with no retryAfter leaves the field undefined", async () => { + respond = () => + new Response(JSON.stringify({ error: "rate_limited" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.code).toBe("rate_limited"); + expect(err.retryAfter).toBeUndefined(); + }); + + // The gateway sends `retryAfter: null` (not an absent key) for a credit code + // whose period has no known end. + test("an explicit null retryAfter is treated as no hint", async () => { + respond = () => + new Response(JSON.stringify({ error: "out_of_app_credits", retryAfter: null }), { + status: 402, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.code).toBe("out_of_app_credits"); + expect(err.retryAfter).toBeUndefined(); + }); + + // rate_limited expresses retryAfter as SECONDS while the credit codes send an + // ISO instant. Both must reach app code as one type. + test("rate_limited's seconds form normalizes to an absolute Date", async () => { + respond = () => + new Response(JSON.stringify({ error: "rate_limited", retryAfter: 45 }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const before = Date.now(); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.retryAfter).toBeInstanceOf(Date); + const delta = err.retryAfter!.getTime() - before; + expect(delta).toBeGreaterThanOrEqual(45_000); + expect(delta).toBeLessThan(50_000); + }); + + test("retryAfter: 0 is a Date (retry now), not a dropped field", async () => { + respond = () => + new Response(JSON.stringify({ error: "rate_limited", retryAfter: 0 }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.retryAfter).toBeInstanceOf(Date); + }); + + test("a negative retryAfter is dropped rather than yielding a past Date", async () => { + respond = () => + new Response(JSON.stringify({ error: "rate_limited", retryAfter: -5 }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.retryAfter).toBeUndefined(); + }); + + // new Date("45") is the year 2045, not 45 seconds from now — a digits-only + // string must take the seconds path, not the instant path. + // The shared plane preamble answers some 403/404s in text/plain, so there is + // no code to read. That must NOT masquerade as ai_failed (a real 502 code + // apps retry on) — a 404 retried forever is the bug that would cause. + test("a text/plain body yields unknown_error, not ai_failed", async () => { + respond = () => new Response("Not found", { status: 404 }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.status).toBe(404); + expect(err.code).toBe("unknown_error"); + }); + + test("a genuine ai_failed 502 still surfaces as ai_failed", async () => { + respond = () => + new Response(JSON.stringify({ error: "ai_failed" }), { + status: 502, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.code).toBe("ai_failed"); + }); + + // The gateway ships independently of this package, so an app on an older SDK + // can meet a code this build has never heard of. Pass it through verbatim. + test("an unrecognized code passes through rather than being flattened", async () => { + respond = () => + new Response(JSON.stringify({ error: "some_future_code" }), { + status: 400, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.code).toBe("some_future_code"); + }); + + test("a digits-only string retryAfter is read as seconds, not as a year", async () => { + respond = () => + new Response(JSON.stringify({ error: "rate_limited", retryAfter: "45" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const before = Date.now(); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + const delta = err.retryAfter!.getTime() - before; + expect(delta).toBeGreaterThanOrEqual(45_000); + expect(delta).toBeLessThan(50_000); + }); + + test("an unparseable retryAfter is dropped rather than surfaced as Invalid Date", async () => { + respond = () => + new Response(JSON.stringify({ error: "app_credit_daily_cap", retryAfter: "soon" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + expect(err.retryAfter).toBeUndefined(); }); test("stream yields decoded text chunks", async () => { diff --git a/src/client.ts b/src/client.ts index 96b3fe9..be32ae9 100644 --- a/src/client.ts +++ b/src/client.ts @@ -150,17 +150,120 @@ export type BoolChangePayload = { * `{ type: "object", properties: { sentiment: { type: "string" } }, required: ["sentiment"] }`. */ export type BoolAiSchema = Record; +/** Normalize the gateway's `retryAfter` to a Date, or undefined when the field + * is absent or unparseable. + * + * The field has two wire forms depending on which code carries it: an ISO-8601 + * instant (an absolute moment the limit lifts) or a number of SECONDS from now + * (a relative delay). Both mean "not before this point", so both normalize to + * the absolute form — a Date is the shape app code can act on directly + * (compare, subtract, feed a timer) without knowing which code it came from. + * + * A digits-only STRING is read as seconds, not as a date. That case is not on + * the wire today, but `new Date("45")` is not an error in JavaScript — it + * yields the year 2045 — so a gateway that ever JSON-encodes the seconds form + * as a string would otherwise produce a plausible Date two decades out instead + * of a visible failure. + * + * The field may also be an explicit `null` — a credit code whose period has no + * known end sends the key with no value rather than omitting it. `null` and + * absent mean the same thing here: no hint, so no Date. + * + * There is no `Retry-After` HTTP header on any of these responses, so the body + * is the only source. */ +function parseRetryAfter(raw: unknown, now: number = Date.now()): Date | undefined { + // The relative (seconds) form: a number, or a digits-only string. + let seconds: number | undefined; + if (typeof raw === "number") seconds = raw; + else if (typeof raw === "string" && /^\d+$/.test(raw.trim())) seconds = Number(raw.trim()); + if (seconds !== undefined) { + if (!Number.isFinite(seconds) || seconds < 0) return undefined; + return new Date(now + seconds * 1000); + } + // The absolute (ISO-8601 instant) form. + if (typeof raw !== "string") return undefined; + const at = new Date(raw); + return Number.isNaN(at.getTime()) ? undefined : at; +} + +/** The code from an error body, or `"unknown_error"` when there isn't one. + * + * Deliberately NOT defaulting to `ai_failed`: that is a real code with a + * specific meaning (502, the provider request failed, credit refunded), and + * apps reasonably retry it. Reusing it as the catch-all made every + * unreadable-body failure — including the plain-text 403/404 the shared plane + * preamble returns, which has no JSON to read — look like a transient provider + * blip worth retrying. `unknown_error` says only what's true: the call failed + * and the SDK could not name why. */ +function readCode(body: unknown): BoolAiErrorCode { + const code = (body as { error?: unknown } | null | undefined)?.error; + return typeof code === "string" && code ? code : "unknown_error"; +} + +/** Every machine-readable error the AI plane returns as JSON, with the status it + * arrives on. Listed so app code can `switch` on a code and have the compiler + * check the arms. + * + * - `out_of_app_credits` (402) — the owner's shared credit pool is empty. + * - `app_credit_daily_cap` (429) — the pool has credits, but this app has spent + * its share for the day. Distinct from the above so an app can tell them + * apart; carries `retryAfter`. + * - `rate_limited` (429) — per-caller pacing; carries `retryAfter`. + * - `payload_too_large` (413) — the request body exceeded the cap. Reachable in + * normal use: a prompt built from an accumulated transcript grows without + * bound, and the cap is on BYTES, so multi-byte text hits it sooner than its + * character count suggests. + * - `missing_prompt` / `invalid_json` (400) — malformed request. + * - `method_not_allowed` (405) — wrong HTTP method. + * - `not_found` (404) — ambiguous by design: no such app, an app on an older + * runtime, an unknown AI sub-route, or the battery not being available to + * this workspace all collapse to it. Don't render it as "your app is gone". + * - `ai_unavailable` (503) — no model configured; the call cost nothing. + * - `ai_failed` (502) — the provider request failed; the credit is refunded. */ +export type BoolAiWireErrorCode = + | "out_of_app_credits" + | "app_credit_daily_cap" + | "rate_limited" + | "payload_too_large" + | "missing_prompt" + | "invalid_json" + | "method_not_allowed" + | "not_found" + | "ai_unavailable" + | "ai_failed"; + +/** `BoolAiError.code`: a wire code, `"unknown_error"` when the SDK couldn't read + * one, or any other string. + * + * The trailing `string` keeps this union OPEN on purpose. The gateway ships + * independently of this package, so an app pinned to an older SDK can receive a + * code added after it was published; a closed union would make that a type + * error at the `default` arm instead of the runtime case it actually is. Every + * known code still autocompletes and is still checked when you spell it. */ +export type BoolAiErrorCode = BoolAiWireErrorCode | "unknown_error" | (string & {}); + /** Thrown when a bool.ai call fails. `status` is the gateway HTTP status and - * `code` its machine-readable error (e.g. "out_of_ai_credits" on a 402, - * "rate_limited" on a 429) so app code can branch without string-matching. */ + * `code` its machine-readable error — see {@link BoolAiWireErrorCode}. + * + * Branch on `code`, never on `status`: one status can carry more than one code, + * and the two 429s mean different things (`app_credit_daily_cap` is a cap that + * lifts at a known time; `rate_limited` is short-term pacing). Two statuses can + * also carry the same code — `not_found` arrives as JSON here, but the shared + * plane preamble answers some 404s and 403s in plain text, which the SDK cannot + * read a code out of at all (those surface as `unknown_error`). */ export class BoolAiError extends Error { readonly status: number; - readonly code: string; - constructor(code: string, status: number) { + readonly code: BoolAiErrorCode; + /** The earliest moment this call is worth retrying, when the gateway said so; + * absent on codes that carry no retry hint. Always an absolute Date, whether + * the gateway expressed it as an instant or as a relative delay. */ + readonly retryAfter?: Date; + constructor(code: BoolAiErrorCode, status: number, retryAfter?: Date) { super(`bool.ai failed: ${code} (${status})`); this.name = "BoolAiError"; this.code = code; this.status = status; + this.retryAfter = retryAfter; } } @@ -592,7 +695,12 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { try { body = await res.json(); } catch (_) {} - if (!res.ok) throw new BoolAiError(body?.error ?? "ai_failed", res.status); + if (!res.ok) + throw new BoolAiError( + readCode(body), + res.status, + parseRetryAfter(body?.retryAfter), + ); // Structured → { object }; plain → { text }. Return the inner value. return opts.schema ? body?.object : body?.text; }) as BoolAi["generate"], @@ -609,7 +717,11 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { try { body = await res.json(); } catch (_) {} - throw new BoolAiError(body?.error ?? "ai_failed", res.status); + throw new BoolAiError( + readCode(body), + res.status, + parseRetryAfter(body?.retryAfter), + ); } // The gateway streams raw text deltas (text/plain). Decode and yield each // chunk as it arrives. diff --git a/src/index.ts b/src/index.ts index bc06389..16faee8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,8 @@ export { type BoolAi, type BoolAiSchema, BoolAiError, + type BoolAiErrorCode, + type BoolAiWireErrorCode, type BoolUser, type BoolChangePayload, type AuthEvent, From c3364da77d7b4b227d8daa581ddaaafc9af5a651 Mon Sep 17 00:00:00 2001 From: Jonah Cohen Date: Wed, 5 Aug 2026 10:38:26 -0500 Subject: [PATCH 2/5] Add a runtime code list and a type guard that narrows to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BoolAiErrorCode is open so a newer gateway's code can reach an older app without a type error. The cost is that comparing against it never catches a typo — any string is assignable, so `err.code === "out_of_app_credit"` compiles and the arm just never runs. isBoolAiWireErrorCode() narrows to the closed set. Inside the guard a switch over BoolAiWireErrorCode is exhaustiveness-checked, so a missing or misspelled case is a compile error. A test pins that the check is load-bearing rather than decorative: removing one case fails typecheck with `not assignable to never`. BOOL_AI_WIRE_ERROR_CODES exposes the same list at runtime. It is also now the single source the union derives from (`(typeof …)[number]`) — a separate array and hand-written union would be two lists to update, and the drift would be invisible until an app hit the code that was in one but not the other. A test pins the list against the gateway's ai-route.ts, so adding a code there fails here and points at the array to update. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 7 +++++ README.md | 5 ++++ src/client.test.ts | 66 ++++++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 58 +++++++++++++++++++++++++++++++--------- src/index.ts | 2 ++ 5 files changed, 126 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d3fc805..2796037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,13 @@ wire shape isn't consistent. this package, so an app on an older SDK can meet a code published after it, and that's a runtime case to handle rather than a compile error. + Because that union is open, comparing against it can't catch a typo — any + string is assignable. `isBoolAiWireErrorCode()` narrows to the closed set, and + inside that guard a `switch` is exhaustiveness-checked and a misspelled case is + an error rather than an arm that silently never runs. + `BOOL_AI_WIRE_ERROR_CODES` exposes the same list at runtime and is the single + source both the type and the guard derive from, so they can't drift. + - **Branch on `code`, not on `status`.** 429 is now two different conditions: `rate_limited` (per-caller pacing) and `app_credit_daily_cap` (this app has spent its share of the owner's credits for the day, while the pool itself diff --git a/README.md b/README.md index 3ca71d9..ffb8fe8 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ tested, and upgradable independently of any one app. absent otherwise. The wire has two forms for it (an instant, or a delay in seconds); the SDK normalizes both so app code only handles one. + `BoolAiErrorCode` is an open union, so a code added to the gateway after your + SDK was published still typechecks. To get an exhaustiveness-checked `switch`, + narrow with `isBoolAiWireErrorCode()` first — `BOOL_AI_WIRE_ERROR_CODES` is + the same list at runtime, if you'd rather iterate it than spell the cases. + Requires the workspace to be opted into the `bool-ai` server flag. - **React auth layer** (`bool-sdk/react`): ``, `useBoolAuth()`, ``, and the headless `useSignInForm()` state diff --git a/src/client.test.ts b/src/client.test.ts index c7ea5e2..9ce46fc 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -5,6 +5,9 @@ import { hasDefaultBoolClient, isDeploymentSubdomain, BoolAiError, + BOOL_AI_WIRE_ERROR_CODES, + isBoolAiWireErrorCode, + type BoolAiWireErrorCode, type BoolClientConfig, } from "./client"; @@ -425,6 +428,69 @@ describe("bool.ai battery", () => { expect(err.retryAfter).toBeUndefined(); }); + test("isBoolAiWireErrorCode accepts every known code and rejects others", () => { + for (const code of BOOL_AI_WIRE_ERROR_CODES) { + expect(isBoolAiWireErrorCode(code)).toBe(true); + } + expect(isBoolAiWireErrorCode("unknown_error")).toBe(false); + expect(isBoolAiWireErrorCode("some_future_code")).toBe(false); + expect(isBoolAiWireErrorCode("")).toBe(false); + // A near-miss: the kind of typo the closed union exists to catch. + expect(isBoolAiWireErrorCode("out_of_app_credit")).toBe(false); + }); + + // Pins the list against the gateway's ai-route.ts. If a code is added there, + // this is the test that should fail and send someone to update the array. + test("the wire code list matches the AI plane, exactly", () => { + expect([...BOOL_AI_WIRE_ERROR_CODES].sort()).toEqual([ + "ai_failed", + "ai_unavailable", + "app_credit_daily_cap", + "invalid_json", + "method_not_allowed", + "missing_prompt", + "not_found", + "out_of_app_credits", + "payload_too_large", + "rate_limited", + ]); + }); + + test("a thrown error's code narrows to the closed union", async () => { + respond = () => + new Response(JSON.stringify({ error: "app_credit_daily_cap" }), { + status: 429, + headers: { "content-type": "application/json" }, + }); + const client = createBoolClient(CONFIG); + const err = (await client.ai.generate("hi").catch((e) => e)) as BoolAiError; + if (!isBoolAiWireErrorCode(err.code)) throw new Error("expected a known code"); + // Inside the guard the compiler sees the closed union, so this switch is + // exhaustiveness-checked — the `never` default is the assertion. + const label: string = ((code: BoolAiWireErrorCode): string => { + switch (code) { + case "app_credit_daily_cap": + return "daily cap"; + case "out_of_app_credits": + return "out of credits"; + case "rate_limited": + case "payload_too_large": + case "missing_prompt": + case "invalid_json": + case "method_not_allowed": + case "not_found": + case "ai_unavailable": + case "ai_failed": + return "other"; + default: { + const exhaustive: never = code; + return exhaustive; + } + } + })(err.code); + expect(label).toBe("daily cap"); + }); + // The gateway sends `retryAfter: null` (not an absent key) for a credit code // whose period has no known end. test("an explicit null retryAfter is treated as no hint", async () => { diff --git a/src/client.ts b/src/client.ts index be32ae9..e751cbb 100644 --- a/src/client.ts +++ b/src/client.ts @@ -219,18 +219,27 @@ function readCode(body: unknown): BoolAiErrorCode { * runtime, an unknown AI sub-route, or the battery not being available to * this workspace all collapse to it. Don't render it as "your app is gone". * - `ai_unavailable` (503) — no model configured; the call cost nothing. - * - `ai_failed` (502) — the provider request failed; the credit is refunded. */ -export type BoolAiWireErrorCode = - | "out_of_app_credits" - | "app_credit_daily_cap" - | "rate_limited" - | "payload_too_large" - | "missing_prompt" - | "invalid_json" - | "method_not_allowed" - | "not_found" - | "ai_unavailable" - | "ai_failed"; + * - `ai_failed` (502) — the provider request failed; the credit is refunded. + * + * This array is the single source of the list — {@link BoolAiWireErrorCode} is + * derived from it, so the type and the runtime check can't drift apart when a + * code is added. */ +export const BOOL_AI_WIRE_ERROR_CODES = [ + "out_of_app_credits", + "app_credit_daily_cap", + "rate_limited", + "payload_too_large", + "missing_prompt", + "invalid_json", + "method_not_allowed", + "not_found", + "ai_unavailable", + "ai_failed", +] as const; + +/** Every machine-readable error the AI plane returns as JSON. See + * {@link BOOL_AI_WIRE_ERROR_CODES} for what each one means. */ +export type BoolAiWireErrorCode = (typeof BOOL_AI_WIRE_ERROR_CODES)[number]; /** `BoolAiError.code`: a wire code, `"unknown_error"` when the SDK couldn't read * one, or any other string. @@ -242,6 +251,31 @@ export type BoolAiWireErrorCode = * known code still autocompletes and is still checked when you spell it. */ export type BoolAiErrorCode = BoolAiWireErrorCode | "unknown_error" | (string & {}); +/** Narrow a code to the closed set of known wire codes. + * + * `BoolAiErrorCode` is open, which is what lets a newer gateway's code reach an + * older app without a type error — but it also means comparing against it never + * catches a typo, since any string is assignable. Narrow first and the compiler + * starts helping: inside the guard a `switch` over + * {@link BoolAiWireErrorCode} is checked for exhaustiveness (with a + * `never`-typed default), and a misspelled case is an error rather than an arm + * that silently never runs. + * + * ```ts + * if (isBoolAiWireErrorCode(err.code)) { + * switch (err.code) { + * case "app_credit_daily_cap": return waitUntil(err.retryAfter); + * // …every other code, or the compiler complains + * } + * } else { + * // unknown_error, or a code newer than this SDK — degrade generically + * } + * ``` + */ +export function isBoolAiWireErrorCode(code: string): code is BoolAiWireErrorCode { + return (BOOL_AI_WIRE_ERROR_CODES as readonly string[]).includes(code); +} + /** Thrown when a bool.ai call fails. `status` is the gateway HTTP status and * `code` its machine-readable error — see {@link BoolAiWireErrorCode}. * diff --git a/src/index.ts b/src/index.ts index 16faee8..2137687 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,8 @@ export { BoolAiError, type BoolAiErrorCode, type BoolAiWireErrorCode, + BOOL_AI_WIRE_ERROR_CODES, + isBoolAiWireErrorCode, type BoolUser, type BoolChangePayload, type AuthEvent, From 8bc4f5597e1236015b553bc1c737482501d386e0 Mon Sep 17 00:00:00 2001 From: Jonah Cohen Date: Wed, 5 Aug 2026 10:49:27 -0500 Subject: [PATCH 3/5] Give bool.ai.stream its own error path for a mid-stream failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stream that dies after the gateway has sent headers can never be status-mapped: the 200 is already committed, so the only thing left to break is the body. That surfaced as a rejected reader read escaping as whatever the runtime threw, outside BoolAiError entirely, so a catch written against the AI surface couldn't classify it. It now throws code "stream_interrupted" with the spent status and the original failure on cause. Chunks yielded before the break were real output and stay yielded. Groups it with unknown_error as BoolAiLocalErrorCode — codes raised where no response body exists to carry one. Both stay out of BOOL_AI_WIRE_ERROR_CODES, which is pinned against the gateway's codes. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 20 ++++++++++++++-- README.md | 7 ++++++ src/client.test.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++++ src/client.ts | 52 ++++++++++++++++++++++++++++++++++------ src/index.ts | 1 + 5 files changed, 130 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2796037..d326f70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,8 @@ wire shape isn't consistent. plane returns as JSON — `out_of_app_credits`, `app_credit_daily_cap`, `rate_limited`, `payload_too_large`, `missing_prompt`, `invalid_json`, `method_not_allowed`, `not_found`, `ai_unavailable`, `ai_failed` — each - documented with the status it arrives on. `BoolAiErrorCode` adds - `"unknown_error"` and stays an open union: the gateway ships independently of + documented with the status it arrives on. `BoolAiErrorCode` adds the SDK's own + `BoolAiLocalErrorCode`s and stays an open union: the gateway ships independently of this package, so an app on an older SDK can meet a code published after it, and that's a runtime case to handle rather than a compile error. @@ -44,6 +44,22 @@ wire shape isn't consistent. branch on `ai_failed` to drive a retry should keep doing exactly that — it still arrives on a genuine 502, and now only then. +- **`stream` has its own error path.** A stream can fail after it starts, and + that failure can never be status-mapped: the gateway commits to 200 when it + sends headers, so a provider that dies mid-generation can only break the body. + Previously the reader's rejection escaped as whatever the runtime threw — a + plain `TypeError`, outside `BoolAiError` entirely, so a `catch` written against + the AI surface couldn't classify it. It now throws + `code === "stream_interrupted"` with `status` 200 and the original failure on + `cause`. Chunks yielded before the break were real output and stay yielded, so + this means "the response stopped early", not "the response failed". + + `stream_interrupted` and `unknown_error` are grouped as + `BoolAiLocalErrorCode` — codes the SDK raises where no response body exists to + carry one. They're deliberately absent from `BOOL_AI_WIRE_ERROR_CODES`, which + is pinned against the gateway's own codes and would otherwise misreport what + the wire can produce. + - Renamed to match the gateway: the 402 code is `out_of_app_credits` (was `out_of_ai_credits`). The code names the credit pool rather than this plane, so every battery drawing on that pool reports the same string for the same diff --git a/README.md b/README.md index ffb8fe8..7269381 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,13 @@ tested, and upgradable independently of any one app. absent otherwise. The wire has two forms for it (an instant, or a delay in seconds); the SDK normalizes both so app code only handles one. + `stream` throws the same error type, but a stream can also fail *after* it + starts: the gateway has already sent its 200 by then, so a provider that dies + mid-generation can only break the body. That arrives as + `code === "stream_interrupted"` with `status` still `200` (and the underlying + failure on `cause`). Chunks yielded before the break were real output — treat + it as "the response stopped early", not "the response failed". + `BoolAiErrorCode` is an open union, so a code added to the gateway after your SDK was published still typechecks. To get an exhaustiveness-checked `switch`, narrow with `isBoolAiWireErrorCode()` first — `BOOL_AI_WIRE_ERROR_CODES` is diff --git a/src/client.test.ts b/src/client.test.ts index 9ce46fc..aca1133 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -629,6 +629,65 @@ describe("bool.ai battery", () => { expect((err as BoolAiError).code).toBe("rate_limited"); }); + // A mid-stream provider failure can't be status-mapped: the gateway already + // sent 200 and only the body can break afterwards. + function brokenStreamAfter(chunks: string[], cause: Error): Response { + const encoder = new TextEncoder(); + let sent = 0; + return new Response( + new ReadableStream({ + // Enqueue one chunk per read, then error. Erroring a stream discards + // anything still queued, so the chunks have to be handed over one at a + // time for this to model a reader that saw output and then broke. + pull(controller) { + if (sent < chunks.length) controller.enqueue(encoder.encode(chunks[sent++]!)); + else controller.error(cause); + }, + }), + { status: 200 }, + ); + } + + test("a stream that breaks mid-body throws stream_interrupted, not the raw read error", async () => { + respond = () => brokenStreamAfter(["Once upon "], new Error("provider hung up")); + const client = createBoolClient(CONFIG); + const seen: string[] = []; + let err: unknown; + try { + for await (const chunk of client.ai.stream("a story")) seen.push(chunk); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(BoolAiError); + expect((err as BoolAiError).code).toBe("stream_interrupted"); + // The status is the one the gateway committed to before it broke — which is + // exactly why callers must branch on `code` here and not on `status`. + expect((err as BoolAiError).status).toBe(200); + expect((err as BoolAiError).retryAfter).toBeUndefined(); + // Chunks delivered before the break were real output and stay delivered. + expect(seen).toEqual(["Once upon "]); + }); + + test("stream_interrupted keeps the underlying failure as `cause`", async () => { + const underlying = new Error("provider hung up"); + respond = () => brokenStreamAfter([], underlying); + const client = createBoolClient(CONFIG); + const err = (await (async () => { + try { + for await (const _ of client.ai.stream("go")) void _; + } catch (e) { + return e; + } + })()) as BoolAiError; + expect(err.cause).toBe(underlying); + }); + + test("stream_interrupted is not a wire code", () => { + // It's raised locally, so the array pinned against the gateway must not + // claim the gateway can send it. + expect(isBoolAiWireErrorCode("stream_interrupted")).toBe(false); + }); + test("replays the preview viewer token as x-bool-viewer", async () => { respond = () => new Response(JSON.stringify({ text: "ok" }), { diff --git a/src/client.ts b/src/client.ts index e751cbb..0b632d6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -241,15 +241,30 @@ export const BOOL_AI_WIRE_ERROR_CODES = [ * {@link BOOL_AI_WIRE_ERROR_CODES} for what each one means. */ export type BoolAiWireErrorCode = (typeof BOOL_AI_WIRE_ERROR_CODES)[number]; -/** `BoolAiError.code`: a wire code, `"unknown_error"` when the SDK couldn't read - * one, or any other string. +/** Codes this SDK raises itself. They never appear in a response body, because + * each names a failure that happens where no body is available to carry one: + * + * - `unknown_error` — the response had no readable JSON code (a plain-text + * preamble answer, or a body that wasn't JSON at all). + * - `stream_interrupted` — the stream began (headers said 200) and then the + * connection or the provider broke partway through. Chunks yielded before + * that point were real and stay yielded; there is no status to map, because + * the status was already sent and it was a success. + * + * Kept out of {@link BOOL_AI_WIRE_ERROR_CODES} deliberately: that array is + * pinned against the gateway's own codes, and mixing locally-raised ones in + * would make it lie about what the wire can produce. */ +export type BoolAiLocalErrorCode = "unknown_error" | "stream_interrupted"; + +/** `BoolAiError.code`: a wire code, one of the SDK's own + * {@link BoolAiLocalErrorCode}s, or any other string. * * The trailing `string` keeps this union OPEN on purpose. The gateway ships * independently of this package, so an app pinned to an older SDK can receive a * code added after it was published; a closed union would make that a type * error at the `default` arm instead of the runtime case it actually is. Every * known code still autocompletes and is still checked when you spell it. */ -export type BoolAiErrorCode = BoolAiWireErrorCode | "unknown_error" | (string & {}); +export type BoolAiErrorCode = BoolAiWireErrorCode | BoolAiLocalErrorCode | (string & {}); /** Narrow a code to the closed set of known wire codes. * @@ -284,7 +299,11 @@ export function isBoolAiWireErrorCode(code: string): code is BoolAiWireErrorCode * lifts at a known time; `rate_limited` is short-term pacing). Two statuses can * also carry the same code — `not_found` arrives as JSON here, but the shared * plane preamble answers some 404s and 403s in plain text, which the SDK cannot - * read a code out of at all (those surface as `unknown_error`). */ + * read a code out of at all (those surface as `unknown_error`). + * + * `status` can even be a success: a stream that breaks partway through throws + * `stream_interrupted` carrying the 200 the gateway already sent. See + * {@link BoolAiLocalErrorCode}. */ export class BoolAiError extends Error { readonly status: number; readonly code: BoolAiErrorCode; @@ -292,8 +311,13 @@ export class BoolAiError extends Error { * absent on codes that carry no retry hint. Always an absolute Date, whether * the gateway expressed it as an instant or as a relative delay. */ readonly retryAfter?: Date; - constructor(code: BoolAiErrorCode, status: number, retryAfter?: Date) { - super(`bool.ai failed: ${code} (${status})`); + constructor( + code: BoolAiErrorCode, + status: number, + retryAfter?: Date, + options?: { cause?: unknown }, + ) { + super(`bool.ai failed: ${code} (${status})`, options); this.name = "BoolAiError"; this.code = code; this.status = status; @@ -759,11 +783,25 @@ export function createBoolClient(config: BoolClientConfig): BoolClient { } // The gateway streams raw text deltas (text/plain). Decode and yield each // chunk as it arrives. + // + // Past this point the status is spent: the gateway committed to 200 the + // moment it sent headers, so a provider that dies mid-generation can only + // break the body. That surfaces here as a rejected read, with no body and + // no status to map — hence a locally-raised `stream_interrupted` rather + // than the status-keyed path above. Everything already yielded was real + // output; a caller that has been appending chunks to a UI should treat + // this as "the response stopped early", not "the response failed". const reader = res.body.getReader(); const decoder = new TextDecoder(); try { while (true) { - const { done, value } = await reader.read(); + let done: boolean; + let value: Uint8Array | undefined; + try { + ({ done, value } = await reader.read()); + } catch (cause) { + throw new BoolAiError("stream_interrupted", res.status, undefined, { cause }); + } if (done) break; const chunk = decoder.decode(value, { stream: true }); if (chunk) yield chunk; diff --git a/src/index.ts b/src/index.ts index 2137687..602a59b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ export { BoolAiError, type BoolAiErrorCode, type BoolAiWireErrorCode, + type BoolAiLocalErrorCode, BOOL_AI_WIRE_ERROR_CODES, isBoolAiWireErrorCode, type BoolUser, From 2e3e2c0ef7957428532ac130a7ae8372d84d660f Mon Sep 17 00:00:00 2001 From: Jonah Cohen Date: Wed, 5 Aug 2026 11:24:48 -0500 Subject: [PATCH 4/5] =?UTF-8?q?Release=20as=200.5.0=20=E2=80=94=200.4.0=20?= =?UTF-8?q?is=20taken=20by=20the=20fetch=20battery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d326f70..7ad9e97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 0.4.0 +## 0.5.0 Types every error the AI plane can return, and normalizes the one field whose wire shape isn't consistent. diff --git a/package.json b/package.json index d0d1f8b..4862148 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.4.0", + "version": "0.5.0", "description": "Client SDK for apps built on Bool \u2014 gateway data access, end-user auth, the AI battery, the React auth layer, and the local-dev CLI (link, types, deploy).", "type": "module", "main": "./dist/index.js", From c6461ad37432edbd9d1b0e7aa6b8c807dab675be Mon Sep 17 00:00:00 2001 From: Jonah Cohen Date: Thu, 6 Aug 2026 13:48:18 -0500 Subject: [PATCH 5/5] Restore the blank line between CHANGELOG sections Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7181cb7..0ec77d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ wire shape isn't consistent. `bool.ai` remains gated server-side by the `bool-ai` feature flag, off by default, so these codes only reach apps in workspaces opted into the battery. + ## 0.4.1 _Published as 0.4.1 — 0.4.0 was published in error with unrelated content and unpublished; the fetch battery below is otherwise unchanged._