What you'll learn: how to put every model call behind one small contract, why the capstone defaults to a mock, how to retry with a deadline without multiplying retries, and how to record tokens and cost per request.
At a glance
Figure 1. The provider layer: one contract, wrappers around it
How to read this: every caller sees only complete(), so the counting, the retries and the choice between a mock and a real provider can change without touching the pipeline (app/ has the meter and both providers; ResilientProvider is built in this lesson).
- All model calls go through one method,
LLMProvider.complete, so providers are swappable and testable. - The mock is a rule-based stand-in for a model. It makes the course runnable offline; it is not evidence about a real model.
- A rate limit (429) or an overload is normal traffic, not an exception. Retry a few times, with a deadline.
- Exactly one layer owns retries. The official SDK retries by default, so a wrapper must switch that off.
- One discovery request makes nine model calls, so count tokens per request, not per call.
Scenario
Composite scenario, not a real company. Lumen Industrial Supply, the fictional distributor, puts the copilot in front of its sales engineers. On Monday mornings about 30 of them ask a question within the same minute. The demo runs on the mock, and a key for a real provider arrives this week. What breaks first at 30 requests a minute, how long may a request retry, and what does one request cost? All numbers are illustrative.
What you will be able to do
- Explain what the
LLMProvidercontract hides and why the pipeline never imports a vendor SDK. - Wrap a provider with bounded retries, a server-given wait and a deadline, and prove the behavior in a test without sleeping.
- Name the three timeouts in a model call and say which one each layer enforces.
- Compute the token cost of one request from recorded usage and a placeholder price.
Prerequisites
Async, HTTP clients, timeouts and retries covers the general mechanics; Structured outputs and schema validation uses the same complete method. For why a retry has one owner and how jittered backoff works, read Reliability and scaling; for the cost model behind the widget, Cost economics.
Core ideas
One contract for every provider
| Piece | What it is | Why it exists |
|---|---|---|
Message |
role plus text | The same input for every provider |
Completion |
text plus input and output token counts | The only thing the pipeline reads back |
LLMProvider |
a protocol with one method, complete |
Swap, wrap and fake providers without changing callers |
Verified: capstone/fde-product-discovery-copilot/app/llm/base.py
@dataclass(frozen=True)
class Message:
role: Literal["system", "user", "assistant"]
content: str
@dataclass(frozen=True)
class Completion:
text: str
input_tokens: int
output_tokens: int
class LLMProvider(Protocol):
name: str
def complete(self, messages: Sequence[Message], schema: dict[str, Any] | None = None) -> Completion:
"""Return the model's text. If `schema` is given the text must be JSON matching it."""
...
The contract is a Python Protocol: any class with a name and a complete method fits, with no inheritance (think of a small Java interface). It has no streaming, no tool blocks and no stop reason; Streaming and invalid output comes back to that gap.
The mock is a stand-in, not a model
Verified: capstone/fde-product-discovery-copilot/app/llm/mock.py
class MockProvider:
name = "mock"
def complete(self, messages: Sequence[Message], schema: dict[str, Any] | None = None) -> Completion:
task = task_of(messages)
first_user = next(m.content for m in messages if m.role == "user")
if task == TASK_EXTRACT:
payload: dict[str, Any] = extract_requirements(first_user).model_dump()
else:
payload = _HANDLERS[task](json.loads(first_user))
text = json.dumps(payload, sort_keys=True)
return Completion(text, sum(estimate_tokens(m.content) for m in messages), estimate_tokens(text))
MockProvider reads the task name from the system message, runs a rule (regular expressions, a fixed script, sentence copying) and reports estimated token counts. It is deterministic, free and fast, so the test suite runs offline. It never rate-limits, times out or returns malformed JSON; a real model does all of these, so every failure in this lesson has to be simulated in tests.
Do not quote a result produced with the mock as a statement about model quality. It measures your pipeline, not a model.
What goes wrong on the way to a real provider
| Failure | What the provider documents | What your code should do |
|---|---|---|
429 rate_limit_error |
A limit was hit; the response may carry a retry-after header |
Wait that long, then retry within the deadline |
| 429 for a tier spend cap | No retry-after; keeps failing until access resumes |
Do not retry; alert a person |
5xx, 529 overloaded_error |
Internal error or temporary overload | Retry with backoff |
| 504 or no answer | The request timed out | Retry once or twice, then fail clearly |
| 400, 401, 403 | The request or the key is wrong | Never retry; fix the cause |
Per the provider's documentation (checked 2026-11-02), limits are measured in requests, input tokens and output tokens per minute, and capacity is replenished continuously (a token bucket). A burst can trip a limit even when the per-minute average is below it. Your own limits depend on your account tier; read them in the provider's console.
Figure 2. One rate-limited call, retried inside the deadline
How to read this: the retry is invisible to the caller, except that the call took seven seconds longer.
Three timeouts, three owners
| Timeout | Who enforces it | Default in this stack |
|---|---|---|
| One attempt | The HTTP client inside the SDK (timeout=) |
The Python SDK documents 10 minutes: far too long for a person waiting |
| The whole call, retries included | ResilientProvider (deadline_s) |
10 seconds, set by you |
| The whole workflow | WorkflowRunner (workflow_timeout_s) |
5 seconds, checked only between steps |
The deadline stops new attempts; it cannot interrupt one already running, so the per-attempt timeout must be shorter than the deadline. The workflow limit works the same way: the runner checks the clock before each step, so one slow call can pass it.
Decision exercise. The official Python SDK retries connection errors, 408, 409, 429 and 5xx responses twice by default. You wrap the provider in your own three-attempt retry. A teammate says "more retries, more resilience, leave both on". Choose: (a) leave both on, (b) set the SDK to max_retries=0 and keep yours, (c) keep the SDK's retries and delete yours. Justify in two sentences.
Model answer
Choose (b), or (c) if you need no deadline or per-error rules. With both on, one logical call can make 3 x 3 = 9 HTTP attempts, each with its own timeout, and nothing bounds the total. Pick (b) here, because your layer knows the deadline, the usage meter and the rule "never retry a 400"; the SDK knows none of those.
Recording usage
Verified: capstone/fde-product-discovery-copilot/app/observability/usage.py