A model call is a network call
Slow, metered, and allowed to fail. Wrap it the way you would wrap any third party: a timeout, a bounded retry, a budget, and a defined answer for when it does not come back.
What the call actually is
Strip away the interesting part and a model call is an HTTP request to somebody else's server. Everything you already know about third-party dependencies applies, and the parts people forget are exactly the parts they forget for payment gateways and geocoding APIs:
- It is slow, in seconds rather than milliseconds, and the variance is large.
- It costs money per call, roughly in proportion to the text going in and coming out.
- It fails, with timeouts, rate limits, overload responses, and transport errors.
- It returns text, which is not the same thing as data. That is the next lesson.
The exact client library differs between vendors and changes every few months, so the shape below is deliberately generic. What does not change is the wrapping.
answer = client.complete(prompt) # one network call: slow, metered, failure-prone
A line like that in the middle of a request handler, with nothing around it, is the same defect as an unwrapped call to a payment provider. It happens to be written by the person most excited about the feature, which is why it so often ships.
Four wrappings, always
A timeout. Pick one and pass it. Every client library takes one, and the default is usually far longer than you want. A timeout is how you convert an unbounded wait into a failure you can handle.
A bounded retry. Transient failures are common enough that one retry is worth it and unbounded retries are how you take down a recovering provider. Three attempts is a reasonable default, with a growing pause between them so a hundred clients do not all return at the same instant.
def call_with_retry(client, prompt, attempts=3):
for attempt in range(attempts):
try:
return client.complete(prompt)
except TransientError:
if attempt == attempts - 1:
raise
sleep(2 ** attempt) # 1s, then 2s: give the provider room
A budget. Calls cost money and a loop over user-supplied input is a loop over your invoice. Count the calls and stop at a cap you chose, rather than at the one your finance team discovers.
A defined answer for failure. When the call does not come back, what does your function return? "It raises" is a legitimate answer. So is a cached previous result, a simpler non-model fallback, or a sentinel the caller can render. What is not legitimate is not having decided, because then the answer is whatever exception happens to escape.
Sort each failure from a model provider by whether retrying it is worth doing.
Retrying is only safe when repeating is safe
A retry sends the same request again, which is fine when the call only reads. It is not fine when the call causes something.
If the model call is one step in a flow that also charges a card, sends an email, or writes a row, ask what happens when step three fails after step two succeeded. The answer is usually that the retry must not repeat step two, which means the write needs an idempotency key or the retry has to be scoped to just the model call. This is the same reasoning you would apply to any external write, and generated code almost never includes it, because nothing in the prompt mentioned it.
Testing it without the network
You cannot write a reliable test against a live provider: it is slow, it costs money, and it will not fail on demand. So you do what you would do for any other dependency and hand your code a stand-in that fails exactly when you tell it to.
class FlakyModel:
"""A stand-in client. Each entry in script is what the next call does."""
def __init__(self, script):
self.script = list(script)
self.calls = 0
def complete(self, prompt):
self.calls += 1
step = self.script.pop(0)
if step == "error":
raise RuntimeError("upstream error")
return step
Now "the second attempt succeeds" is a test, and so is "every attempt fails", and both run in a millisecond. The counter matters as much as the responses: an implementation that retries forever and one that stops at three both pass a script of two errors, and only the call count separates them.
Interview nuance: "how would you test this?" applied to a model call is a question that separates people quickly. The weak answer describes checking the output text. The strong answer is that the provider gets replaced by a stand-in, and the tests are about attempt counts, timeouts, and what the function returns when the model never answers. That is ordinary dependency discipline, and the fact that the dependency is a model changes none of it.
class FlakyModel:
def __init__(self, script):
self.script = list(script)
self.calls = 0
def complete(self, prompt):
self.calls += 1
step = self.script.pop(0)
if step == "error":
raise RuntimeError("upstream error")
return step
model = FlakyModel(["error", "error", "summary text"])
for attempt in range(3):
try:
print("attempt", attempt + 1, "->", model.complete("summarise"))
break
except RuntimeError as exc:
print("attempt", attempt + 1, "->", exc)Apply
Your turn
The task this lesson builds to.
Write a function call_with_retry(script) that builds a FlakyModel(script), asks it to
complete the prompt "summarise", and returns the first response it gets back.
The rules: make at most 3 attempts in total. If an attempt raises, try again. If all 3 attempts
raise, return the string "unavailable" rather than letting the exception escape. Never make a
fourth call, even when the script still has entries left in it.
FlakyModel is in the starter: each entry of script says what the next call does, where
"error" raises and anything else is returned as the response text. Keep call_with_retry as the
last function in the file.
3 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Your support tool summarises each ticket in a queue with a model. Last week a provider incident turned a nightly batch into a retry storm that ran for six hours and produced an invoice nobody had approved, so every batch now has to run under a hard cap on how many calls it may make.
Write a function process_batch(scripts, call_budget) that returns a list with one result per
entry in scripts, in the same order.
The rules, applied to each entry in turn:
- If the number of calls already made has reached
call_budget, record"[skipped]"and move on without calling at all. - Otherwise attempt the call, retrying up to 3 attempts for that entry, and never make a call
once the number of calls made has reached
call_budget. - Record the first response you get. If every attempt you were able to make raised, record
"[unavailable]".
FlakyModel is in the starter and each entry of scripts is one script for it. Keep
process_batch as the last function in the file.
3 hints and 4 automated checks are waiting in the workspace.