Limits & errors
Error shape
Failures are JSON with a detail string:
{ "detail": "token lacks capability: computer" }
The SDK throws AgentApiError with .status and .detail:
try {
await ai.chat.send({ message });
} catch (e) {
if (e instanceof AgentApiError && e.status === 403) requestUpgrade();
else throw e;
}
| Status | Meaning | What to do |
|---|---|---|
400 | malformed request, a bad attachment (unresolvable URL, truncated data: URI, not-an-image), or the model provider rejecting it (unsupported parameter, context too long) | fix the call; detail names the problem |
401 | missing, invalid or expired token | mint a fresh one |
403 | capability or origin denied, or an action you may not take on something you can see | not allowed — don't retry |
404 | not found, or not visible to this subject | treat as not found |
409 | conflict — a project or organization name already taken, an upload acted on out of order, a turn already running on the conversation you addressed, or this project has no usable provider | reconcile, then retry; for a running turn, attach to it (see Chat); for a provider, register one |
422 | invalid request body, or an ingest that failed on the document itself | detail is an array of {loc, msg} — see below |
429 | rate limit or usage cap reached — including on an upload | back off; see below |
500 | a bug on our side, or a backend we depend on being unreachable | retry once; if it repeats, the detail is worth sending us |
502 | the model provider failed with an error of its own | retry with backoff |
504 | the model provider did not answer in time | retry with backoff; if it repeats, your endpoint is too slow rather than your request wrong |
504, not 502, when nothing answered. A provider that never replies and a provider
that replies badly are different problems with different fixes, and they used to share a
number. If your model endpoint sits behind a CDN, a 504 here is the same event your CDN
logs as 524 — your origin exceeded its limit — and the detail says so in as many words:
the request to the upstream endpoint timed out after 125.4s. Nothing in that sentence is a
status code any more, because none was ever sent: an upstream that has not answered has not
answered with a number either.
An attachment you got wrong is a 400, not a 502. Three shapes used to answer 502 — a
host that does not resolve, a data: URI cut short, and a URL that resolves to something
that is not an image — which told every client following the table above to retry a request
that cannot succeed. On a stream the same failure arrives as code: "bad_attachment" with
status: 400.
A 404 that's really "you can't see it" is deliberate: probing ids shouldn't reveal which
exist. So reading another subject's document or session is 404, in the same words a
made-up id gets — not 403, which would confirm the id was real. 403 is kept for the
case where it tells you nothing you didn't know: you can see the thing and may not do
this to it (deleting a tenant-visible document you don't own), or the capability,
origin or model allow-list refused the call outright.
The project's own per-minute limits
tpmLimit and rpmLimit (dashboard: LLM & limits, or limits.set) bound how fast a
project may call its models. Exhausting one answers 429 — the same status and the same
rate_limit reason as any other rate limit — with the provider's own reset time in the
message where it gives one:
429 rate limit reached on rpm — this is transient, so retry shortly (resets at 12:01 UTC).
Raise the per-minute limits under LLM & limits if it keeps happening.
Back off and retry; do not treat it as a failure. Unlike a spend cap, this one clears by itself.
It used to be enforced by waiting rather than refusing — the proxy answered Retry-After: 60 and the data plane's own HTTP client slept through it, twice — so a project with
rpmLimit: 2 sending five requests saw 5.6s, 5.9s, 64.1s, 66.0s and 125.6s and no 429 at
all. That outlived most client timeouts, including this SDK's own, so the platform was
holding requests its caller had already abandoned, and nothing named the delay. If you have
retry logic written against a queueing platform, it is now the ordinary 429 path.
A streamed turn that is refused
A stream cannot answer with a status — the response has already begun — so the refusal
arrives as an error frame, and the SDK throws AgentStreamError. Two of its codes are
about the turn rather than the stream, and both carry the status the same refusal would
have had on POST /chat:
try {
await ai.chat.stream({ message }, handlers);
} catch (e) {
if (e instanceof AgentStreamError) {
if (e.code === "usage_cap") stopAndTellSomeone(e.message); // raise it, or wait
if (e.code === "rate_limited") await backOffAndRetry(); // transient
if (e.status === 429) { /* either of the two, if you don't care which */ }
}
}
Before this the streaming path handed you the provider's own sentence — Budget has been exceeded! Key=… Max budget: 0.0 — with no code and no status, so "am I out of budget" was
answerable only by matching English against the message. POST /chat had always answered
429 with a sentence naming the setting; the two paths now say the same thing.
An upload that fails
Ingestion is asynchronous, so its failure is not the status of any one request: the
document ends status: "failed" and carries both a sentence and a word.
const doc = await ai.documents.uploadAndWait(file, { filename: "handbook.pdf" }); // throws
// or, polling yourself:
if (doc.status === "failed") {
doc.error; // "spend cap reached for this project — raise it under LLM & limits…"
doc.failure; // "usage_cap"
}
error is for a person; failure is for the code around them. Two of its values are not
about the file at all — usage_cap and rate_limit mean the project hit its own spend cap
or per-minute limit, nothing is wrong with the document, and re-uploading it will fail the
same way until the cap is raised or the window resets. (rate_limited is a 429 whose body
did not say which of the two it was.) The rest name where it broke:
unsupported_type, unreadable, embedding, vector_store, storage, unknown.
uploadAndWait and waitReady throw with the status that matches: 429 for the two
limit cases, 422 for the rest. So the retry rule is the same one you already have for
/chat — back off on 429, don't retry a 422.
Reading detail
detail is a string for most errors and an array for a 422, so
`${e.detail}` prints [object Object] on the one status where the useful content
is per-field. e.message is always a readable summary — use that for logs, and reach
into e.detail when you want to attach errors to form fields:
catch (e) {
if (e instanceof AgentApiError && Array.isArray(e.detail)) {
for (const { loc, msg } of e.detail) console.log(`${loc.join(".")}: ${msg}`);
} else if (e instanceof AgentApiError) {
console.log(e.status, e.message);
}
}
When the provider goes away mid-conversation
Rotating a credential out from under a live conversation is routine, and it used to answer
with the proxy's own words: User not found. A customer reads that as their end-user and
goes looking at the subject, the token, the mint call and per-user isolation — all correct,
none of them the thing that changed. It is now a 409 naming the cause and the fix, with
code: "no_provider" on the streaming path.
Do not retry it: nothing can answer until a provider is registered. The conversation is not damaged — the failed turn persisted nothing, and a turn on the same session works the moment a provider exists again, with its history intact.
A 502/524 may be generated by the proxy in front of the API rather than by the API,
in which case the body is an HTML page. The SDK collapses that to a one-line summary
before it reaches message and detail — logging one used to print ninety lines of
markup — and says it came from an intermediary, which is your cue that the request may
never have reached us at all.
Which turn failed
A failure carries the ids of the turn it belongs to, on both paths, so a report of one is answerable afterwards rather than only at the moment it happened:
try {
await ai.chat.send({ message: "…" });
} catch (e) {
if (e instanceof AgentApiError) {
console.error(e.status, e.message, { runId: e.runId, sessionId: e.sessionId });
// Whatever the turn managed to write is still readable:
if (e.sessionId) await ai.chat.sessions.messages(e.sessionId);
}
}
AgentStreamError has carried both for longer, alongside the content that had already
streamed. Either one is absent when the failure happened before there was anything to name —
a refused token has no run — so check before using them.
Streaming has its own two: AgentStreamError (the server sent an error frame, or retries
were exhausted) and AgentCancelledError (someone called cancel()). handle.disconnect()
and aborting your own signal reject with a plain AbortError instead — the run carries
on server-side in those cases, so calling it cancelled would be wrong.
An AgentCancelledError means the request is finished with, not parked: the message stays in
the conversation but is marked unanswered and never replayed, so your next turn does not
inherit what the user just stopped. See What cancel guarantees.
Retries
The SDK retries transport failures and resumes dropped streams; it does not blindly re-POST a chat turn, because a dropped stream resumes by run id instead — you never pay twice for the same answer. Uploads retry per part with backoff, and ranged downloads resume from where they stopped.
For your own retries: 429, 502 and 504 are worth retrying with jittered backoff, 4xx
otherwise is not.
A streamed turn carries the same number. A failure inside a stream cannot be an HTTP
status — the response was already 200 when the first frame left — so the error frame
carries status alongside its code, and it is the status the same failure would have got
from a blocking POST /chat. Branch on that and one retry policy covers both, which is the
point: a rotated provider key is 409 either way, a spend cap is 429 either way, and a
provider timeout is 502 either way. Read status where you have it and treat a frame
without one as a failure you cannot classify rather than as a retryable one.
Knowing your turns are failing at all
A failed streamed turn is a 200 OK — the failure is a frame inside a body that had
already started — so it appears in no access log, no 5xx count, and no HTTP-level alert
you have built. That is the correct shape and it has a cost: a client that retries into a
provider which cannot answer produces a failure every few minutes and looks, from outside,
like silence.
Two reads answer it:
# What the last 24 hours of turns did
curl -s "https://oberik.com/api/projects/$OBERIK_PROJECT_ID/turns" \
-H "X-API-Key: $OBERIK_PROJECT_KEY"
# {"window_hours":24,"total":234,"errored":234,"consecutive_errors":234,
# "last_error":"the upstream endpoint answered 502 Bad Gateway","last_success_at":null}
consecutive_errors is the number to alert on: it counts back from the most recent turn
and a success resets it, so it separates a client retrying carefully from a client stuck in
a loop. window_hours defaults to 24 and takes 1–168.
The control plane says the same thing without you asking. GET /readiness carries a
turns step once the streak passes a handful, naming the count and the last reason. It is
deliberately not essential, so canAnswer stays true and a deploy gated on it is not
held hostage to your provider having a bad night — but ready goes false and next points
at it.
Neither of these refuses anything or asks you to back off. What is failing is your own provider or configuration, the first failure already said which, and retrying into it returns the same answer.
Telling a refusal from a dropped connection
AgentStreamError.terminal says which one you have. true means the server sent an
error frame and closed — status, code and the sentence are all populated, the SDK
does not reconnect, and retrying the same request gets the same answer. false means the
stream stopped without saying anything, which is the case reconnection is for; the SDK has
already retried by the time you see it, and the message says why it gave up.
try {
await ai.chat.stream({ message }).done;
} catch (e) {
if (e instanceof AgentStreamError && e.terminal) {
// e.status is the same number `POST /chat` would have given: branch on it.
if (e.status === 429) await backOff();
else show(e.message, e.content); // render what already arrived
}
}
It is a property rather than something you read out of the message, and that is not
cosmetic: the SDK itself used to decide terminality by testing whether the message started
with "server: ", that prefix was removed for being noise in front of the first word a
customer reads, and every server refusal silently became a "dropped connection" — ten
reconnects over 136 seconds against a 405, ending in status: null.
A stream that cannot be resumed
Resuming needs the run's frames, and those live in the memory of the API process that produced them — for the length of the turn, plus 10 minutes, and no longer than that process itself. See how long a run lasts.
When they are gone the reconnect does not 404. It answers with one terminal error
frame, so handle.done rejects with an AgentStreamError that carries code,
sessionId and the content that had already streamed — enough to render what the user
watched arrive and then either read the finished answer out of the conversation
(stream_expired) or send the turn again (stream_lost). Retrying the POST blindly is
the one wrong move: on stream_expired it pays for an answer that already exists.
Timeouts
Long runs are normal — retrieval, tools, a sandbox build. Prefer streaming for anything interactive: the first token arrives quickly and you're not holding a request open with nothing to show. If you must block, set a generous client timeout (120s+) and expect tool-heavy turns to sit near it.
In the SDK that timeout is timeoutMs on the client, and it defaults to 600000 (10
minutes):
const ai = createClient({ getToken, timeoutMs: 180_000 });
timeoutMs aborts the request. Node's HTTP client has its own 300-second
headersTimeout that fires first and is not reachable from an AbortSignal, so a
600000 budget really ends at ~302s as UND_ERR_HEADERS_TIMEOUT.
Streaming is the better answer and needs none of what follows: it holds the connection open by design, has no header ceiling, and can be resumed after a dropped connection. Reach for a dispatcher only when you must block.
Raising it takes an undici Agent, which is yours to pass — this SDK has no
dependencies and has to load in a browser and under React Native, so it cannot import
one for you:
npm i undici@^7 # the major matters — see below
import { Agent } from "undici";
const ai = createClient({
getToken,
timeoutMs: 900_000,
dispatcher: new Agent({ headersTimeout: 900_000, bodyTimeout: 900_000 }),
});
Pin the major. Node's built-in fetch is an embedded copy of undici, and the
dispatcher handler protocol changed between majors — so an Agent from undici@8 makes
every request fail, tools.list() included:
TypeError: fetch failed
cause: invalid onRequestStart method
undici@7 matches what Node embeds and works. Ask before you rely on it:
import { blockingBudgetIsHonoured, dispatcherProblem } from "@oberik/sdk";
const problem = dispatcherProblem(agent); // null, or the sentence
if (problem) throw new Error(problem);
blockingBudgetIsHonoured(900_000, agent); // false for a dispatcher that would fail
Both ask the dispatcher itself which handler interface it speaks and compare it with the one
serving fetch, so a mismatch is caught before you make a request rather than by every
request failing as TypeError: fetch failed. (They used to answer "is one configured",
which meant true for exactly the setup that breaks everything.)
In a browser there is no such ceiling and none of this applies.
A stream can be re-attached: it has a run id, and a dropped connection resumes the same
run and replays what was missed. A blocking call has neither. There is no run id to
reconnect to, so a timed-out chat.send or chat.answer leaves the work done and paid
for with no way to collect the answer — and what surfaces in your code is your HTTP
client's error (TypeError: fetch failed … read ETIMEDOUT from undici, with nothing
naming Oberik in the stack), not ours.
So: streams are resumable, blocking calls are not. For anything that may run long, stream
it — even if you only use the final done.
Sandbox commands default to 5 minutes and may ask for up to 30 minutes, for the
agent's computer_bash and for ai.computers.exec alike. GET /computers/host reports
both figures as exec_default_timeout_s and exec_max_timeout_s — read them rather than
hard-coding these. A timeout_s above the ceiling is clamped to it, and a command that
overruns is killed and reported as timed_out, not left to hang.
Bounding a run
There's no platform-imposed tool-loop limit — an agent that needs forty steps to finish a job gets forty. Bound it deliberately, at mint time:
{
subject: user.id,
maxToolIterations: 12, // ceiling for this token
models: ["openai/gpt-4o-mini"], // model allow-list
maxEffort: "low", // reasoning-effort ceiling
expiresIn: 900,
}
maxToolIterations on a mint is bounded by the project's own ceiling (Capabilities tab):
a token can be given fewer loops than the project allows and never more, and a mint that
omits it inherits the project's. With no project ceiling set there is no limit — an
agent that has misread a task keeps calling tools until the project's spend cap stops it,
so a project serving end-users should set one.
If a run hits the ceiling you get whatever prose the agent had already written, plus:
res.finish_reason; // "max_tool_iterations"
content can be empty. If the ceiling lands mid-tool-loop — which is the common
case, since that is what the ceiling counts — the agent had not started writing its reply
yet, and there is no partial answer to hand back. Your UI needs its own copy for that,
not just "render content":
if (res.finish_reason === "max_tool_iterations" && !res.content.trim()) {
show("That took more steps than allowed. Try a narrower question.");
}
It's a machine-readable string, deliberately not a sentence — you decide how (and in which
language) to tell the user. null means the agent finished on its own terms. Check it before
treating a response as complete.
finish_reason | What happened | Who can fix it |
|---|---|---|
max_tool_iterations | the tool-loop ceiling was hit; content is the prose written so far, and is often empty | you, by raising maxToolIterations on the mint — or the project's own ceiling, which the mint can only narrow |
guardrail | an input guardrail refused the message | the end-user, by asking something else |
no_model | the project has no model configured, so nothing could run | whoever set the project up |
no_model is worth handling separately: it is not the end-user's doing and not something
they can act on, so showing it as a normal agent reply sends them looking for a different
question to ask. Route it to whoever configures the project —
GET /api/projects/{id}/readiness lists everything that is still missing. The turn costs
nothing: no model runs.
A project does not silently fall back to a shared platform model. It would bill the operator rather than the project, land outside the spend cap that project set, and be attributed to nobody — and where no shared model is configured it simply fails with the provider's own error, which reads as the product being broken rather than as one setup step.
Rate limits and spend caps
Per-project caps are set in the dashboard: requests per minute, tokens per minute, and a spend
budget over a window. Exceeding any of them returns 429 rather than silently degrading —
including budget_exceeded, which is a cap you set, not a transient condition, so retrying
won't help until the window resets or you raise it.
Other standing limits: 10 concurrent sandboxes per project, 20 MB per sandbox file read/write/export, 30,000 characters of command output per call.
When an answer looks wrong
In order of usefulness:
res.citations— did it retrieve the right thing?ai.documents.chunks(id)— was the document indexed the way you expect?ai.chat.sessions.messages(id)— the full transcript, including tool calls.res.guard_flags— did a guardrail rewrite the answer?- Memory, if enabled — a remembered fact may be steering it.
- The dashboard's usage and run views — latency, tokens, cost, per model.
Every streamed run has an id (X-Run-Id, and the run event). Log it next to your own
request id and a support question becomes one lookup instead of an investigation.