Session 2. Call a model through the adapter — Tue 15 Sep
Failing closed
What "closed" means here
A call through the seam has three honest endings: the text, a named error with the fix in it, or a refusal your caller can act on. It has one dishonest ending, and that is a plausible answer produced by something other than a model. Failing closed is the rule that removes the fourth ending.
Two constraints ride along. An error message may name the environment variable and must never print its value. And the failure has to arrive as your type, not as whatever the vendor's library happened to raise.
Failure 1: the credential that is not there
get_client(Settings(provider="anthropic", model=None, api_key=None, base_url=None))
ConfigError: Provider 'anthropic' needs an API key in the environment;
see .env.example. (The key itself is never printed.)
It raises before the SDK is constructed and before any request leaves the
machine, so there is nothing to leak and nothing to bill. The message names
.env.example, which is the file that lists the variable. A message that
echoed the key, or a prefix of it, would put a secret in a notebook output and
then in a pull request.
The neighbouring failure is the missing package. import anthropic runs inside
that client's __init__, so an uninstalled SDK surfaces as an ImportError
when you ask for that lane, and the preflight turns it into
SDK missing; run: uv sync --extra anthropic.
Failure 2: the model that lane does not serve
The provider name is checked in your process, at config time:
load_settings(env={"BOOTCAMP_PROVIDER": "gpt-9"})
ConfigError: Unknown BOOTCAMP_PROVIDER 'gpt-9';
expected one of ('fake', 'ollama', 'anthropic', 'openai')
The model name cannot be checked that way, because only the provider knows
what it serves. So the local lane asks. probe() lists the server's models
without sending a prompt, and reports a model that is not pulled as a fix
line: model 'qwen2.5:7b-instruct' is not pulled; run: ollama pull qwen2.5:7b-instruct. If you skip the probe and send the prompt anyway, the
server answers HTTP 404 and OllamaClient raises:
OllamaError: Ollama answered HTTP 404 for model 'qwen2.5:7b-instruct'.
Pull it with: ollama pull qwen2.5:7b-instruct
One rule holds across both: the error carries the command that fixes it. An error a learner cannot act on costs the same to raise and teaches nothing.
Failure 3: the call that runs past its deadline
Every adapter that touches the network has a deadline. OllamaClient takes
timeout: float = 120 and passes it to urllib, which raises TimeoutError
when the deadline passes. The adapter catches that, together with the other
transport errors, and raises OllamaError.
Two things follow, and the second one is easy to get wrong.
First, a timeout arrives at your code as an exception, from a thread of control
you do not own, at the one point where you have no answer to give. Second, the
local adapter maps "no server", "timed out" and any other OSError onto the
same message. So your refusal should state what you know — the model did not
answer in time — and not invent a cause it cannot see. Catch both types:
except (TimeoutError, OllamaError) as error:
The refusal, as a value
def answer_with_timeout(question: str) -> AgentResult:
try:
text = TimeoutLLM().complete(system="You are concise.", user=question)
except (TimeoutError, OllamaError) as error:
return AgentResult(
answer=ResearchAnswer(
answer="The model did not respond in time.",
citations=(),
confidence=0.0,
needs_human_review=True,
),
trace=(TraceEvent("decision", f"timeout: {error}"),),
)
...
ResearchAnswer is the course's answer contract; session 3 builds its parser,
and session 5 builds the agent that returns AgentResult. Today you only need
the refusal shape. Four things make it a refusal rather than a lie:
| Field | Value | Why |
|---|---|---|
answer |
what happened, in one sentence | the caller can show it to a human |
citations |
empty | a call that never happened supports nothing |
confidence |
0.0 |
there is no answer to be confident about |
needs_human_review |
True |
this is the flag downstream code routes on |
The trace keeps the cause. The refusal never claims it.
Returning a value rather than re-raising is a decision, not a habit: the caller
then handles one shape whether the provider answered or not. ch02-e4 judges
exactly that. It calls your function and fails you for a TimeoutError that
escaped, for a return value that is not an AgentResult, for a missing review
flag, and for any citation at all.
The three failures, side by side
| Failure | Caught where | The caller gets |
|---|---|---|
| Missing credential | get_client, before any request |
ConfigError naming the file, never the key |
| Unknown provider | load_settings, at config time |
ConfigError listing the four known lanes |
| Model not served | the provider, or probe() first |
OllamaError carrying the ollama pull command |
| Deadline exceeded | your except around complete |
an AgentResult flagged for review, citing nothing |
Recap
| Lesson | One line |
|---|---|
| The provider seam | complete(system, user) -> str is the whole boundary; a Protocol, four clients, one get_client |
| Two lanes | swap the provider, and permissions, grades, redaction, budgets and failure behaviour must not move |
| Failing closed | a named error with its fix, or a flagged refusal — never a plausible answer |
Exit ticket and homework
One thing that works, one thing that is unclear, your next action.
Homework: run the same prompt on a second lane — set BOOTCAMP_PROVIDER=ollama
in .env, or leave it unset and read the fake's answer again — and write one
sentence on what changed and one on what did not. Then set a deadline you think
is right for a chat answer, and say what your code should do when it passes.