Errors.
One base class, stable codes, and a clear next move for each.
One base class#
Everything JevChain throws or reports extends JevChainError, so one instanceof catches the lot. Each carries a stable string code that's safe to switch on, log and serialize. The subclass tells you what to do next.
import { JevChainError } from "jevchain";
try {
await jev.ask(state, questions); // the raw client does throw
} catch (e) {
if (e instanceof JevChainError) console.log(e.code, e.message);
}Remember that jev.run doesn't throw for runtime failures: it hands the error back on the result (see Statuses). The low-level jev.ask does throw.
Every error#
| class | code | retried | what it means / what to do |
|---|---|---|---|
| JevAuthError | auth_error | retried: no | 401/403. The key is missing, wrong or not allowed. Fix the key; retrying won't. |
| JevValidationError | validation_error | retried: no | 400/422. TypeSafe couldn't process the request, usually a malformed question. The message carries their detail. |
| JevRateLimitError | rate_limited | retried: yes | 429. retryAfterMs is set when the server said how long; the client waits that long (up to maxRetryAfterMs). |
| JevServerError | server_error · overloaded | retried: yes* | 5xx, or 529 (overloaded). *Retried for 500, 502, 503, 504 and 529. Back off; it's them, not you. |
| JevAPIError | api_error | retried: 408, 409 | Any other non-2xx. The base class of the four above; all of them have status and body. |
| JevTimeoutError | timeout | retried: per attempt | An attempt took longer than timeoutMs, a step exceeded its own timeoutMs, or the run hit its deadline. Only per-attempt API timeouts are retried by the client (steps use their own retries). Has timeoutMs. |
| JevConnectionError | connection_error | retried: yes | The network failed before any response. The original error is its cause. |
| JevAbortError | aborted | retried: no | Your AbortSignal fired. The run's status is aborted. Nothing to fix. |
| JevResponseError | bad_response | retried: no | A 200 whose body wasn't right: no answers, a missing answer, or an answer of the wrong type. Has body. |
| ChainConfigError | chain_config | retried: — | The chain itself is invalid. Has issues: string[]. Thrown, not returned. |
| NodeError | cause's code | retried: — | Wraps whatever failed inside a node. Has nodeId and cause; its code is the cause's code, or node_error for plain exceptions from your code. |
A few more codes appear on bare JevChainErrors: no_questions (an ask with nothing to ask), no_fetch (no global fetch; pass one), no_branch (a route answered a label it has no branch for, only possible with unchecked JSON) and unknown.
retries: 3. Run it a few times in the studio: most runs succeed after a retry or two, each one recorded in the span's retries. Now and then all four attempts fail, and the run ends error with a node_error from consult-oracle.const consult = step(
"consult-oracle",
(question: string, ctx) => {
// Flaky on purpose: fails about half the time. `retries` absorbs it,
// and every retry is recorded in the trace.
if (Math.random() < 0.5) throw new Error("the oracle is napping");
ctx.log("the oracle stirs");
return `You asked "${question}". The oracle says: it is certain, eventually.`;
},
{ retries: 3, timeoutMs: _000 },
);
const oracle = chain(
"flaky-oracle",
consult,
gate("good-news", {
ask: noul("Is this prophecy good news?"),
pass: { min: 0.5 },
then: emit("🎉 {{input}}"),
otherwise: emit("🌧 {{input}}"),
}),
);Will the release ship on Friday?
NodeError and ChainConfigError#
These two are about your chain rather than the API. NodeError points at the culprit: when anything fails while a node runs, it's wrapped once, at the innermost node, so nodeId names where it actually broke rather than the chain around it. Because it copies its cause's code, a switch on error.code sees rate_limited, not a generic wrapper.
In a trace, the failure is recorded twice: trace.error is the NodeError and the failing span's error is the original cause, both flattened by serializeError into { name, code, message, status?, nodeId? }:
// trace.error: the NodeError, flattened
{ "name": "NodeError", "code": "rate_limited", "nodeId": "front-desk",
"message": "Node \"front-desk\" failed: Rate limited by TypeSafe (retry after 2000ms)" }
// the failing span's error: the original cause
{ "name": "JevRateLimitError", "code": "rate_limited", "status": 429,
"message": "Rate limited by TypeSafe (retry after 2000ms)" }ChainConfigError is thrown by run/stream before anything executes, and by fromJSON, when the structure is wrong in ways the type system couldn't see (usually because the chain came from JSON). It lists every problem at once:
try {
await jev.run(fromJSON(doc, { handlers }), input);
} catch (e) {
if (e instanceof ChainConfigError) console.error(e.issues);
// [ '$ (route "fridge-verdict"): no branch for "compost"' ]
}Handling them#
Switch on the code for the cases you can do something about, and let the rest surface. The trace is on the result either way, so log it before you decide.
import { JevRateLimitError, NodeError } from "jevchain";
const result = await jev.run(desk, ticket);
if (result.status === "error" || result.status === "aborted") {
const { error } = result;
switch (error.code) { // NodeError copies its cause's code
case "auth_error":
return askUserForANewKey();
case "rate_limited": {
const cause = error.cause;
const wait = cause instanceof JevRateLimitError ? cause.retryAfterMs : undefined;
return retryLater(wait ?? _000);
}
case "timeout":
case "overloaded":
return fallBackToAHuman();
default:
if (error instanceof NodeError) log(`${error.nodeId} broke`, error.cause);
throw error;
}
}