Skip to content
Domain 2Task 2.4

Software Engineering Foundations

Learn this interactively

What You Need to Know

This sub-skill covers the engineering around a Claude integration. A fan-out splits one job into many independent API calls. Bounded concurrency limits how many of those calls run at the same time. Messages API rate limits are measured in requests, input tokens and output tokens per minute (RPM, ITPM and OTPM).

This lesson uses one made-up example throughout: a food delivery app company whose engineering team uses Claude to convert restaurant menus into listings for the app. The team sends a few hundred menus each night, and support staff request single listings from a web form.

HTTP and JSON

Everything in 2.3 runs on plain HTTP and JSON. Know which side sets which header: the client sets request headers, and the server sets response headers such as retry-after. The split between 4xx and 5xx statuses tells you whose bug it is. A 4xx status reports a problem with the request, and a 5xx status reports a failure on the server. An idempotent retry (a retry that leaves the system in the same state when repeated) is safe for a read, because reading twice changes nothing. Retrying a create needs care, because the team could publish one listing twice.

A JSON payload that crosses a service boundary is an interface. Renaming a field or changing a type breaks every consumer. So changes to this contract (the agreed fields and types) are versioned and reviewed like any API change. They are never shipped as internal details.

The official SDKs wrap the REST API in seven languages: Python, TypeScript, C#, Go, Java, PHP and Ruby. Typed error classes map to the documented status codes. The SDK tables name no class for 402 or 413. For those statuses, read the value of status_code. In every language, it is better to catch the specific class than to match strings in error messages.

python
import anthropic

client = anthropic.Anthropic()

try:
    response = client.messages.create(
        model="claude-sonnet-5-5",
        max_tokens=512,
        messages=[{"role": "user", "content": record}],
    )
except anthropic.RateLimitError:
    ...  # 429: honour retry-after, back off
except anthropic.APIStatusError as err:
    print(err.status_code, err.request_id)

Bounded Concurrency

The team's nightly run can fail in two ways. A serial loop sends one call at a time. It wastes the rate limit and takes hours that it does not need. An unbounded fan-out sends every request at the same time. It triggers the token-bucket limiter, which enforces the rate limits, and the run returns a large number of 429 errors. A token bucket is an allowance that refills continuously up to the limit. Sharp usage spikes can also trigger acceleration limits.

The correct design is a worker pool or semaphore. It keeps enough requests in progress to use the documented RPM, ITPM and OTPM headroom (the capacity left below those limits), and no more. In addition, the code retries transient failures, including rate limits and 5xx server errors, with exponential backoff (the wait grows after each failed attempt). That backoff uses jitter (a small random addition to each wait) and honours retry-after.

python
import asyncio
import random
import anthropic

# Hand-rolling backoff here, so disable the SDK default of two retries;
# otherwise every application attempt multiplies into three requests.
client = anthropic.AsyncAnthropic(max_retries=0)
semaphore = asyncio.Semaphore(5)  # sized against your documented limits

def spend_capped(err: anthropic.RateLimitError) -> bool:
    body = err.body if isinstance(err.body, dict) else {}
    details = (body.get("error") or {}).get("details") or {}
    return details.get("error_code") == "enforced_spend_limit_reached"

async def normalise(record: str) -> str:
    async with semaphore:
        for attempt in range(4):
            try:
                response = await client.messages.create(
                    model="claude-sonnet-5-5",
                    max_tokens=512,
                    messages=[{"role": "user", "content": record}],
                )
                return response.content[0].text
            except anthropic.RateLimitError as err:
                if spend_capped(err):
                    raise  # the tier's monthly spend cap: no retry clears it
                wait = float(err.response.headers.get("retry-after", 2 ** attempt))
                await asyncio.sleep(wait + random.uniform(0, 1))
        raise RuntimeError("still rate limited after backoff")

async def main(records: list[str]) -> list[str]:
    return await asyncio.gather(*(normalise(r) for r in records))

The SDKs already retry twice by default. So a hand-written loop that ignores this multiplies attempts: three application tries become nine requests. The extra requests amplify the bursts that backoff exists to reduce. That is why the code sets max_retries=0. OTPM counts the tokens generated. So lowering max_tokens gives no control over rate limits. It only risks truncating the menu listings.

The loop also re-raises one type of 429 immediately. As of September 2026, the tier's monthly spend cap returns a 429 with no retry-after and error.details.error_code set to enforced_spend_limit_reached. No retry clears it until access resumes.

The synchronous form of this problem is the blocked handler. If the team's web form calls the Messages API synchronously inside the request handler, the call blocks a worker for the whole generation. The generation takes seconds to minutes, so throughput falls sharply under modest load. The fix is async I/O, streaming to the client, or a job queue. The interaction pattern decides which one to use.

Concurrency or Batch: the Deadline Decides

When the fan-out is large and the deadline loose, the Batches API from 2.3 is relevant again. If you need results within hours, use realtime calls with bounded concurrency. If results within a day are acceptable, and cost is the main concern, use batch. Listings due before restaurants open the next morning stay realtime. 2.3 covers how to choose the API. This sub-skill covers how to write the concurrent code when realtime is the better choice.

Prompts Are Source Code

