One HTTP round trip
POST /v1/messages200request-id, limit headersREST, learned from the API you already call
A REST-style API exposes resources at paths and acts on them with HTTP methods. The Claude API is a clean worked example: you create a message with POST /v1/messages; you create a batch with POST /v1/messages/batches, then read it with GET /v1/messages/batches/{id} and its results at /results, list batches with GET, and cancel with a POST to /cancel; files are created, listed, retrieved and removed at /v1/files. The verb tells you the intent, the path names the thing.
Three ideas earn their place in your code. Statelessness: each request carries everything needed to serve it, which is exactly why the Messages API needs the whole conversation every time. Status codes in classes: 2xx succeeded, 4xx means your request was wrong, 5xx means the server failed. And headers carry the metadata that makes operations possible — the Claude API returns a request-id on every response, rate-limit headers telling you what remains, and retry-after on a 429.
| You see | Whose fault | What the code should do |
|---|---|---|
400 invalid_request_error | Yours | Fix the request; retrying identical input just repeats it |
401 / 403 | Yours | Credentials or permissions — never retry in a loop |
413 request_too_large | Yours | Shrink the payload; consider file references instead of inline data |
429 rate_limit_error | Shared | Back off, honouring retry-after, with jitter |
500 api_error / 529 overloaded_error | Theirs | Retry with exponential backoff, then give up and surface it |
Error typing and recovery are their own task statement (4.1), so the foundations point is narrower: a 4xx and a 5xx are different categories of event, and code that treats every non-200 as “retry” will hammer an endpoint that will never succeed. Log the request-id with the failure — it is the one identifier that lets someone else investigate.
JSON, and parsing things you did not write
JSON is the interchange format for all of this: objects, arrays, strings, numbers, booleans and null, with double-quoted keys and no comments. Two rules of thumb prevent most JSON defects in a Claude application. First, ignore what you do not recognise: Anthropic's versioning policy allows additive change without a version bump — new optional inputs, new values in enum-like outputs — so a parser that rejects unknown fields or crashes on an unfamiliar enum value will break on a day you did not deploy. Second, never parse by regular expression or by trusting the shape; use the typed models your SDK provides, or validate against a schema.
JSON Schema shows up twice in this exam and both times it is a contract: a tool's input_schema describes what Claude may send you, and a structured-output schema describes what you require back. Schema design is 2.5's subject; what belongs here is the engineering instinct that a contract written down is worth more than a convention everybody remembers differently.
Two ways to read a response
Brittlepython
text = resp.content[0].text
data = json.loads(
text.split("```")[1])
name = data["customer"]["name"]
if resp.stop_reason == "end_turn":
save(name)Defensivepython
block = next(
(b for b in resp.content
if b.type == "text"), None)
if block is None:
return handle_no_text(resp)
try:
data = json.loads(block.text)
except ValueError:
return handle_bad_json(resp)
name = data.get("customer", {}).get("name")Asynchronous programming
Calls to a model are slow and almost entirely spent waiting on the network. That makes them the textbook case for concurrency: not doing more work at once, but stopping one wait from blocking the others. Every official SDK offers an asynchronous client — AsyncAnthropic in Python, promises in TypeScript — and the useful pattern is nearly always the same: fire off many requests, bound how many are in flight, and collect the results.
Bounded is the operative word. Unbounded concurrency is how a well-meaning script turns into a self-inflicted rate-limit incident: a hundred coroutines all hit the per-minute limit at once, all receive 429, all retry together, and the pattern repeats. A semaphore around the call, backoff that honours retry-after, and jitter so retries do not re-synchronise, are the three parts of a fix.
import asyncio
from anthropic import AsyncAnthropic
client = AsyncAnthropic() # same API surface, awaitable
limit = asyncio.Semaphore(8) # at most 8 requests in flight
async def summarise(doc: str) -> str:
async with limit: # queue here, not at the API
resp = await client.messages.create(
model=MODEL_ID, max_tokens=512,
messages=[{"role": "user", "content": doc}],
)
return resp.content[0].text
async def main(docs: list[str]) -> list[str]:
# return_exceptions keeps one failure from cancelling the rest
return await asyncio.gather(
*(summarise(d) for d in docs), return_exceptions=True
)Two adjacent facts save work. The SDKs already retry transient failures — connection errors, rate limits and 5xx responses — with exponential backoff, honouring retry-after, and both the retry count and the timeout are configurable; write your own retry loop only when you know what the built-in one does not do. And if the work does not need to be concurrent because nobody is waiting, the asynchronous answer may be the Message Batches API rather than more threads.
Version control, review and the pipeline
The rule that makes an AI application maintainable is that prompts, schemas, tool definitions and configuration are source code. They go in the repository, they change on a branch, they are reviewed in a pull request, and their history explains why last quarter's output looked different. A prompt in a wiki page or pasted into a console is a production dependency with no diff, no author and no rollback.
Configuration follows the same split most teams already use for application settings. Claude Code's own settings are a good model of it: a shared .claude/settings.json that is committed so everyone in the repository gets the same rules, and a .claude/settings.local.json for personal overrides that is kept out of commits. Secrets are the exception that proves the rule: they are referenced from the repository and stored outside it, as GitHub repository or organisation secrets rather than as a line in a workflow file.
A change, from branch to production
- Branchcode, prompts and schemas together
- Pull requestsmall, with a stated intent
- Automated checkstests, linting, evaluation set
- Human reviewsomeone who can say no
- Merge and releasewith a way back
a defect found in production re-enters as a new branch, not a hotfix in the console
Claude can take part in that pipeline rather than sitting outside it. The Claude Code GitHub Action runs inside a repository's workflows: mention it in an issue or pull-request comment and it responds there, or give the workflow a prompt and it runs automatically on an event — the documented review workflow posts findings as inline comments on the pull request. Teams keep it bounded with a turn limit and an explicit allowed-tools list, and they keep the human reviewer, because an automated reviewer is an extra pair of eyes and not an approver.
Refactoring, small and large
Refactoring is changing structure without changing behaviour, and the exam's phrase “at small and large scale” is a real distinction. Small scale is local and safe: renaming a variable to what it actually holds, extracting a function, deleting a branch nobody reaches. You do it continuously, under the cover of tests, in the same pull request as the work that revealed the problem.
Large scale is a migration, and it is a different discipline: you cannot stop the world while you do it. The pattern that works is incremental — introduce the new structure beside the old one, route a slice of traffic or one module through it, verify, widen, then remove the old path once nothing calls it. What makes it safe is having something that tells you behaviour has not changed: for a Claude application that is both ordinary tests at the boundary and an evaluation set for the model-facing behaviour.
Traps the wrong answers are built from
| Tempting but wrong | Do this instead |
|---|---|
| Retrying every non-200 response | Retry 429 and 5xx with backoff; fix 4xx requests instead of repeating them. |
| Firing unbounded concurrent requests | Bound in-flight work with a semaphore and honour retry-after with jitter. |
| Extracting fields with string splitting or regular expressions | Use typed SDK models or parse and validate, tolerating unknown fields. |
| Keeping prompts in a document outside version control | Treat prompts, schemas and configuration as source code with diffs and review. |
| Committing API keys so a workflow can use them | Store them as repository or organisation secrets and reference them from the workflow. |
| Rewriting a sprawling integration in one pull request | Introduce a seam, migrate a slice at a time, and delete the old path last. |
You should now be able to
- Describe an API call in terms of method, path, headers, status class and body, and log
request-id. - Parse model and API output defensively, tolerating unknown fields and missing values.
- Write bounded concurrent request code, and say when batch processing is the better answer.
- Keep prompts, schemas and configuration under version control with review gates.
- Explain the difference between small-scale refactoring and an incremental large-scale migration.