Day 12 · Calling the Claude API
In Project 3 you wrote the server side of HTTP. Today you cross to the other side and
become a client: askr will take a question, POST it to the Claude Messages API, and print the
answer. The mechanics are the same Result-and-? discipline you already know, plus one new idea that
shapes the whole project — putting the network behind a trait so the program is testable without it.
Why a trait is the first thing we build
Section titled “Why a trait is the first thing we build”The naive version of this program calls reqwest directly from main. It works exactly once: the first
time you run cargo test, every test tries to open a socket to api.anthropic.com, needs a real API key,
costs money, and fails on a plane. The fix is to make the network an interface, not a hardcoded fact:
main / tests ─────► trait LlmClient ◄─────┐ complete_streaming │ ▲ ▲ │ │ │ │ ClaudeClient MockLlm (more impls later) (real HTTP) (offline, deterministic)Callers depend on the trait. Tests inject MockLlm; production injects ClaudeClient. This is the
same “depend on a capability, not a concretion” move you’d reach for in any language — but in Rust the
compiler enforces it: a Box<dyn LlmClient> can only call methods the trait declares, so a test can’t
accidentally trip over a real socket.
pub type TokenSink<'a> = dyn FnMut(&str) + Send + 'a;
#[async_trait]pub trait LlmClient: Send + Sync { async fn complete_streaming( &self, request: &CompletionRequest, sink: &mut TokenSink<'_>, ) -> Result<String>;}The Messages API shape — get this exactly right
Section titled “The Messages API shape — get this exactly right”This is the stable contract. A completion is one HTTP request:
POST https://api.anthropic.com/v1/messages
headers: x-api-key: <your key> anthropic-version: 2023-06-01 content-type: application/json
body: { "model": "<a current Claude model id>", "max_tokens": 1024, "messages": [ { "role": "user", "content": "..." } ] }The response carries the answer in a content array of blocks; for a plain text reply you read the
text of the first block. (Tomorrow we switch this same endpoint to streaming, which changes the
response into a sequence of events — but the request shape is identical.)
In Rust, serde turns your structs into that JSON and back. We model the request as plain types and a
private “wire” struct that matches the body byte-for-byte:
#[derive(Debug, Clone, Serialize)]pub struct Message { pub role: Role, // serializes to "user" / "assistant" pub content: String,}
#[derive(Serialize)]struct WireRequest<'a> { model: &'a str, max_tokens: u32, #[serde(skip_serializing_if = "Option::is_none")] system: Option<&'a str>, messages: &'a [Message], stream: bool,}The #[serde(rename_all = "lowercase")] on Role is what makes the enum variant User serialize as the
string "user" — the API speaks lowercase, your code speaks Rust, and serde is the translator. The
skip_serializing_if means an absent system prompt simply doesn’t appear in the JSON, rather than
showing up as null.
The real client
Section titled “The real client”ClaudeClient holds a reused reqwest::Client (it pools TCP/TLS connections, so a second request is
cheaper) and the key. The send is unremarkable — and that’s the point; the interesting design work was
the trait:
let resp = self.http .post(ANTHROPIC_API_URL) .header("x-api-key", &self.api_key) .header("anthropic-version", ANTHROPIC_VERSION) .header("content-type", "application/json") .json(&body) // serde serializes WireRequest here .send() .await?;
if !resp.status().is_success() { let status = resp.status().as_u16(); let raw = resp.text().await.unwrap_or_default(); let message = extract_api_error(&raw).unwrap_or(raw); return Err(AskrError::Api { status, message });}Two things the compiler made us do, both of which are real bugs in other languages:
.await?on every step.send()can fail (DNS, TLS, timeout);text()can fail. Each returns aResult, and?forces you to either handle it or propagate it. You cannot accidentally ignore a network error.- Check the status before trusting the body. A non-2xx response still has a body — usually a JSON
error like
{"type":"error","error":{"message":"..."}}. We surface that message in a typedAskrError::Api { status, message }instead of parsing an error body as if it were an answer.
The error type
Section titled “The error type”One enum carries every failure, with #[from] so ? converts foreign errors automatically:
#[derive(Debug, Error)]pub enum AskrError { #[error("missing environment variable: {0}")] MissingEnv(String), #[error("HTTP request failed: {0}")] Http(#[from] reqwest::Error), #[error("Claude API error ({status}): {message}")] Api { status: u16, message: String }, #[error("could not read the documents directory or a file: {0}")] Io(#[from] std::io::Error), #[error("JSON (de)serialization failed: {0}")] Json(#[from] serde_json::Error),}This is the thiserror pattern from kvlite, now spanning the network: a reqwest::Error, an
io::Error, and a serde_json::Error all flow through one ? into one type your main can match on.
The mock — why the tests are hermetic
Section titled “The mock — why the tests are hermetic”MockLlm implements the same trait with no socket: it returns a canned reply (or echoes the question),
streamed word-by-word so callers exercise the identical code path. Because tests depend on LlmClient,
they never know the difference:
#[tokio::test]async fn mock_fixed_reply_via_complete() { let client = MockLlm::new("forty-two"); let answer = client.complete(&req("the meaning of life?")).await.unwrap(); assert_eq!(answer, "forty-two");}cargo test is green with no ANTHROPIC_API_KEY and no network. That’s the requirement the trait was
there to satisfy.
Secrets come from the environment, never the source
Section titled “Secrets come from the environment, never the source”The key is read at the boundary and nowhere else:
pub fn api_key() -> Result<String> { env::var("ANTHROPIC_API_KEY") .map_err(|_| AskrError::MissingEnv("ANTHROPIC_API_KEY".to_string()))}A hardcoded key is a one-line mistake with a long tail.
Under the hood — what reqwest is doing before your await returns
Section titled “Under the hood — what reqwest is doing before your await returns”.send().await is a lot of machinery compressed into one line. Under it, reqwest (on tokio) resolves
DNS, opens a TCP connection, runs the TLS handshake (we built with rustls, so no system OpenSSL is
needed), writes the HTTP request, and yields the task back to the runtime while it waits for bytes. That
yield is the whole reason async exists here: a network round-trip is mostly waiting, and an await
point lets the runtime do other work meanwhile instead of blocking a thread. The borrow checker has
already proven that everything you’re holding across that await is safe to hold — which is exactly the
guarantee that makes “thousands of in-flight requests on a handful of threads” sound rather than
terrifying.
Build it
Section titled “Build it”Today’s slice — the one-shot ask:
cargo new askr --bin, addtokio,reqwest(json+stream+rustls-tls),serde,serde_json,thiserror,async-trait.- Define
LlmClient,CompletionRequest,Message,Role, andAskrError. - Implement
ClaudeClient(real POST) andMockLlm(offline). - In
main, pick the client: real ifANTHROPIC_API_KEYis set, otherwise the mock (and say so). cargo testgreen offline;cargo run -- "your question"prints an answer.
Tomorrow we make the answer stream, and teach askr to read your local files first.
Check your understanding
Section titled “Check your understanding”- Why does
askrhide the network behind theLlmClienttrait instead of callingreqwestdirectly frommain? Name two concrete things it buys. - List the four parts of a Claude Messages API request a client must get right (endpoint, the three headers, and the required body fields).
- Why is the model id read from
ANTHROPIC_MODELrather than written as a constant in the code? - After
.send().await?, why doesClaudeClientcheckresp.status().is_success()before reading the body as an answer? What would go wrong if it didn’t? - Why can
cargo testpass with no API key and no network — what is actually under test in the mock path?
Show answers
- So the program is testable and runnable without the network. Concretely: (a)
cargo testswaps inMockLlm, so the suite needs no API key, no socket, and no money and runs fast/hermetically; (b) the binary can fall back to the mock when no key is set, and swapping providers later is a newimpl, not a rewrite. The compiler enforces that callers only use what the trait declares. - Endpoint:
POST https://api.anthropic.com/v1/messages. Headers:x-api-key: <key>,anthropic-version: 2023-06-01,content-type: application/json. Body:model,max_tokens, and amessagesarray of{ "role", "content" }objects. - Model ids are versioned and eventually retired, so hardcoding one bakes an expiry date into the binary. Reading it from an env var (with a clearly-labelled placeholder default) makes the model a config value you can change without editing and recompiling source.
- A non-2xx response still has a body, but it’s a JSON error object, not an answer. Without the
status check you’d parse the error as a successful reply — surfacing garbage or a confusing parse
failure instead of the real problem. The check lets
askrreturn a typedAskrError::Api { status, message }carrying the server’s actual error message. - The mock path exercises the trait, request building, message/role serialization, and the streaming
sink — all the logic that isn’t the socket.
MockLlmimplementsLlmClientwith a deterministic, in-memory reply, so everything except the actual HTTP call is covered without touching the network.
Prev: Overview — Project 4 · Next: Day 13 · Streaming and RAG →