Lesson 6 — failures, timeouts, and retries¶
What you will build¶
The failure half of every model call. A model service is a remote system that is slow, rate-limited, and occasionally wrong, so this lesson is about the taxonomy the runtime already has and the retry policy that belongs on top of it.
What you need¶
Nothing new.
"""Lesson 6 — failures, timeouts, and the retry that is worth having.
Run it with:
python -m learn_ai.ai06_errors
"""
from __future__ import annotations
import asyncio
from xyberos import Retry, StepContext, Workflow, seconds
from xyberos.ai import AIFacade, ChatRequest, FakeModelProvider, Message, ModelReply
from xyberos.errors import AIError, TransientError
QUESTION = "Will it rain?"
async def ask(assistant: AIFacade) -> str:
"""One model call, as a caller sees it."""
response = await assistant.chat(ChatRequest(messages=(Message.user(QUESTION),)))
return response.message.content
def retrying_step(provider: FakeModelProvider) -> Workflow:
"""Wrap one model call in a workflow step that retries a transient failure."""
async def ask_step(value: str, context: StepContext) -> str:
return await ask(AIFacade(provider))
return Workflow("ask-once", version="1", input_type=str).step(
"ask", ask_step, retry=Retry(max_attempts=2)
)
async def failure(provider: FakeModelProvider) -> str:
"""Return how one scripted failure is classified, or the answer if it succeeds."""
try:
return await ask(AIFacade(provider))
except AIError as error:
return f"{type(error).__name__} | transient: {isinstance(error, TransientError)}"
async def main() -> None:
"""Three failures and the two different conclusions they lead to."""
transient = FakeModelProvider([ModelReply.transient(), ModelReply.ok("Answered on attempt 2.")])
result = await retrying_step(transient).run(input=QUESTION, timeout=seconds(5))
print("after a transient failure:", result.value, f"(calls: {transient.calls})")
print("a timeout: ", await failure(FakeModelProvider([ModelReply.timeout()])))
print("a refusal: ", await failure(FakeModelProvider([ModelReply.refusal(content="No.")])))
print("rate limited:", await failure(FakeModelProvider([ModelReply.rate_limited()])))
if __name__ == "__main__":
asyncio.run(main())
The step is a workflow step, and that is deliberate: a model call inside the execution engine inherits a retry policy, a deadline, and cancellation handling without any of them being written by hand. The engine also refuses a synchronous step function, because blocking work cannot be cancelled:
WorkflowValidationError: step 'ask' must be an async function: blocking or CPU-bound work would stall
the event loop and could not be cancelled; await asyncio.to_thread(...) inside an async step instead
How to run it¶
What it prints¶
after a transient failure: Answered on attempt 2. (calls: 2)
a timeout: AITimeoutError | transient: True
a refusal: AIRefusalError | transient: False
rate limited: AIRateLimitError | transient: True
| Failure | Class | Transient? | What to do |
|---|---|---|---|
| slow or unreachable service | AITransportError |
yes | retry with backoff |
| no answer inside the deadline | AITimeoutError |
yes | retry, with a shorter prompt or a longer budget |
| rate limited | AIRateLimitError |
yes | retry, and slow down |
| unparseable payload | AIInvalidPayloadError |
yes | retry: the next answer may be well-formed |
| model refused the request | AIRefusalError |
no | do not retry; change the prompt or escalate |
| capability not declared | AIUnsupportedCapabilityError |
no | fix the configuration |
| model not found | AIModelNotFoundError |
no | fix the configuration |
| payload fails the schema | AISchemaError |
no | retry only with a changed prompt |
Transience is a property of the error class, not of the exception message: the engine's retry policy
asks isinstance(error, TransientError), which is why the lesson prints that boolean rather than a
guess. The first line is the payoff — a transient failure plus Retry(max_attempts=2) becomes an answer
on the second call, with calls: 2 proving the retry happened.
Common mistakes¶
- Retrying a refusal. It costs tokens and produces the same refusal; a refusal is a prompt or policy problem, and the fix is upstream of the retry.
- Retrying without a bound. A model service under load plus an unbounded retry loop is a
denial-of-service you wrote yourself;
max_attemptsis not optional. - Retrying a non-idempotent effect. Retry the call, never the side effect — send nothing, write nothing, then retry. The approval boundary in the hybrid path exists for this reason.
- Catching
AIErrorand returning a default. A silent default hides a class of failure the caller could have handled; let it out, and let the error policy decide what a client sees. - Doing slow work in a synchronous step. The engine's validation catches it, and the error message says where to put the blocking call.
Next¶
Lesson 7 starts the half of the path the runtime does not provide: conversation history, memory, and the retrieval layer you build yourself.