When it fails
Failures live in three layers, and each asks something different of you:
- Thrown errors end the turn. Catch them.
- Tool problems are outcomes the model sees and adapts to. The turn continues.
- Diagnostics stop nothing. Read them when behavior looks off.
Thrown errors end the turn
send() throws AgentSessionError. The cases you handle in practice:
.cancelled— you cancelled the turn (see below)..alreadyRunning—send()while a turn was in flight; gate onisRunning..maxToolCallsExceeded,.maxRoundTripsExceeded,.wallClockTimeoutExceeded— a run limit tripped..toolChoiceUnsupported,.toolChoiceNotHonored— a tool-choice demand could not be met..egressRefused— your pre-transmit filter refused the request..unreadableImageAttachment— a file-URL image failed to load at the send boundary, before anything else happened (see images).
generate() additionally throws StructuredOutputError — a structured
request fails loud rather than degrading to free text (see
structured output). Providers throw their own
typed errors through send() too; the cloud's are below. The complete case
tables live in the error reference.
A turn that fails after your message is accepted has one contract: whatever
the turn already appended stays in history (your message, plus any completed
tool exchanges), nothing is appended after the failure, and the turn's
undo transaction rolls back — pre-flight
failures throw before any session mutation. Because the user
message stays, calling send() again with the same text appends it a second
time — the model copes, but trim your rendering if it bothers you.
Tool problems are outcomes
An executor that returns .failed, .denied, or .conflict has not failed
the turn. Those are values: the session feeds them back to the model, which
reads the message and adapts — retries with better arguments, picks another
tool, or explains the problem. send() completes normally.
Only a thrown executor error escalates: it propagates out of send() and
the turn rolls back. The rule of thumb — return .failed for anything the
model could reasonably react to; throw only for what it can't.
Diagnostics that stop nothing
Two observable properties report problems that did not fail the turn:
lastSchemaWarnings (the provider down-converted part of a tool schema) and
lastContextDiagnostics (a context source failed to refresh). Both are
covered in watch it work.
Cloud errors carry retry guidance
Cloud-backed providers throw BackendRouterError — a typed enum with a
metadata accessor for the server's structured details. Never parse the
message text. Switch on retryGuidance, which the SDK derives statically
from the error code:
| Guidance | Cases | What it means |
|---|---|---|
.refreshAuthThenRetry |
authExpired |
the user token expired — mint a fresh one and retry |
.retryWithBackoff(afterSeconds:) |
quotaExceeded, rateLimited, modelUnavailable, providerError, providerUnavailable, serviceUnavailable, internalError, streamTruncated |
transient — wait, then retry. afterSeconds is the server's retry_after hint when present, else a 1-second floor |
.none |
everything else | retrying won't help — check requiresUserAction |
isRetryable is shorthand for "guidance is not .none".
requiresUserAction is true for exactly two cases: authUserInvalid (the
end user must re-authenticate) and entitlementRequired (the end user must
upgrade). For display, safeDescription is a stable, safe-to-show summary;
unsafeMessage is the server's prose — log it, don't show it.
Retry once, guided
@MainActor
func sendWithRecovery(_ text: String, agent: AgentSession, auth: AuthSession) async throws {
do {
try await agent.send(text)
} catch let error as BackendRouterError {
switch error.retryGuidance {
case .refreshAuthThenRetry:
try await auth.refreshUserToken()
try await agent.send(text)
case .retryWithBackoff(let afterSeconds):
try await Task.sleep(for: .seconds(afterSeconds ?? 1.0))
try await agent.send(text)
case .none:
throw error
}
}
}
AuthSession stands in for whatever refreshes your user token. With the
static key-and-token configuration, refreshing means rebuilding the provider
with the fresh token; a custom signer resolves credentials per request, so
the retry picks them up automatically — see
AgentKit Cloud.
Cancellation is an error you expect
A cancelled turn throws AgentSessionError.cancelled — the normal "user
changed their mind" signal, not a failure to report. Partial text stays in
currentText; how a turn works owns the full
contract.