Skip to content

Commit 7ab0ed1

Browse files
committed
docs(ai-chat): comprehensive error handling guide
1 parent 67ba3a8 commit 7ab0ed1

2 files changed

Lines changed: 374 additions & 0 deletions

File tree

docs/ai-chat/error-handling.mdx

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
---
2+
title: "Error handling"
3+
sidebarTitle: "Error handling"
4+
description: "How errors flow through chat.agent — stream errors, hook errors, run failures — and how to recover."
5+
---
6+
7+
`chat.agent` errors fall into four layers, each with different recovery semantics. The default behavior is **conversation-preserving**: a thrown error in a hook or `run()` does not kill the chat. The current turn ends with an error chunk, and the agent waits for the user's next message.
8+
9+
## Error layers at a glance
10+
11+
| Layer | Source | Default behavior | Recovery |
12+
|-------|--------|------------------|----------|
13+
| **Stream** | `streamText` errors mid-response (rate limits, model API failures) | `onError` callback converts to error chunk | Sanitize message via `uiMessageStreamOptions.onError` |
14+
| **Hook / turn** | Throws in `onValidateMessages`, `onTurnStart`, `run`, etc. | Error chunk + turn-complete written to stream; conversation continues | Catch in your hook, or rely on default |
15+
| **Run** | Unhandled exception escapes the run | Run fails. No retry by default. Standard task `onFailure` fires. | `onFailure` task hook |
16+
| **Frontend** | Stream delivers `{ type: "error", errorText }` | `useChat` exposes via `error` field and `onError` callback | Show toast, retry button, etc. |
17+
18+
## Stream errors mid-turn
19+
20+
When the model API errors mid-response (rate limits, network failures, malformed output), the AI SDK's `streamText` calls the `onError` callback. Use `uiMessageStreamOptions.onError` to convert the error to a user-friendly string. The string is sent to the frontend as an error chunk.
21+
22+
```ts
23+
import { chat } from "@trigger.dev/sdk/ai";
24+
25+
export const myChat = chat.agent({
26+
id: "my-chat",
27+
uiMessageStreamOptions: {
28+
onError: (error) => {
29+
console.error("Stream error:", error);
30+
if (error instanceof Error && error.message.includes("rate limit")) {
31+
return "Rate limited. Please wait a moment and try again.";
32+
}
33+
if (error instanceof Error && error.message.includes("context_length")) {
34+
return "This conversation is too long. Please start a new chat.";
35+
}
36+
return "Something went wrong while generating a response. Please try again.";
37+
},
38+
},
39+
run: async ({ messages, signal }) => {
40+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
41+
},
42+
});
43+
```
44+
45+
<Note>
46+
Returning a string from `onError` is what gets shown to the user. Do not return raw error messages — they may leak internal details (API keys, stack traces, etc.).
47+
</Note>
48+
49+
The frontend receives this as an error chunk that `useChat` exposes via its `error` field:
50+
51+
```tsx
52+
const { messages, error } = useChat({ transport });
53+
54+
{error && <div className="text-red-600">{error.message}</div>}
55+
```
56+
57+
## Hook and turn errors
58+
59+
If any lifecycle hook (`onValidateMessages`, `onChatStart`, `onTurnStart`, `hydrateMessages`, `onAction`, `prepareMessages`, `onBeforeTurnComplete`, `onTurnComplete`) or `run()` throws an unhandled exception, the turn loop catches it:
60+
61+
1. Writes `{ type: "error", errorText: error.message }` to the stream
62+
2. Writes a turn-complete chunk to close the turn
63+
3. Waits for the next user message
64+
65+
The conversation stays alive. The user can send another message and continue.
66+
67+
```ts
68+
export const myChat = chat.agent({
69+
id: "my-chat",
70+
onTurnStart: async ({ chatId, uiMessages }) => {
71+
// If this throws, the turn ends with an error chunk
72+
// and the agent waits for the next message
73+
await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } });
74+
},
75+
run: async ({ messages, signal }) => {
76+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
77+
},
78+
});
79+
```
80+
81+
### Catching errors in your own hooks
82+
83+
For granular control, wrap your hook code in try/catch and decide what to do. Common patterns:
84+
85+
```ts
86+
onValidateMessages: async ({ messages }) => {
87+
try {
88+
return await validateUIMessages({ messages, tools: chatTools });
89+
} catch (err) {
90+
// Log to your error tracking service
91+
Sentry.captureException(err);
92+
// Throw a user-facing error message — this becomes the error chunk
93+
throw new Error("Your message contains invalid data and could not be sent.");
94+
}
95+
},
96+
```
97+
98+
<Tip>
99+
The `Error.message` you throw is sent verbatim to the frontend as the error chunk's `errorText`. Use messages safe for end users.
100+
</Tip>
101+
102+
### Catching errors inside `run()`
103+
104+
`run()` is your code — wrap it in try/catch for full control. This is the right place to save partial state to your DB before the error chunk goes out:
105+
106+
```ts
107+
run: async ({ messages, chatId, signal }) => {
108+
try {
109+
return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal });
110+
} catch (err) {
111+
// Save the failed turn for debugging / undo
112+
await db.failedTurn.create({
113+
data: {
114+
chatId,
115+
error: err instanceof Error ? err.message : String(err),
116+
messages,
117+
},
118+
});
119+
throw err; // Re-throw to trigger the error chunk
120+
}
121+
},
122+
```
123+
124+
## Saving error state to your DB
125+
126+
To persist errors for debugging or undo, use `onTurnComplete` (which fires even after errors) or the standard task `onComplete` hook.
127+
128+
### Using `onTurnComplete`
129+
130+
`onTurnComplete` fires after every turn — successful **or** errored. The `responseMessage` will be undefined or partial on errors. Use this to mark the turn as failed:
131+
132+
```ts
133+
onTurnComplete: async ({ chatId, uiMessages, responseMessage, stopped }) => {
134+
// Persist the messages regardless of error state
135+
await db.chat.update({
136+
where: { id: chatId },
137+
data: {
138+
messages: uiMessages,
139+
// Mark the chat as errored if no response message
140+
lastTurnStatus: responseMessage ? "ok" : stopped ? "stopped" : "errored",
141+
},
142+
});
143+
},
144+
```
145+
146+
### Using the standard `onFailure` task hook
147+
148+
For run-level failures (the entire run dies), use the standard task `onFailure` hook. This fires when the run terminates with an unhandled exception:
149+
150+
```ts
151+
chat.agent({
152+
id: "my-chat",
153+
onFailure: async ({ error, ctx }) => {
154+
// Log run-level failure to your monitoring service
155+
await monitoring.recordRunFailure({
156+
runId: ctx.run.id,
157+
chatId: ctx.run.tags.find(t => t.startsWith("chat:"))?.slice(5),
158+
error: error.message,
159+
});
160+
},
161+
run: async ({ messages, signal }) => {
162+
return streamText({ ... });
163+
},
164+
});
165+
```
166+
167+
<Info>
168+
`chat.agent` uses `retry: { maxAttempts: 1 }` internally, so the run never retries on failure. To add run-level retries, wrap the agent in a parent task or implement your own retry logic in the frontend (re-send the message).
169+
</Info>
170+
171+
## Recovery patterns
172+
173+
### Pattern 1: Undo to last successful response
174+
175+
A common pattern is to let the user "undo" the failed turn and try again. Combine `chat.history.rollbackTo` with a custom action:
176+
177+
```ts
178+
chat.agent({
179+
id: "my-chat",
180+
actionSchema: z.discriminatedUnion("type", [
181+
z.object({ type: z.literal("undo") }),
182+
]),
183+
onAction: async ({ action, uiMessages }) => {
184+
if (action.type === "undo") {
185+
// Find the last user message and roll back to it
186+
const lastUserIdx = [...uiMessages].reverse().findIndex(m => m.role === "user");
187+
if (lastUserIdx !== -1) {
188+
const targetIdx = uiMessages.length - 1 - lastUserIdx - 1;
189+
const target = uiMessages[targetIdx];
190+
if (target) chat.history.rollbackTo(target.id);
191+
}
192+
}
193+
},
194+
run: async ({ messages, signal }) => {
195+
return streamText({ ... });
196+
},
197+
});
198+
```
199+
200+
On the frontend, show an "Undo" button when an error occurs:
201+
202+
```tsx
203+
{error && (
204+
<button onClick={() => transport.sendAction(chatId, { type: "undo" })}>
205+
Undo and try again
206+
</button>
207+
)}
208+
```
209+
210+
### Pattern 2: Retry the last message
211+
212+
For transient errors (network blips, rate limits), the simplest recovery is to re-send the last user message. The AI SDK's `useChat` provides `regenerate()`:
213+
214+
```tsx
215+
const { messages, error, regenerate } = useChat({ transport });
216+
217+
{error && (
218+
<button onClick={() => regenerate()}>Retry</button>
219+
)}
220+
```
221+
222+
`regenerate()` removes the last assistant response and re-sends. Combined with `onValidateMessages` or `hydrateMessages`, you can reload the canonical state from your DB before retrying.
223+
224+
### Pattern 3: Save partial responses
225+
226+
When a stream errors mid-response, the `responseMessage` in `onBeforeTurnComplete` and `onTurnComplete` contains the partial output. Save it as a "draft" so the user can see what was generated before the error:
227+
228+
```ts
229+
onBeforeTurnComplete: async ({ chatId, responseMessage, stopped }) => {
230+
if (responseMessage && responseMessage.parts.length > 0) {
231+
// Save partial response — user can manually accept or discard
232+
await db.partialResponse.create({
233+
data: {
234+
chatId,
235+
message: responseMessage,
236+
reason: stopped ? "stopped" : "errored",
237+
},
238+
});
239+
}
240+
},
241+
```
242+
243+
### Pattern 4: Fall back to a different model
244+
245+
If the primary model errors, try a fallback model in the same turn:
246+
247+
```ts
248+
run: async ({ messages, signal }) => {
249+
try {
250+
return streamText({
251+
model: openai("gpt-4o"),
252+
messages,
253+
abortSignal: signal,
254+
});
255+
} catch (err) {
256+
console.warn("Primary model failed, falling back:", err);
257+
return streamText({
258+
model: anthropic("claude-sonnet-4-6"),
259+
messages,
260+
abortSignal: signal,
261+
});
262+
}
263+
},
264+
```
265+
266+
<Note>
267+
This only catches errors thrown synchronously by `streamText` setup. Errors that happen mid-stream go through `uiMessageStreamOptions.onError`, not your try/catch.
268+
</Note>
269+
270+
## What gets written to the stream on error
271+
272+
When an error occurs at any layer, the frontend receives an error chunk in the SSE stream:
273+
274+
```
275+
event: data
276+
data: {"type":"error","errorText":"Rate limited. Please wait a moment and try again."}
277+
278+
event: data
279+
data: {"type":"trigger:turn-complete",...}
280+
```
281+
282+
The AI SDK's `useChat` processes this and:
283+
284+
1. Sets `useChat`'s `error` field to an `Error` with `message = errorText`
285+
2. Calls the user's `onError` callback (if set)
286+
3. Marks the turn as complete (`status` returns to `"ready"`)
287+
288+
```tsx
289+
const { messages, error, status } = useChat({
290+
transport,
291+
onError: (err) => {
292+
toast.error(err.message);
293+
},
294+
});
295+
```
296+
297+
## Frontend error handling
298+
299+
### Showing the error to the user
300+
301+
```tsx
302+
function Chat() {
303+
const transport = useTriggerChatTransport({ task: "my-chat", accessToken });
304+
const { messages, error, sendMessage } = useChat({ transport });
305+
306+
return (
307+
<div>
308+
{messages.map(m => /* ... */)}
309+
{error && (
310+
<div className="rounded border border-red-300 bg-red-50 p-3">
311+
<p className="text-red-700">{error.message}</p>
312+
</div>
313+
)}
314+
<form onSubmit={(e) => { e.preventDefault(); sendMessage(/* ... */); }}>
315+
{/* ... */}
316+
</form>
317+
</div>
318+
);
319+
}
320+
```
321+
322+
### Distinguishing error types
323+
324+
The `errorText` is just a string, so distinguish error types via prefixes or codes:
325+
326+
```ts
327+
// Backend
328+
uiMessageStreamOptions: {
329+
onError: (error) => {
330+
if (error.message.includes("rate limit")) return "RATE_LIMIT: Please wait and try again.";
331+
if (error.message.includes("context_length")) return "CONTEXT_TOO_LONG: Start a new chat.";
332+
return "UNKNOWN: Something went wrong.";
333+
},
334+
},
335+
```
336+
337+
```tsx
338+
// Frontend
339+
{error?.message.startsWith("RATE_LIMIT") && <RateLimitNotice />}
340+
{error?.message.startsWith("CONTEXT_TOO_LONG") && <NewChatPrompt />}
341+
```
342+
343+
<Tip>
344+
For richer error structures, use [`chat.response.write()`](/ai-chat/features#custom-data-parts) with a custom `data-error` part type. This lets you ship structured error metadata (codes, retry hints, etc.) instead of stringly-typed messages.
345+
</Tip>
346+
347+
## Run-level retries
348+
349+
`chat.agent` uses `retry: { maxAttempts: 1 }` — the run **never retries** on unhandled failure. This is intentional: each turn is conversation-preserving, so a true run failure is severe and shouldn't silently retry (which could send duplicate API calls or mutate state twice).
350+
351+
To add retry-like behavior:
352+
353+
- **Per-turn retries**: handle inside `run()` with try/catch and a fallback model
354+
- **Per-message retries**: re-send from the frontend (call `sendMessage` or `regenerate` again)
355+
- **Whole-run retries**: wrap `chat.agent` with a parent task that has `retry` configured, and call the agent's task internally
356+
357+
## Best practices
358+
359+
1. **Always set `uiMessageStreamOptions.onError`** to sanitize stream errors before they reach the user.
360+
2. **Persist messages in `onTurnStart`** so a mid-stream failure still leaves the user's message visible.
361+
3. **Use `onTurnComplete` to mark turn status** in your DB (`ok` / `errored` / `stopped`).
362+
4. **Don't throw raw errors with internal details** in hooks — catch, log, then throw a sanitized user-facing message.
363+
5. **Provide an undo or retry affordance** in the UI when errors occur.
364+
6. **Use `onFailure` for run-level monitoring** (Sentry, monitoring dashboards).
365+
7. **For known transient errors (rate limits, network)**, consider a fallback model inside `run()` instead of failing the turn.
366+
367+
## See also
368+
369+
- [`uiMessageStreamOptions.onError`](/ai-chat/backend#error-handling-with-onerror) — stream error handler details
370+
- [Custom actions](/ai-chat/backend#actions) — implement undo/retry actions
371+
- [`chat.history`](/ai-chat/backend#chat-history) — rollback to a previous message
372+
- [Database persistence](/ai-chat/patterns/database-persistence) — saving conversation state
373+
- [Standard task hooks](/tasks/overview)`onFailure`, `onComplete`, `onWait`, etc.

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@
9898
"ai-chat/compaction",
9999
"ai-chat/pending-messages",
100100
"ai-chat/background-injection",
101+
"ai-chat/error-handling",
101102
"ai-chat/mcp",
102103
"ai-chat/testing",
103104
{

0 commit comments

Comments
 (0)