A prompt change is a behaviour change with the same possible impact as a code change. The same holds for output schemas, eval sets, CLAUDE.md files and settings. So, like code, they are stored in the repository and change through branches and review. Like code, they carry history and can be reverted. A listing prompt changed live in the company's production console has no history, no review and no rollback. There is also no guarantee that staging (the test environment before production) matches what users see.

Prompt and schema changes use the code pipeline, and the eval suite (the test cases that score the output of a prompt) is their regression gate. An edit that lowers eval scores fails the build like a failing test. That gate makes it safe to change prompts fast. If the pipeline is the only route to production, editing in the console is impossible by design, not merely discouraged.

Review and Refactoring Discipline

Claude makes mechanical work faster, but some human disciplines do not become less strict. An AI-assisted diff gets the same review standard as a human one, because review catches a subtly wrong generated refactor. Passing tests do not prove that behaviour is unchanged when no test checked that behaviour. If no test covers how the app calculates the delivery fee, a refactor that changes it still passes.

The approach to refactoring depends on scale. Do small refactors in behaviour-preserving steps, with the suite passing after each step. On a poorly covered module, large work first builds a safety net: characterisation tests that record what the code does now. Then comes an incremental migration. Each step is small enough to review and revert. For the team, that means tests on today's delivery fees first. A one-big-rewrite plan that adds a new contract and skips review because tests pass combines three violations.

In the exam

This sub-skill is the second-heaviest in the domain. It tests whether your engineering discipline is real. It expects protocol fluency more than framework familiarity. It also expects you to know which human disciplines do not become less strict. Wrong options are shortcuts that look reasonable: the synchronous handler, the unbounded fan-out, the console-edited prompt, the review exemption. The exam directly names the production console edit as an anti-pattern. The one-big-rewrite recurs as a wrong option because it combines three violations.

Exam Traps

Exam Trap

Retrying immediately in a tight loop to clear a large number of 429 errors

Instant retries on top of an over-limit burst add traffic to the condition that caused the failures. Bound the fan-out with a semaphore or worker pool, and retry with jittered exponential backoff that honours retry-after.

Exam Trap

Lowering max_tokens to relieve rate-limit pressure or cut costs on a struggling fan-out

OTPM counts the tokens generated, so lowering max_tokens is no rate-limit lever, and the exam guide's sample answer rejects it as a cost fix. It only risks truncating output. Bound concurrency within the documented RPM, ITPM and OTPM headroom instead.

Exam Trap

Skipping human review of AI-generated changes because the tests pass

Passing checks confirm the tests that exist still pass; they say nothing about whether the behavioural change is the one anyone intended. Review catches a subtly wrong generated refactor, so the review standard stays the same whoever wrote the diff.

Exam Trap

Rewriting an under-tested module in one atomic pass with a contract change bundled in

A single switch to the new code with no tests in place leaves no small step to roll back, and a bundled contract change breaks every consumer at once. Write tests that record current behaviour first, then migrate incrementally and version the contract change.

Practice Scenario

A postal service must normalise 5,000 address records against the Messages API before the 06:00 sorting run, roughly eight hours away. An engineer's first attempt fired all 5,000 requests concurrently; the run failed with more and more 429 rate_limit_error responses and got slower with each retry pass. What is the correct rework?

Build Exercise

Build Exercise

Build the Fan-Out Three Ways

Difficulty
40 minutes

What you'll learn

  • How serial, unbounded and bounded fan-outs behave against a real rate limit
  • How SDK default retries interact with hand-rolled backoff
  • How prompts and schemas use the same review pipeline as code
  • How to argue the realtime-versus-batch decision from an actual deadline
  1. Write a serial baseline: loop over 50 independent documents, one Messages call each, and record the wall-clock time

    Why: The serial loop is the baseline every concurrency argument compares against, and its slowness is the reason the fan-out exists.

    You should see: A completion time that is roughly 50 times a single call.

  2. Rework it with async I/O and a semaphore bounding concurrency at 5; record the time and confirm no 429s in the logs

    Why: The bounded pool is the right pattern, and seeing it finish fast without triggering the limiter is the point of the whole sub-skill.

    You should see: A large speed-up over serial with zero rate_limit_error responses.

  3. Remove the bound deliberately, fan out all 50 at once, and observe what the SDK default retries do; then add jittered backoff honouring retry-after and compare

    Why: Watching the burst of rate-limit errors and its fix on a small scale teaches both the failure mode and why hand-rolled retry loops must account for the SDK retrying twice on its own.

    You should see: 429s under the unbounded fan-out, then a clean recovery once backoff and the bound return.

  4. Put the prompt and the JSON output schema in the repository next to the code, and make one prompt change via a branch and pull request with a reviewer

    Why: Prompts-as-source-code only becomes real when a prompt change actually completes the pipeline once.

    You should see: A merged PR whose diff is a prompt edit, with history and a rollback path.

  5. Write a paragraph in the PR description stating why this workload stayed realtime rather than batch, referencing your actual deadline

    Why: The reasoning is the exam skill: the deadline decides, and writing the argument down proves you can make it under exam conditions.

    You should see: Two or three sentences naming the deadline, the completion-window risk, and the bounded-concurrency choice.

Sources