diff --git a/CHANGELOG.md b/CHANGELOG.md index b01324e..0ec77d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## 0.5.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 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. + + 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 + 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. + +- **`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 + 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.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._ diff --git a/README.md b/README.md index 24fadfb..18e110e 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,39 @@ 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. + + `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 + 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. - **Fetch battery.** `client.fetch` calls a third-party API using a key the app's owner stored with Bool, **without the key entering the bundle**. Write diff --git a/package.json b/package.json index 8f81cab..4862148 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bool-sdk", - "version": "0.4.1", + "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", diff --git a/src/client.test.ts b/src/client.test.ts index fbc7195..9bde32b 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, BoolFetchError, type BoolClientConfig, } from "./client"; @@ -371,7 +374,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" }, }); @@ -379,7 +382,229 @@ 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(); + }); + + 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 () => { + 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 () => { @@ -405,6 +630,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 8802751..511568a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -150,17 +150,178 @@ 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. + * + * 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]; + +/** 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 | BoolAiLocalErrorCode | (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 (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`). + * + * `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: string; - constructor(code: string, status: number) { - super(`bool.ai failed: ${code} (${status})`); + 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, + options?: { cause?: unknown }, + ) { + super(`bool.ai failed: ${code} (${status})`, options); this.name = "BoolAiError"; this.code = code; this.status = status; + this.retryAfter = retryAfter; } } @@ -658,7 +819,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"], @@ -675,15 +841,33 @@ 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. + // + // 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 9c3d582..2f44ff3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,11 @@ export { type BoolAi, type BoolAiSchema, BoolAiError, + type BoolAiErrorCode, + type BoolAiWireErrorCode, + type BoolAiLocalErrorCode, + BOOL_AI_WIRE_ERROR_CODES, + isBoolAiWireErrorCode, type BoolFetch, type BoolFetchInit, BoolFetchError,