Overview
You already know how to write software. This lesson is about the specific habits that stop working once a model is one of your dependencies.
A normal function call is deterministic: same inputs, same output, and failure arrives as an exception you catch. A model call is none of that. The same prompt gives different text, failure often looks like a perfectly formed wrong answer, and retrying is a legitimate strategy rather than an admission of defeat. Everything that feels strange about building on models traces back to that shift, so it is worth getting straight before you write any of it.
Theory
Errors are values, not exceptional events. A model call fails constantly — rate limits, timeouts, malformed output — and that is the normal path, not the exception. Return a result you can branch on rather than throwing from three layers down and hoping someone catches it.
Idempotency makes retries safe. If calling twice does the same thing as calling once, you can retry freely. If it does not, every retry is a potential duplicate charge or duplicate email. Design for it before you add the retry, not after.
Non-determinism changes what "correct" means. You cannot assert equality on a model's output. Correctness becomes a property — does it validate against the schema, does it score well against labelled examples — rather than an exact match. This is the single biggest mental shift in the course.
Partial success is a real outcome. Three of five documents embedded; the model answered but the tool call failed. Neither "worked" nor "failed" describes it, so your types need a third shape or you will silently drop the remainder.
Timeouts and budgets belong in the signature. How long may this take, how many tokens may it spend, how many retries may it make — these are inputs, not constants buried inside. Callers have different budgets, and the ones that do not say so get yours.
Visual Diagram
Deterministic call
Everything you have written until now
- Same inputs give the same output
- Failure is an exception you can catch
- Tested with one equality assertion
- Retrying changes nothing
Model call
The new dependency
- Same inputs, a distribution of outputs
- Failure is often plausible-looking wrong text
- Tested with a scored eval set
- Retrying is a real strategy
Everything strange about building on models comes from this one column shift.
Picture two boxes side by side. The left box, "deterministic path," has one line in and one line out — call it
with x, get f(x), every time, forever. The right box, "path with a model call," has one line in but a fan of
several lines out: a successful, well-formed response; a successful but subtly-wrong response; a rate-limit
error; a timeout; a malformed-JSON response that technically "succeeded" at the HTTP layer. Good AI-engineering
code is the funnel on the right-hand side of that second box — the layer that takes all five of those possible
outcomes and narrows them back down to one predictable, typed result the rest of your program can rely on:
either a validated value, or an explicit, typed failure. That funnel is not an add-on; in AI product code it is
most of the actual engineering.
Examples
- A retry wrapper with exponential backoff and jitter: the third call in a burst backs off longer than the first, and a small random jitter is added so that many clients retrying at once don't all slam the server in the same instant.
- An idempotency key threaded through a "generate and save" flow: a user clicks "regenerate" twice quickly on a slow connection; the second click reuses the same key as the first in-flight request, so the backend can detect and collapse the duplicate instead of writing two rows.
- A discriminated union return type for a function that can succeed, fail validation, or fail transport —
three distinct outcomes a caller can exhaustively
switchover instead of guessing what a caught exception might mean. - A batch processor that reports partial results: uploading 100 documents for embedding, 6 fail due to size limits, and the UI shows "94 of 100 processed, 6 need attention" instead of either silently dropping the 6 or failing the whole batch.
Code
// A discriminated union makes failure a value your caller must handle,
// not an exception they might forget to catch.
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
type CallError =
| { kind: "timeout" }
| { kind: "rate_limited"; retryAfterMs: number }
| { kind: "invalid_response"; raw: string };
/**
* Wraps an unreliable async call with a timeout, retries with exponential
* backoff + jitter, and an idempotency key so a retried "duplicate" request
* is provably safe to send twice.
*/
async function callWithRetry<T>(
fn: (idempotencyKey: string) => Promise<T>,
opts: { maxAttempts?: number; timeoutMs?: number } = {},
): Promise<Result<T, CallError>> {
const maxAttempts = opts.maxAttempts ?? 3;
const timeoutMs = opts.timeoutMs ?? 10_000;
// One key for the whole logical operation — every attempt reuses it,
// so the receiving system can de-duplicate retries safely.
const idempotencyKey = crypto.randomUUID();
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const value = await fn(idempotencyKey);
clearTimeout(timer);
return { ok: true, value };
} catch (err) {
clearTimeout(timer);
const isLastAttempt = attempt === maxAttempts;
if (controller.signal.aborted) {
if (isLastAttempt) return { ok: false, error: { kind: "timeout" } };
} else if (isLastAttempt) {
return {
ok: false,
error: { kind: "invalid_response", raw: String(err) },
};
}
// Exponential backoff with jitter: base delay doubles each attempt,
// plus up to 30% random jitter so concurrent retries don't sync up.
const baseDelay = 250 * 2 ** (attempt - 1);
const jitter = baseDelay * 0.3 * Math.random();
await new Promise((r) => setTimeout(r, baseDelay + jitter));
}
}
// Unreachable, but TypeScript needs an exhaustive return path.
return { ok: false, error: { kind: "timeout" } };
}The caller never sees a thrown exception for an expected failure mode — it gets a Result it must check, and the
kind field on CallError lets it decide per-failure-type what to do (show "try again in a moment" for a rate
limit, log and alert for a malformed response). That's the fundamental shift: failure became data.
Real Product Example
Stripe's payments API is the canonical example of idempotency done right, and it's worth studying even though
it predates the LLM era, because the exact same failure mode — "did my request actually go through, or did the
network just drop the response?" — shows up constantly in AI products (did that "generate and charge credits"
call actually deduct credits once, or twice, because the client retried after a slow response?). Stripe requires
an Idempotency-Key header on POST requests; if you send the same key twice, Stripe returns the stored result
of the first request instead of performing the operation again, even if the second request arrives seconds or
hours later. This turns "should I retry this uncertain request?" from a risky judgment call into a safe default:
you can always retry, because the key guarantees the side effect happens at most once. Any AI product that debits
credits, sends a notification, or writes a billable usage record per generation needs the same pattern — without
it, a flaky network combined with an eager retry loop will double-charge real users in production, not in theory.
Common Mistakes
Catching errors and swallowing the distinction between failure types
catch { return null; } treats a rate limit, a timeout, and a genuinely malformed response as the same thing.
They need different responses: back off and retry, show the user "still working," or log for a human to inspect.
Collapsing them into one catch-all makes debugging production incidents nearly impossible — you can't tell from
the logs what actually happened.
Retrying non-idempotent operations without a key
Wrapping a "charge card and generate report" call in a naive retry loop means a slow-but-successful first attempt plus a client-side timeout can result in two charges and two reports. If an operation has a side effect, it needs an idempotency key before it gets a retry loop — the two features are inseparable.
Writing tests that assert exact equality on non-deterministic output
expect(result).toBe("The weather is sunny today.") against a live model call will flake constantly, and the
usual "fix" — hardcoding a mocked response — means the test no longer catches real schema drift. Test the
invariant (valid JSON, correct enum value, no PII) instead of the exact string, and reserve exact-string
assertions for genuinely deterministic code paths.
Letting one slow item block an entire batch
Processing 50 items sequentially and await-ing each one means a single hung request stalls the other 49
indefinitely if there's no per-item timeout. Every item in a batch needs its own timeout and its own error
boundary — one bad row should never take down the other 49.
Senior Tips
Senior tip
Push the timeout down to the smallest unit that has one. A 30-second timeout on an entire multi-step pipeline tells you nothing about which step is slow. Timeout each individual call, and let the outer orchestration budget be the sum (or max, for parallel work) of the inner ones — you'll debug 10x faster when something is slow in production.
Senior tip
Jitter isn't optional at scale. Plain exponential backoff without randomness means that when your provider has a bad minute and drops a wave of requests, every client's retries land at the same synchronized instants, regrowing the exact spike that caused the problem. A small random jitter spreads retries out and is one line of code for a real reliability win.
Senior tip
Model your Result type so illegal states are unrepresentable. A { data?: T; error?: string } object lets
callers accidentally read data when error is set, because the type system doesn't stop them. A discriminated
union ({ ok: true; value: T } | { ok: false; error: E }) makes the compiler force a check before either field is
accessible — the type system does the disciplining, not code review.
Exercises
Exercise
Take the callWithRetry function from the Code section and identify which failure kinds should be retried and
which shouldn't (hint: is a rate_limited failure worth retrying immediately, and should a rate-limited response
be retried with the same delay logic as a timeout, or should it respect retryAfterMs?). Rewrite the retry loop
to use retryAfterMs when the error kind is rate_limited.
Exercise
Design (on paper, no code needed yet) an idempotency-key scheme for a "generate and save a document" feature: what should the key be derived from, where does it get stored, and how long should it be valid for? Write three sentences.
Exercise
Write down two properties (not exact strings) you could assert about the output of a hypothetical "summarize this article" model call in a test — properties that would still hold true across many different valid summaries.
Mini Project
Retry-safe classifier wrapper. Using the callWithRetry function from the Code section as a starting point:
- Write a fake
unreliableClassify(text: string, idempotencyKey: string)function that randomly throws about 30% of the time (simulate rate limits and timeouts with different thrown values). - Wire it through
callWithRetryand confirm, by running it many times, that it eventually succeeds far more often than a single unwrapped call would. - Add a discriminated
CallErrorcase your fake function can throw, and handle it distinctly in the caller (don't just log it — show a different message per error kind). - Write one property-based test-style assertion (not exact-string) confirming the final successful result always matches your expected shape.
This mini project is deliberately small — the point is the pattern, not the amount of code. You'll reuse this exact wrapper shape, nearly unmodified, the first time you wrap a real model call in Week 3.
Resources
- MDN — Control flow and error handling:
the baseline mechanics of
try/catch/throwthis lesson assumes you already have and builds on top of. - TypeScript Handbook: the canonical reference for the
type system used to model
Resultand discriminated unions throughout this path's code samples. - Stripe — Idempotent requests: the real-world reference implementation of the idempotency-key pattern discussed in Real Product Example, worth reading in full.