Where the Client Gets Its Configuration
The hand-rolled httpx loop from Your First LLM API Call works, but it already needs three things you have not written yet: credential resolution, retries, and a timeout policy. The SDK ships all three, and the first decision is where its configuration comes from.
from openai import OpenAI
client = OpenAI( # api_key defaults to $OPENAI_API_KEY
timeout=30.0, # budget for one attempt, including reads
max_retries=2, # attempts the SDK makes before raising
)
reply = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Classify: my card was charged twice"}],
)
print(reply.choices[0].message.content)
Input: Classify: my card was charged twice
Output: {"category": "billing", "urgency": "medium"}
Credential resolution is a two-step rule: an explicit api_key argument wins, otherwise the client reads the OPENAI_API_KEY environment variable. If neither is present, the constructor raises immediately with a message naming both options — you find out at startup, not on the first user request. Pass api_key explicitly only in tests that load a fixture key; production code should depend on the environment so no key can be committed by accident.
Three configuration points matter beyond the key:
- Build one client per process. The client wraps a connection pool. Constructing it inside a request handler throws away keep-alive connections and pays a fresh TLS handshake every call, which shows up as added latency — Latency Profiling shows how to catch it.
base_urlpoints the same client at a gateway, proxy, or self-hosted compatible endpoint without touching call sites.default_headers, organization, and project come from client options or theOPENAI_ORG_ID/OPENAI_PROJECT_IDenvironment variables when one deployment must bill a specific project.
The Retry Loop You No Longer Have to Write
A 429 at 3 a.m. should not wake anyone up. The SDK's retry helper answers three questions on its own: which responses are worth retrying, how long to wait, and when to give up.
attempt 1 -> 429 sleep 0.8s (honors retry-after when the provider sends one)
attempt 2 -> 429 sleep 1.7s (exponential backoff with jitter)
attempt 3 -> 200 return default max_retries=2, so three attempts total
The distinction that saves you from a bad afternoon is which statuses are retryable:
| Status | SDK behavior | Why |
|---|---|---|
| 429 | Retried with backoff | Rate or quota limit clears on its own |
| 500, 502, 503 | Retried with backoff | Provider incident, usually brief |
| 408, 409 | Retried with backoff | Timeout or transient conflict |
| 400, 401, 404 | Raises APIStatusError immediately |
Your payload, your key, or a bad model name — retrying repeats the same mistake |
So the loop in the previous topic collapses to one line of configuration. Override the budget per client, or for a single call when one path can afford to wait longer:
patient = client.with_options(max_retries=5)
reply = patient.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Summarize the incident report"}],
)
When to turn retries down. Retries are not free: each attempt re-sends the full prompt, so a retried call bills the prompt tokens twice, and three attempts on a 10-second generation can blow a 2-second interactive budget. Set max_retries=0 when you are writing your own recovery loop, when the call streams (a stream that dies mid-answer cannot be replayed — see Streaming Responses), or when the SDK's total worst-case wait exceeds the latency target you promised the user. The full policy — circuit breakers, jitter formulas, budget caps — lives in Rate Limiting and Retries.
JSON You Can Validate on Arrival
Free-text output forces you to strip markdown fences, hunt for the first brace, and hope the model did not add a commentary sentence. Parsing failures like that are the most common source of bugs in LLM features, and the SDK can eliminate most of them.
Three output modes, increasing in strictness:
| Mode | You send | What you get back |
|---|---|---|
| Default | Nothing extra | Free text; you find and parse the JSON yourself |
| JSON mode | response_format={"type": "json_object"} |
Parses as JSON, but any shape the model likes |
| Structured output | response_format={"type": "json_schema", "json_schema": {..., "strict": true}} |
Keys, types, and required fields match your schema |
schema = {
"name": "ticket_classification",
"strict": True,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "bug", "question"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
},
"required": ["category", "urgency"],
"additionalProperties": False,
},
}
reply = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Ticket: charges are duplicated on invoice 88"}],
response_format={"type": "json_schema", "json_schema": schema},
)
print(reply.choices[0].message.content)
Input: Ticket: charges are duplicated on invoice 88
Output: {"category": "billing", "urgency": "medium"} — a string you can pass straight to json.loads, then to a validator.
A strict schema turns "did the model feel like producing JSON today" into a contract: missing keys and wrong types are rejected rather than discovered downstream. The additionalProperties: False line and the required list are what make that contract airtight; omit them and the model may slip in extra fields you never see. The SDK's client.chat.completions.parse helper wraps this pattern with a Pydantic model so the schema is derived from your type instead of hand-written — JSON and Pydantic Basics walks through it.
Two limits worth knowing. Structured output constrains the shape, not the truth: a schema with an enum of urgency levels cannot stop the model from picking the wrong one, so grounding and evaluation still do that job — Schema-Constrained Generation covers the tradeoffs, and JSON mode in JSON Mode Outputs is the lighter option when you only need parseable text.
Turning Usage Into an Invoice
A summarization feature that costs nothing visible until the monthly bill arrives is a feature with an expiration date. Every non-streaming response carries usage, and reading it at the call site is the whole of cost attribution.
reply = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Summarize ticket 88"}],
)
u = reply.usage
record_event(
tenant="acme",
feature="ticket-summary",
input_tokens=u.prompt_tokens,
output_tokens=u.completion_tokens,
)
Two numbers, two prices: prompt_tokens cover the instructions and history you resupply on every turn, while completion_tokens cover what the model wrote — and output tokens usually cost several times more per token than input. Re-sending a 4,000-token conversation to generate a 40-token reply means the prompt dominates the bill, which is why trimming history pays for itself. Token Usage Metering builds the aggregation layer on top of this event, and Token Cost Estimation turns counts into currency before you ship.
Streaming changes where usage lives. With stream=True the usage block only appears if you ask for it with stream_options={"include_usage": True}, and it arrives on the final chunk — which has an empty choices array. Code written against the non-streaming shape will raise IndexError on that chunk, so guard it:
last = None
for chunk in stream:
if chunk.choices: # final usage chunk has choices == []
last = chunk
usage = chunk.usage # read from the terminal chunk, not from choices
Log the counts alongside a trace id rather than the raw completion text, and you keep PII out of the billing pipeline while still being able to answer "why did this tenant's spend triple" — Trace LLM Calls shows the layout.
Quiz
1. You construct `OpenAI()` with no arguments in a fresh environment. Where does the API key come from?
2. A call returns 429 twice and then succeeds on the third attempt. What did the SDK do, and what happens if the first response had been a 400?
3. Your service parses the model's reply with `json.loads` and crashes when the model adds a sentence before the object. Which request change addresses the root cause?
4. You need to bill each tenant for the tokens they consumed. Where does that data come from?
Flashcards
Question
How does the OpenAI client resolve its API key?
Click to reveal answer
Answer
An explicit `api_key` argument wins; otherwise it reads `OPENAI_API_KEY` from the environment and raises at construction if neither is set.
Question
Which HTTP statuses does the SDK retry automatically, and which does it raise on?
Click to reveal answer
Answer
Retries with backoff: 429, 5xx, 408, 409. Raises immediately: 400, 401, 404 — request or credential errors that retrying cannot fix.
Question
What does `response_format` with a strict `json_schema` guarantee?
Click to reveal answer
Answer
The reply parses as JSON and its keys, types, enums, and required fields match the schema; shape is enforced, semantic correctness is not.
Question
Which response fields feed cost attribution?
Click to reveal answer
Answer
`usage.prompt_tokens` and `usage.completion_tokens`, stored per request with tenant and feature ids; output tokens are priced higher than input tokens.
Question
Why should one OpenAI client be built per process rather than per request?
Click to reveal answer
Answer
The client owns a connection pool; rebuilding it discards keep-alive state and adds a TLS handshake per call, which shows up as latency.
Revision Notes
Key Takeaways
- 1. Credential resolution is explicit api_key, then OPENAI_API_KEY — nothing else, and failure happens at construction
- 2. The SDK's retry helper covers 429 and transient 5xx with backoff; 400 and 401 are raised immediately
- 3. A strict json_schema response_format converts parsing from regex-guessing into a contract, but still cannot guarantee the content is true
- 4. prompt_tokens and completion_tokens are the billing primitive, and streaming moves them to the final chunk
- 5. One pooled client per process; retries and timeouts are latency budget you spend deliberately
Interview Tips
- • Expect to be asked which errors are retryable and why — answer 429/5xx with backoff, 400/401 never, and mention honoring retry-after
- • Be ready to compare JSON mode with structured output and say what `strict: true` adds (shape enforcement, not truth)
- • A common prompt: 'how would you know what this call cost?' — cite `usage`, the input/output price difference, and per-request attribution
- • Have an opinion on timeouts: give the SDK a budget, cap retries when the UI has a latency target, and explain that retries double prompt spend
Cheat Sheet
OpenAI SDK Essentials — Cheat Sheet
Client construction
client = OpenAI(timeout=30.0, max_retries=2) # key from $OPENAI_API_KEY
patient = client.with_options(max_retries=5) # per-call override
- Build once per process, reuse everywhere
- Override
base_urlfor gateways,OPENAI_ORG_ID/OPENAI_PROJECT_IDfor billing scope
Retry decision rule
| Status | Action |
|---|---|
| 429 / 5xx / 408 / 409 | SDK retries, exponential backoff + jitter |
| 400 / 401 / 404 | Raises APIStatusError — fix payload or key |
| any, on a streaming call | Set max_retries=0 and handle recovery yourself |
Structured output ladder
- Default → free text, you parse
{"type": "json_object"}→ parseable, shape unconstrained{"type": "json_schema", strict: true}→ schema-enforced; requiresrequiredlist +additionalProperties: falseclient.chat.completions.parse(response_format=Model)→ Pydantic-derived schema
Usage capture
u = reply.usage # .prompt_tokens / .completion_tokens
# streaming: stream_options={"include_usage": True}, read chunk.usage on the last chunk
- Record per request with tenant + feature id
- Guard streaming loops with
if chunk.choices— the usage chunk has none
Continue Learning
Before You Start
- No prerequisites listed.