Topic 1.6
Build: Mitra Answers Reliably
In one line
Mitra v1 asks through your own route, gets a whole answer as JSON, and handles every outcome: a complete answer, a cut-off one, a blocked one, a timeout, a rate limit, a broken server, no connection. It retries what is worth retrying and never shows the provider's words.
Think of it like this
The same market stall, one week later. On day one the owner served one product to one customer at a time and hoped nothing went wrong. Now there is a till drawer that is locked, a sign saying "back in 5 minutes" for when the owner steps away, and a rule for what to say when an item is sold out. Mitra v1 adds the locked drawer and the signs: it does not do more, it fails well.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- MitraError
- Mitra's own error type. It has a
kindsuch astimeoutorrate_limited, and never carries a provider's words. - askMitra
- The one function that sends a conversation to
/api/chatand returns a whole answer or throws aMitraError. - askWithRetry
- A wrapper around
askMitrathat retries the failures worth retrying, with backoff and a limit. - Reducer
- A function
(state, action) => newStatethat is the only place state changes. It makes allowed moves explicit. - noteFor
- A small function that turns a response into what to show: the answer, plus a note when it was cut off, blocked or empty.
- Ref guard
- A
useRefflag that is checked and set at once inside the submit handler, so two submits in the same moment cannot both pass.
Step by step
01Start from v0
Mitra v0 had one text box, one streamed answer (the reply came from a stand-in stream, piece by piece), a status variable with five names, and one catch-all error. It posted { question } to /api/chat and read the body as a stream. That is the starting point: v1 keeps the screen's idea, and replaces what is behind it.
02The route and the provider
The route validates exactly as in Topic 1.5 (here shortened to one expression), builds the provider request with its own system message, and passes the visitor's turns through. The provider is the stand-in, which answers according to a keyword in the question: busy is rate limited, flaky fails once with a 503, blocked returns a filtered empty answer, essay is cut off at the length limit, and slow takes two seconds.
The route logs the provider's reason and returns a code only. The mapping is a tiny version of Topic 1.3's.
03askMitra and the typed errors
The first live example tests askMitra without any screen, against ten situations. Read the output as a table of every outcome the page must handle: a normal answer; an empty list (a 400); a rate limit that carries retryAfter=2; a provider that fails once and then works; a timeout; no server at all; a cut-off answer; a blocked one; and the visitor pressing Stop, which surfaces as a plain AbortError, not a MitraError.
Building and testing this layer without UI is a habit worth keeping. Once these ten lines are right, the screen only has to display them.
04The screen on top
The second example is the whole of Mitra v1. A scripted visitor asks six questions: a normal one; one where the provider fails once (watch the console show attempt 1 failed (server), trying again and then an answer); a slow one (two attempts time out, then the error and the Try again button appear); a rate-limited one (no retry, a wait time); a blocked one; a cut-off one. Look at the page afterwards, and type your own.
Look at #status in the page for each of the states in Topic 1.2: idle, loading, success, error.
05Why the code is split this way
Four layers, each with one job: the route (secrets, validation, mapping), askMitra (one request, typed errors), askWithRetry (patience), and the component (states and words). When Phase 2 changes the response from JSON to a stream, only askMitra and the reading of the response change. When Phase 3 adds Stop, the signal option is already in place.
Try it yourself
- 1
Teach the route a new situation
In the stand-in provider add
if (/closed/i.test(text)) return { fail: { status: 500, message: "Internal error" } };, then ask "closed" in the page.Mitra retries twice (a 500 is retryable), fails, and shows "Mitra had a problem on its side" with a Try again button. The screen code did not change: new failures reuse the messages for their kind.
- 2
Change the time limit
Set
TIMEOUT_MSto3000and ask for the slow question.The slow stand-in answers after 2 seconds, so it now arrives in time (the page shows its scripted text, "This answer comes too late.", as a normal answer). The example takes longer. Moving one number changed which outcome the visitor sees, which is why the limit should come from measured response times.
- 3
Retry a rate limit automatically
Add
"rate_limited"toRETRYABLEand ask thebusyquestion.Mitra now makes three requests against a provider that said to wait two seconds, and each one counts against the limit. The visitor ends up with the same error, later. Topic 3.6 shows the right way: wait the
Retry-After, with a countdown.
Code & diagrams
Everything above the ten calls is the stand-in provider and your route: in a real app the provider is their servers and the route calls it with your key (Topic 1.5), and only `MitraError` and `askMitra` are code the browser runs. The (server log) lines are what only you see. Note which outcomes are `ok` (including the cut-off and blocked ones) and which are errors.
Preview
Console
Nothing logged yet.Expected console output
a normal question → ok {"finish":"stop","answer":"Orders ship in 1 to 2 working days."}
an empty list → Error kind=bad_request
(server log) provider said 429 Rate limit reached.
rate limited → Error kind=rate_limited retryAfter=2
(server log) provider said 503 The engine is overloaded.
provider fails once → Error kind=server
provider fine again → ok {"finish":"stop","answer":"Orders ship in 1 to 2 working days."}
too slow (timeout) → Error kind=timeout
no server (offline) → Error kind=offline
cut at length limit → ok {"finish":"length","answer":"Our masala chai is black tea blended with cardamom, ginger and"}
blocked by a filter → ok {"finish":"content_filter","answer":""}
visitor pressed Stop → AbortErrorMitra v1 as it stands after Phase 1. The scripted visitor asks six questions in turn. When it finishes, the page shows the last answer; type your own: try an empty question, "busy", "slow" and "flaky", then press Try again after an error. The retry waits are short here so the example finishes quickly; in a real app they are seconds.
Preview
Console
Nothing logged yet.Expected console output
> How long does shipping take?
answered: "Orders ship in 1 to 2 working days."
> flaky: where is my order?
(server log) provider said 503 The engine is overloaded.
attempt 1 failed (server), trying again
answered: "Orders ship in 1 to 2 working days."
> slow question please
attempt 1 failed (timeout), trying again
attempt 2 failed (timeout), trying again
failed (timeout): Mitra is taking too long. Please try again.
> busy question
(server log) provider said 429 Rate limit reached.
failed (rate_limited): Mitra is busy. Try again in 2 seconds.
> something blocked
answered: "Mitra cannot answer that. Try asking in a different way."
> write me an essay
answered: "Our masala chai is black tea blended with cardamom, ginger and…"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
Forget to release the guard
In run, delete the line busyRef.current = false; inside finally.
Myth vs fact
Myth
Retrying makes errors go away.
Fact
It hides short ones. Rate limits, bad requests and bad keys need a message and a person's decision, not a loop.
Myth
If the provider fails, the page should say what the provider said.
Fact
The page says what happened in your words. The provider's words go to the log.
Myth
Putting everything in one component is simpler.
Fact
It is shorter at first and harder to test. A layered version lets you test askMitra with no screen, as the first example does.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Keep
askMitrafree of React. It takes plain data and returns a promise, so it can be tested alone, reused by a different screen, and replaced by an SDK call in Phase 5 without touching the components. - ▸
The retry waits in this example are tens of milliseconds so the example finishes quickly. In production use whole seconds (for instance a base of 500 ms to 1 s and a cap near 8 s), and log each retry so a rising count is visible (Topic 13.5).
- ▸
Notice that
noteFortreats a cut-off answer as a *success with a note*, not an error. The visitor got something useful, and you tell them it is incomplete. That choice matters again in Phase 3, where a stopped answer is the same shape: text plus an honest label.
Remember this
- 1
The problem this solves: Mitra v0 (Topic 0.5) works on a good day. Real days have a rate limit, a slow reply, a phone that drops off Wi-Fi, an answer that stops mid-sentence. This topic turns Topics 1.1 to 1.5 into one small program, so each idea has a place to live and each failure has a message and a way forward.
- 2
What changes from v0. v0 streamed text with the stand-in stream helper and sent
{ question }. v1 sends the conversation shape every real provider uses ({ messages }, Topic 1.1), through a route that validates and adds the system message (Topic 1.5), gets a whole JSON answer back, and no longer streams: Phase 2 streams again, on this solid footing. Every file keeps the registry names: the route is/api/chat, the helper isaskMitra(messages, { signal }), and the elements are#messages,#prompt,#send,#statusand#error. - 3
The server part. The route checks the body, builds the provider request itself, and calls the provider. If the provider fails, it logs the real reason and returns only a small code:
429stays429with itsRetry-After; anything else becomes502 provider_error. A success carries the answer, thefinishreason and the token count. In this live example the "provider" is the stand-in, and the route calls it as it would call a real URL. - 4
**
askMitra(messages, { signal })is the one place that talks to the route.** It adds the time limit, joins it with an optional stop signal (used from Phase 3), checksres.ok, and throws aMitraErrorwith akind:bad_request,rate_limited(withretryAfter),server,timeoutoroffline. A caller never sees a status code or a provider message. If the *visitor* aborted, the originalAbortErrorpasses through untouched, so it is not mistaken for a failure. - 5
**
askWithRetryadds patience.** It retries onlyserver,timeoutandoffline, up to twice, with a short growing wait with jitter, and tells the screen each time ("Trying again (attempt 2)…"). It does not retryrate_limited: telling the visitor to wait is better than spending the quota that ran out (Topic 3.6 builds a countdown). After the last failure the error shows with a Try again button, which starts a fresh set of attempts. - 6
The screen. One
reducerholdsstatus, the question asked, the answer, a note and an error.noteFor(data)makes the four decisions from Topic 1.1: complete (answer only), cut off (answer with an ellipsis and a note), blocked (a note, no answer) and empty (a note). A ref guard stops a double submit (Topic 1.2). The error hasrole="alert", and the messages area is a polite live region. - 7
What Mitra can do now that it could not before: survive a failing provider, say something true and useful for each failure, retry the right ones, ignore double sends, distinguish a cut-off or blocked answer from a complete one, and keep the secret key and the instructions on the server. What is still missing, and where it is added: text that appears as it is written (Phase 2); a Stop button, partial answers and a rate-limit countdown (Phase 3); a conversation with history (Phase 4); an SDK in place of the hand-written plumbing (Phase 5); formatted answers (Phase 6); cards and actions (Phases 7 and 8).
Explain it without notes
Walk through what Mitra v1 does from the click on Send to the answer on screen, naming each layer.
Why is rate_limited not retried automatically while server and timeout are?
Which situations does Mitra v1 handle that v0 did not, and which does it still not handle?
Why does askMitra let an AbortError pass through instead of converting it to a MitraError?
Practice
Add a visible counter to Mitra v1: after each success, show Used N tokens below the answer using data.tokens from the route, and nothing when the field is missing.
Make the Try again button keep the visitor's question in the box instead of re-running it, so they can edit it first.
Trade-offs
- ↔
Four layers cost more lines than one component, and pay back in tests, in swapping the transport (Phase 5) and in knowing where a bug lives.
- ↔
Waiting for the whole answer makes every state simple to reason about and validate, at the cost of a slow-feeling product. Phase 2 trades some of that simplicity for speed of feel.
Done when you can
I can describe each layer of Mitra v1: route,
askMitra,askWithRetry, the screen.I can list the kinds of
MitraErrorand the message each one shows.I can explain which failures Mitra retries and which it does not.
I can test
askMitrawithout a screen, and I know why that is useful.I can say what Mitra v1 cannot do yet and which phase adds each missing piece.