Skip to content

Type the AI plane's error codes; normalize retryAfter to a Date - #29

Merged
jonahc44 merged 6 commits into
mainfrom
claude/sdk-error-codes-0fdd47
Aug 6, 2026
Merged

jonahc44 merged 6 commits into
mainfrom
claude/sdk-error-codes-0fdd47

Conversation

@jonahc44

@jonahc44 jonahc44 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

bool.ai threw a BoolAiError whose code was a bare string, so nothing in
the SDK described what an app could actually receive. Ten codes come back as
JSON from the plane; none were typed. And one of the two things an app most wants
to do with a failure — wait the right amount of time, then retry — had no
supported shape at all.

What's in here

All ten codes are typed. BoolAiWireErrorCode lists them with the status
each arrives on and a line on what it means: 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.
BoolAiErrorCode adds "unknown_error" and stays an open union — the
gateway ships independently of this package, so an app pinned to an older SDK can
meet a code published after it, and that's a runtime case to handle rather than a
compile error at the default arm. Known codes still autocomplete and are still
checked when spelled.

Branch on code, never on status. 429 is now two 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 still has credits).
Those want different copy and different retry behavior. The SDK already read
body.error at both throw sites rather than mapping from the status, so what's
new is the type and the tests that pin the distinction.

New BoolAiError.retryAfter, a Date. 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.

One behavior change worth reading

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 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. A genuine 502 still arrives as ai_failed, and now only then.

Apps branching on ai_failed to drive a retry should keep doing exactly that.

Verification

Read against origin/main in the platform repo rather than trusting the
description of the wire, which was worth doing — it corrected two things:

  • retryAfter can be an explicit null, not just absent (a credit code whose
    period has no known end). Now covered by a test rather than working by
    accident.
  • The plane returns ten JSON codes, not thirteen. Every error: "..." in
    lib/gateway/ai-route.ts is accounted for above.

Both ISO producers use .toISOString(), so the instant form is always UTC — the
digits-only-string guard stays as a guard, not a live path.

bun test — 181 pass, 0 fail. bun run typecheck and bun run build clean;
the new types reach dist/.

Scope

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.

Two related items are deliberately not here, because they live in the
platform repo:

  • The text/plain 403/404 in the shared plane preamble. Fixing it at the source
    touches the GA entities and users planes, so it belongs in its own change with
    its own tests rather than riding along behind a flag-gated one.
  • The starter-template version floor that gates the current codes, the way
    LIVE_SDK_CANARY_SINCE gates live spellings. Per the NOTE FOR THE SDK in
    ai-route.ts, that floor is the real gate before the battery flag can go on
    for real users, and it needs a published SDK version to point at — this one.

Also noted while reading: the comment above the app_credit_daily_cap branch in
ai-route.ts says retryAfter is an ISO timestamp on every code in the plane.
That's true of the two credit codes but not of rate_limited, ~80 lines above in
the same file. Worth a one-line fix upstream; the normalization here handles it
either way.

🤖 Generated with Claude Code

jonahc44 and others added 2 commits August 5, 2026 10:29
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@jonahc44

jonahc44 commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Added the runtime list and the guard, per review.

BOOL_AI_WIRE_ERROR_CODES is now the single source the union derives from ((typeof BOOL_AI_WIRE_ERROR_CODES)[number]) rather than a second list beside a hand-written union — two lists would be two things to update, and the drift would stay invisible until an app hit a code that was in one but not the other.

isBoolAiWireErrorCode() narrows the open union to the closed set, which is what actually buys typo-catching: inside the guard a switch is exhaustiveness-checked, so a missing or misspelled case is a compile error instead of an arm that silently never runs.

Two tests worth calling out:

  • The exhaustiveness check is verified to be load-bearing, not decorative — removing a single case fails tsc with Type '"ai_failed"' is not assignable to type 'never'. I checked that by actually deleting a case and confirming the failure, then restoring it.
  • The list is pinned against ai-route.ts, so adding a code to the gateway fails here and points at the array to update.

184 pass, 0 fail; typecheck and build clean, both symbols reach dist/.

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 <noreply@anthropic.com>
jonahc44 and others added 3 commits August 5, 2026 11:24
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Keeps 0.5.0 as the version — main released 0.4.1 (the fetch battery), and
this branch's minor bump still lands above it. The three-way conflicts were
all additive collisions: the CHANGELOG gains both sections, and the client
export lists gain both the AI error-code surface and the fetch battery's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@jonahc44
jonahc44 merged commit 8223b32 into main Aug 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant