Command Palette

Search for a command to run...

Hectal
PHASE 5Intermediate ~27 min· topic 6 of 9

Topic 5.6

Errors, Retries and Abort in the SDK

In one line

The SDK retries some failures for you (maxRetries, default 2), reports a final failure as an APICallError or a RetryError, and sends errors that happen while streaming as masked error chunks. You decide what the browser may read, turn status codes into plain sentences, and keep partial answers when a stream is stopped or breaks.

Think of it like this

A courier who finds nobody home. A good courier tries again a couple of times (retries). If it still fails, they leave a card that says what happened in plain words, 'We called twice, nobody answered, here is how to rebook', and they do not read out the customer's account details at the door (masking). If you cancel the delivery halfway, they keep the parcel they already unpacked (a partial answer). The SDK does the retries and the card; you write the words on it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Retryable error
A failure that might succeed if tried again soon: statuses 408, 409, 429 and 5xx. The SDK sets isRetryable on them.
Backoff
Waiting longer before each retry, so a struggling service is not hit again at once.
RetryError
The SDK error (AI_RetryError) thrown when every retry failed. It holds errors and lastError.
Error chunk
A stream event {"type":"error","errorText":"…"} that reports a failure inside a 200 response.
Masking
Replacing a real error message with a safe one before it reaches the browser.
Reference id
A short id such as req-12 shown to the user and logged next to the real error, so support can find it.
friendly(error)
Your one function that turns any client error into a sentence for a person.
Partial message
An assistant message that holds only the text that arrived before a stop or a failure.

Step by step

01Retries and what survives them

The first example counts how many times the model was really called. A call that fails with a 429 and then a 503 succeeds on the third attempt with maxRetries: 2. A call that keeps failing with a 429 ends in a RetryError after maxRetries + 1 attempts. A 401 is not retried at all.

The stand-in sets retry-after: 0 so the example does not wait; a real provider's header makes the SDK wait that long, and the user waits with it.

Retries and what survives themdiagram
Rendering diagram…

02Throwing and not throwing

The second example runs the same failing model through generateText (it throws) and streamText (it does not). It prints what onError received, what the text stream held, what awaiting result.text did, and the parts of fullStream.

03Say less on purpose

The third example is a route with a custom onError that writes a short sentence plus a reference id and logs the real error, and a browser function friendly(error). It runs five failures: one inside the stream, a stream that breaks, your own 429 with the wait in the body, a 401, and an unreachable URL. The last column shows the exact sentence each would put on screen.

04Stopped and broken answers

The fourth example stops an answer after its third word (the script waits for the third word to arrive, not for a time), shows what stayed in messages, then runs a stream that breaks and shows that the partial text stays too. regenerate() then replaces the broken answer with a complete one.

Try it yourself

  1. 1

    Retry once more than the failures

    In the first example change the first call's maxRetries: 2 to maxRetries: 1.

    The two failures need two retries, so the call now throws AI_RetryError: Failed after 2 attempts. Last error: …status 503 instead of recovering, and the example stops there with an uncaught error. Count your provider's typical hiccups before choosing the number.

  2. 2

    Return the raw message

    In the third example change the server's onError to return error.message; and run it.

    The 'provider429' line now ends with the text from the provider, including the key fragment sk-live-DEMO. That is exactly the leak masking prevents.

  3. 3

    Print error.message directly

    In the third example replace friendly(chat.error) in the last line with chat.error.message.

    The 401 and the own 429 now print raw JSON such as {"error":"signed_out"}, and the offline case prints Failed to fetch. That is what a person would see without a mapping function.

Code & diagrams

maxRetries: recovered, exhausted, and never retried

maxRetries counts the extra tries: maxRetries 1 means up to two calls. The user is waiting through every attempt, so a large number makes the thinking state long.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

1. "Recovered." after 3 calls
2. AI_RetryError | reason: maxRetriesExceeded | attempts: 2 | last status: 429 | calls: 2
3. AI_APICallError | status: 401 | retryable: false | calls: 1
generateText throws, streamText does not

The error is in the stream, not in an exception. If your route only has try/catch around streamText, it will never run.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

generateText threw: AI_APICallError 500
onError got: AI_APICallError 500 - upstream exploded
streamText did not throw. pieces read: 0
awaiting result.text threw: AI_NoOutputGeneratedError
--- the same failure as stream parts
start
error 500
Mask the message, keep a reference, map every shape to a sentence

Five failures, five sentences, no key fragment on screen. The (server log) lines show where the real messages went. 'own429' reads the wait from the JSON body because the client error has no response headers.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(server log) req-1 429 Rate limit reached for key sk-live-DEMO
provider429 -> Mitra is busy right now. Reference: req-1
(server log) req-2 500 upstream reset
cut -> Mitra had a problem. Reference: req-2
own429 -> You are asking too fast. Please wait 30 seconds.
signedout -> Please sign in again.
offline -> You seem to be offline. Check your connection.
Stopped and broken answers keep their text

A stopped answer and a broken answer both leave their partial text behind. Their text parts stay in the streaming state, so the UI cannot tell from the part that the answer is unfinished: track 'stopped' or 'cut off' yourself.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

after stop:       status ready | last: assistant | text part state: streaming | partial text kept: true | full text: false
after break:      status error | last: assistant | text part state: streaming | partial text kept: true | text: "Orders ship in"
after regenerate: status ready | last: assistant | text part state: done | partial text kept: true | text: "Orders ship in 1 to 2 working days."

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Leak the provider's message

In the third example change the server's onError to return error.message;.

terminal
$ The console, the provider429 line
── what you'll see ──
provider429 -> Rate limit reached for key sk-live-DEMO

Myth vs fact

Myth

If maxRetries is high, my users never see errors.

Fact

They see a long 'thinking' state instead, and a stream that fails halfway is not retried at all. Retries cover only calls that fail before any output.

Myth

A 200 status means the answer is complete.

Fact

A stream can fail after the 200 is sent. Look for the error chunk (useChat does it for you) and at the finish reason.

Myth

Masking errors makes debugging impossible.

Fact

Masking applies to what the browser sees. Your server log still has everything, tied to a reference id you can ask the user for.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Retry policy is a product decision. A chat answer that needs 20 seconds because of three retries may be worse than telling the user at once and offering a button. Many teams use maxRetries: 1 for chat and higher values for background jobs.

  • ▸

    Retries repeat the whole request, including its cost if the provider already billed for an input. Do not retry calls that change something outside (Topic 1.4); with tools (Phase 8) the retry applies to the model call, not to a tool that already ran.

  • ▸

    Log the AI_RetryError.errors array: the first failure is often different from the last, and the first one is the real cause.

  • ▸

    isRetryable for a 429 does not tell you whether the limit is per minute (retry soon) or a spent daily quota (retry tomorrow). Read the provider's message on the server and tell the user the difference (Topic 3.6).

Remember this

  1. 1

    The problem this solves: There are four places where an AI call can fail: before the request leaves (bad setup), at the provider (429, 5xx, 401), on the way (offline), and in the middle of a stream. Each reaches your code in a different shape, and a careless handler either shows the user a raw provider message (with account details in it) or hides everything behind 'Something went wrong'. This topic maps each shape to what you should do.

  2. 2

    Retries in the SDK. generateText and streamText retry a call that fails with a retryable APICallError (status 408, 409, 429 or 5xx) up to maxRetries times, 2 by default, waiting longer each time and respecting a retry-after header. A non-retryable error such as a 401 is thrown at once, after one call. When retries run out you get an AI_RetryError (reason: "maxRetriesExceeded") with errors (every attempt) and lastError. The user waits through all of it, with the status stuck on submitted, so a high maxRetries makes the 'thinking' state long. Set it deliberately, and 0 when your own code does the retrying (Topic 1.4).

  3. 3

    Two kinds of failure on the server. generateText throws, so you wrap it in try/catch. streamText does not throw: it returns at once, and a failure goes to the onError callback and into the stream as an error chunk (Topic 5.2). Reading result.text after a failed stream throws AI_NoOutputGeneratedError; result.textStream simply ends with nothing in it.

  4. 4

    What the browser may read. By default the error chunk says only 'An error occurred.' To say more, return a string from onError in toUIMessageStreamResponse({ onError }). That string is what useChat puts in error.message. Use it to return a short, safe sentence and a reference id, and log the real error with the same id on the server. **Never return error.message as is**: provider messages can include key fragments, account ids, or prompt text.

  5. 5

    Two shapes on the client. A failure inside the stream becomes the string you returned from onError (an Error whose message is that text). A failure *status* from your route (401, 429, 500, 502, 413) becomes an AI_APICallError whose statusCode is the status and whose message is the response body text, often JSON, sometimes an HTML error page from a proxy. Offline is a TypeError: Failed to fetch. Write one function friendly(error) that checks these three in order and returns a sentence; never print error.message directly. The client error does not carry the response headers, so if you want a countdown, put the seconds in the JSON body.

  6. 6

    Abort and partial messages. stop() keeps what arrived. A broken stream keeps what arrived too, and moves to error. In both cases the partial text is in messages, with its text part in the streaming state. Mark such an answer in the UI ('Stopped' or 'Cut off') so nobody mistakes half a sentence for a finished one, and offer Continue or regenerate(). When you save chats, store the partial text with a flag (Topic 5.8).

Explain it without notes

01

How does maxRetries work, and which errors does the SDK retry?

02

Why can't you catch a streamText failure with try/catch, and where do you handle it?

03

How do you make sure a provider's error message never reaches the browser, and what does the user see instead?

04

What state is a stopped or broken answer left in, and how should the UI show it?

Practice

01

Write friendly(error) so that a 413 shows 'That message is too long.' and a 503 shows 'Mitra is down for maintenance. Please try again soon.'

02

A teammate wraps streamText in try/catch and logs 'failed' there. Explain why that never prints, and show the correct place.

Trade-offs

  • ↔

    Generous retries hide short outages but make users wait and add load to a provider that is already struggling. A low value shows errors sooner but needs a good retry button. Pick per feature.

  • ↔

    Detailed error text helps users fix their own problems (for example 'message too long') but any detail from the provider can leak. Write your own messages for the cases you know and use a generic one plus a reference id for the rest.

Done when you can

  • I can explain maxRetries, isRetryable and AI_RetryError.

  • I can handle failures of generateText (catch) and streamText (onError and the stream).

  • I can write an onError that returns a safe sentence and a reference id, and log the real error.

  • I can write friendly(error) for stream errors, status errors and offline.

  • I can keep a partial answer after a stop or a break and mark it as unfinished.