Your script worked yesterday. Today it prints twenty lines of red text and stops. Somewhere in that wall of output is one sentence that names the problem exactly, and the rest is noise you can safely skip on the first pass. Most beginners never learn that, so they paste the whole thing into a search engine, try three unrelated suggestions from three different years, and end up with a broken environment on top of the original error.
This guide replaces that with a method. You will learn to read a traceback in the right order, rebuild the failure in a five-line file, decide which of four layers broke, and fix that layer with a change you can explain. Then you will add a guard so the same failure announces itself clearly instead of returning as a mystery next month. It takes about forty minutes to work through, and it applies to every error in this series, not just the ones we name here.
Debugging feels different from writing code because it is a search, not a construction. The skill is not memorising errors. It is narrowing down where the fault lives until only one place is left, and doing that in a fixed order so you never repeat work.
The debugging loop in four steps
Every fix in this section follows the same loop. Read the error and name the exception type. Reproduce it in the smallest possible file. Decide which layer of your setup is broken. Fix that layer and leave a guard behind. If the error survives, you go around again with what you learned, which is why the fourth step points back at the first.
The loop matters because it stops you changing three things at once. When you edit your prompt, upgrade a package, and switch networks in the same minute, a passing script tells you nothing about which change helped. One hypothesis, one change, one test.
Prerequisites
You need Python 3.10 or newer, a virtual environment (an isolated folder holding one project's packages), and the OpenAI SDK. If any of that is missing, Create a Python Virtual Environment for AI walks through it from scratch.
Check what you have before you debug anything else. A surprising share of "AI errors" are really "wrong Python" errors:
python --version
python -c "import sys; print(sys.executable)"
The second command prints the full path of the interpreter that will run your scripts. Note it down. Half of this guide comes back to that one path.
Create and activate an environment, then install the tools used throughout:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install "openai>=1.40" "httpx>=0.27" "python-dotenv>=1.0"
python -m pip freeze > requirements.txt
Use python -m pip rather than a bare pip. The python -m form guarantees the package lands in the interpreter you just checked, which removes the single most common cause of import failures.
Your editor has an opinion about interpreters too, and it is often a different one from your terminal. Editors let you pick the interpreter for a project; if the "Run" button fails while the terminal succeeds, the two are pointing at different Pythons. Add import sys; print(sys.executable) as the first line of the failing script, run it both ways, and compare the two paths before you change anything else.
Store your key in a .env file beside your script:
OPENAI_API_KEY=sk-your-key-here
Then add that file to .gitignore before you write another line:
echo ".env" >> .gitignore
A key committed to a public repository is scraped and spent by strangers, often within the hour. This one command is not optional.
Step 1 — Read the traceback from the bottom up
A traceback is Python's account of how it got to the failure. It reads like a receipt printed in reverse: the oldest call at the top, the actual explosion at the bottom. Beginners read it top-down, hit a file path inside some library they have never opened, and conclude the library is broken. It almost never is.
Here is a real one, lightly trimmed:
Traceback (most recent call last):
File "/home/you/projects/summarise.py", line 12, in <module>
response = client.chat.completions.create(
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/you/projects/.venv/lib/python3.12/site-packages/openai/_utils/_utils.py", line 275, in wrapper
return func(*args, **kwargs)
^^^^^^^^^^^^^^^^^^^^^
openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Incorrect API key provided'}}
Read it in this order:
- The last line. It has two halves separated by a colon. Left of the colon is the exception type — here
openai.AuthenticationError. Right of it is the message. The type tells you the category of failure; the message tells you the specifics. - The last line of your own code. Scan upwards for the first file path that belongs to you, not to
site-packages. Here it issummarise.py, line 12. That is where you called into the library, and that is where your fix will go. - Everything else, only if needed. The frames inside the library matter when you are debugging the library. For an application error they are scenery.
Name the exception type out loud before you touch anything. ModuleNotFoundError and AuthenticationError live in completely different layers, and confusing them costs an hour. If the traceback ends with a phrase you have not seen, Read a Python Traceback in Five Minutes takes one apart line by line, including chained tracebacks that print "The above exception was the direct cause of the following exception".
Two details often get missed. First, the caret markers (^^^^) under a line point at the exact expression that failed, which is invaluable when one line holds three function calls. Second, a chained traceback shows two failures: the one the library met and the one it re-raised. The original cause is usually in the upper block, so read both last lines.
It also helps to separate errors from warnings. A DeprecationWarning or an SSL notice printed in yellow does not stop your program; it is advice about something that will matter later. If your script printed a warning and then produced no output, the warning is not the cause. Look for a line that begins with Traceback (most recent call last): — no traceback means no crash, and your problem is logic, not an exception.
Two exception types deserve special mention because they look like AI errors and are not. An IndexError on response.choices[0] means the reply came back with an empty list of choices, so the request succeeded but returned nothing usable. An AttributeError such as 'NoneType' object has no attribute 'strip' almost always means a variable you expected to hold text is None, often because a previous step returned nothing and you did not check. Both belong to your own code, not to the service.
Step 2 — Reproduce the error in five lines
Once you can name the exception, shrink the problem. Copy the smallest amount of code that still fails into a new file called repro.py. No command-line arguments, no loops over a spreadsheet, no logging setup. Five lines is a good target.
import os
from dotenv import load_dotenv
from openai import OpenAI
load_dotenv() # .env is listed in .gitignore
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
print(client.chat.completions.create(
model="gpt-4o-mini",
max_tokens=5,
messages=[{"role": "user", "content": "ping"}],
).choices[0].message.content)
Run it with python repro.py. One of three things happens, and each is informative.
If repro.py fails the same way, the fault is in your environment or your call, not in your larger program. You now have a test that runs in two seconds instead of two minutes, so you can try five fixes in the time one used to take.
If repro.py succeeds, the fault is in the code around the call — the data you feed it, a variable that is empty, a loop that runs past the end of your list. Add your real code back a piece at a time until it breaks again. The last piece you added is the culprit.
If repro.py fails differently, you have found a second, hidden problem. Fix this one first; it is smaller.
Keep the file. repro.py becomes your standing smoke test, the thing you run after upgrading a package or moving to a new machine. Costing a handful of tokens, it is the cheapest insurance in your project — and if you are watching spend, Managing AI API Costs and Tokens shows how to keep even test traffic on a budget.
Some failures depend on the data rather than the code: a script that processes four hundred rows and dies on one of them. Shrinking the code will not reproduce that, so shrink the input instead. Run the first half of your rows; if it passes, the offending row is in the second half. Halve again. Eight rounds of this find the exact row in a four-hundred-row file, and you rarely need more than a minute.
import json
rows = json.load(open("input.json"))
print(len(rows))
for i, row in enumerate(rows[:200]): # halve the range until it breaks
print(i, len(str(row))) # then look at the last row printed
When you find the row, look at what is unusual about it: an empty field, a stray emoji, text ten times longer than the rest. That single row usually explains the error better than any amount of staring at the code.
Step 3 — Locate the layer that broke
Everything between your keyboard and the model sits in one of four layers, and every error belongs to exactly one of them. Naming the layer converts a vague "it does not work" into a short list of things to check.
Layer 1, the interpreter. Which python is actually running your file, and is your virtual environment active? Symptoms: a package you know you installed cannot be found, or two terminals behave differently.
Layer 2, the packages. Is the SDK installed in that interpreter, at a version whose functions match your code? Symptoms: ModuleNotFoundError, ImportError, or an AttributeError about a client method that the docs say exists.
Layer 3, the network and TLS. Can your machine reach the provider, and does it trust the certificate presented? TLS is the encryption layer behind HTTPS, and it fails loudly with certificate errors on locked-down or corporate machines. Symptoms: certificate verification failures, timeouts, connection resets.
Layer 4, the API. Did the request arrive and get rejected? Symptoms: 401, 429, 400 responses, or a reply whose shape is not what you expected. The request travelled fine; the service said no.
The decision tree below turns the exception name from step 1 into a layer. Work the questions in order, top to bottom, and stop at the first match.
Each layer has a one-command test you can run before changing anything:
| Layer | Question to answer | Command that answers it |
|---|---|---|
| 1. Interpreter | Which Python is running? | python -c "import sys; print(sys.executable)" |
| 2. Packages | Is the SDK importable here? | python -c "import openai; print(openai.__version__)" |
| 3. Network + TLS | Can I reach the host at all? | python -c "import httpx; print(httpx.get('https://api.openai.com/v1').status_code)" |
| 4. API | Is the key present and accepted? | Run repro.py from step 2 |
One more thing changes layer 3 without any change to your code: the network you are sitting on. Office networks, university networks, and VPNs frequently inspect encrypted traffic, which replaces the provider's certificate with their own and makes Python refuse the connection. If your script works at home and fails at work, you have not found a bug in your code — you have found a proxy. Test the same script on a phone hotspot to confirm it in thirty seconds.
Run the layer tests in order and stop at the first failure. The network check returning 401 is a pass, not a fail: a 401 means you reached the service and it answered, which is precisely what layer 3 is being asked. That distinction — reaching the service versus being accepted by it — is the one most beginners blur, and it is exactly what separates Fix Connection and Timeout Errors with AI APIs from Fix the 401 Unauthorized Error in OpenAI Python.
Step 4 — Apply the fix and add a guard
Fix the layer you identified, then run repro.py again. If it passes, run your real script. If that passes too, you are two-thirds done: the remaining third is making sure this failure cannot come back silently.
A guard is a small piece of code that turns an unhandled crash into either a clear message or an automatic recovery. Which one you want depends on whether the error is temporary or permanent. Temporary errors — timeouts, dropped connections, rate limits — clear on their own, so a retry is right. Permanent errors — a missing module, a rejected key, a malformed request — never clear, so retrying just wastes minutes and tokens. Catch those and explain them.
import os
import time
from dotenv import load_dotenv
from openai import (
OpenAI,
APIConnectionError,
APITimeoutError,
AuthenticationError,
BadRequestError,
RateLimitError,
)
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"), timeout=30.0)
def ask(prompt: str, attempts: int = 3) -> str:
"""Send one prompt, retrying only the errors that are worth retrying."""
for attempt in range(1, attempts + 1):
try:
reply = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return reply.choices[0].message.content
except (APITimeoutError, APIConnectionError, RateLimitError) as exc:
wait = 2 ** attempt # 2s, then 4s, then 8s
print(f"Layer 3/4 hiccup ({type(exc).__name__}); retrying in {wait}s")
time.sleep(wait)
except AuthenticationError as exc:
raise SystemExit("Layer 4: key rejected. Check OPENAI_API_KEY in .env.") from exc
except BadRequestError as exc:
raise SystemExit(f"Layer 4: the request itself is invalid — {exc}") from exc
raise SystemExit(f"Gave up after {attempts} attempts. Check your network and try again.")
if __name__ == "__main__":
print(ask("Say hello in five words."))
Three choices in that code are worth copying into your own projects. The retry delay doubles each time — two seconds, then four, then eight — which is called exponential backoff and is the standard way to be polite to a busy service. The permanent errors raise SystemExit with a message that names the layer, so future-you reads one sentence instead of a traceback. And from exc keeps the original error attached, so the full detail is still there when you need it.
Note also timeout=30.0 on the client. Without an explicit timeout a stalled connection can hang your script indefinitely, which looks like a frozen program rather than an error. Setting one converts silence into an APITimeoutError you can actually catch.
The difference a guard makes is not subtle, and it shows up on the day something breaks while you are not watching.
One warning about guards: never write a bare except: or except Exception: around an API call and continue as if nothing happened. That hides typos, empty responses, and bugs in your own logic, and it produces the worst class of problem — a script that finishes successfully while doing nothing. Catch named exceptions only, and let anything you did not anticipate crash loudly.
When the error only happens sometimes
Intermittent failures break the loop, because step 2 assumes you can reproduce the problem on demand. When one run in twenty fails, you need evidence instead of reproduction: record enough about each call that the failure explains itself after the fact.
Write a line per call to a log file with the timestamp, the exception name if there was one, and the size of the input. That is usually all it takes to spot the pattern — failures bunched at the same time of day point at rate limits, failures on the longest inputs point at timeouts or context limits, failures spread evenly point at flaky networking.
import datetime
import traceback
def log_call(prompt: str, error: Exception | None = None) -> None:
stamp = datetime.datetime.now().isoformat(timespec="seconds")
kind = type(error).__name__ if error else "ok"
with open("calls.log", "a", encoding="utf-8") as fh:
fh.write(f"{stamp}\t{kind}\t{len(prompt)} chars\n")
if error:
fh.write(traceback.format_exc())
Call log_call(prompt) after a success and log_call(prompt, exc) inside your except block. Note that traceback.format_exc() writes the full traceback into the file, so the detail you would otherwise lose in a scrolled-away terminal is preserved. Add calls.log to .gitignore as well — prompts often contain customer data you do not want in a repository.
One habit prevents most intermittent mysteries: pin your versions. Run python -m pip freeze > requirements.txt after every install, and a package that silently upgraded itself can no longer be the invisible cause of "it worked yesterday".
Parameter reference
These are the exception classes you will meet most often, what each one really means, and which layer it belongs to. ModuleNotFoundError, ImportError, and JSONDecodeError are part of Python itself; the rest come from the OpenAI SDK and are importable straight from openai.
| Exception | Layer | What it actually means | First thing to check |
|---|---|---|---|
ModuleNotFoundError | 2 | This interpreter has no package by that name | sys.executable vs where pip installed |
ImportError | 2 | The package exists but the name inside it does not | Installed version vs the code you copied |
AuthenticationError | 4 | Request arrived; the key was rejected (401) | Key loaded, not truncated, still active |
RateLimitError | 4 | Too many requests or no quota left (429) | Request rate, and the billing dashboard |
BadRequestError | 4 | The request is malformed or too long (400) | Model name, parameters, prompt size |
APIConnectionError | 3 | The request never reached the provider | DNS, proxy, firewall, certificates |
APITimeoutError | 3 | No reply arrived inside the time limit | Connection speed, timeout= value |
JSONDecodeError | 4 | Text you tried to parse was not valid JSON | Print the raw string before parsing |
Two relationships save time. APITimeoutError is a subclass of APIConnectionError, so catching the connection error catches both — list the timeout first if you want to treat them differently. And a certificate failure surfaces as an APIConnectionError whose message mentions CERTIFICATE_VERIFY_FAILED, which is why the layer 3 test in the table above uses httpx directly: it shows you the underlying error instead of the SDK's wrapper.
Every layer 4 exception is also an object carrying the raw HTTP response, and that response holds more than the printed message. The status code separates the four rejection types at a glance, and the request id identifies the exact call if you ever need to ask the provider about it:
from openai import APIStatusError
try:
reply = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "ping"}],
)
except APIStatusError as exc:
print("status:", exc.status_code) # 401, 429, 400 ...
print("request id:", exc.response.headers.get("x-request-id"))
print("body:", exc.response.text[:400]) # the provider's own words
raise
APIStatusError is the parent of AuthenticationError, RateLimitError, and BadRequestError, so this one block catches every rejection the service can send. Put the specific classes first and this general one last, exactly as you would order questions from narrow to broad. The raise at the end re-raises the original error after printing, so you get the extra detail without swallowing the failure.
Troubleshooting
ModuleNotFoundError: No module named 'openai'— the interpreter running your file is not the one you installed into. Runpython -m pip install openaiin the same shell where the script fails, or activate your environment first. Full walkthrough: Fix ModuleNotFoundError: No Module Named openai.ssl.SSLCertVerificationError: certificate verify failed— Python cannot verify the provider's certificate, usually because the system certificate bundle is missing or a corporate proxy re-signs traffic. On macOS the fix is often running the bundledInstall Certificates.command. Never disable verification to make it go away; see Fix SSL: CERTIFICATE_VERIFY_FAILED in Python.openai.APITimeoutError: Request timed out— the connection opened but no reply arrived in time. Set an explicittimeout=on the client, retry with backoff, and shorten very large prompts, which take longer to process.openai.RateLimitError: Rate limit reached— you sent requests faster than your account allows, or your quota is exhausted. Add a delay between calls and check the billing page; the two causes look identical in the message. Step-by-step: Fix the 429 Rate-Limit Error in Python.json.decoder.JSONDecodeError: Expecting value: line 1 column 1— you askedjson.loadsto parse text that is not JSON, often because the model wrapped its answer in prose or code fences. Print the raw string first; Fix JSONDecodeError with AI API Responses in Python covers the reliable pattern.openai.BadRequestErrormentioning context length — your prompt plus the requested reply exceeds the model's limit. Trim or split the input rather than switching models blindly: Fix the Context-Length-Exceeded Error in Python.
Worked example
diagnose.py walks the four layers in order and prints a verdict at each stage, stopping at the first failure so you are never guessing which one broke. Save it next to your project and run it whenever something stops working.
"""diagnose.py — check the four layers of an AI script and stop at the first failure."""
import os
import sys
from dotenv import load_dotenv
def stage(name: str, ok: bool, detail: str) -> bool:
print(f"[{'PASS' if ok else 'FAIL'}] {name}: {detail}")
return ok
def main() -> int:
# Layer 1 — which interpreter is running this file?
in_venv = sys.prefix != sys.base_prefix
stage("interpreter", True, f"{sys.executable} (Python {sys.version.split()[0]})")
if not stage("virtual env", in_venv, "active" if in_venv else "not active — activate .venv"):
return 1
# Layer 2 — is the SDK importable from *this* interpreter?
try:
import openai
except ModuleNotFoundError as exc:
stage("openai import", False, f"{exc}; run: {sys.executable} -m pip install openai")
return 1
stage("openai import", True, f"version {openai.__version__}")
# Layer 4a — is the key actually loaded from .env?
load_dotenv()
key = os.getenv("OPENAI_API_KEY", "")
if not stage("api key", bool(key), f"{len(key)} chars loaded" if key else "OPENAI_API_KEY is empty"):
return 1
# Layers 3 and 4 — one cheap call that proves the whole path works
client = openai.OpenAI(api_key=key, timeout=20.0, max_retries=0)
try:
reply = client.chat.completions.create(
model="gpt-4o-mini",
max_tokens=5,
messages=[{"role": "user", "content": "ping"}],
)
except openai.AuthenticationError:
stage("api call", False, "layer 4 — the key was rejected by the service")
return 1
except openai.APIConnectionError as exc:
stage("api call", False, f"layer 3 — the request never arrived: {exc}")
return 1
except openai.OpenAIError as exc:
stage("api call", False, f"layer 4 — {type(exc).__name__}: {exc}")
return 1
stage("api call", True, f"replied with {reply.usage.total_tokens} tokens")
print("All four layers are healthy.")
return 0
if __name__ == "__main__":
sys.exit(main())
Run it with python diagnose.py. A healthy run prints five PASS lines and ends with "All four layers are healthy." A broken run stops at the first FAIL, and that line names the layer you should be working on — which is the whole point. The call costs a handful of tokens, so you can run it as often as you like.
Two details make this more useful than it first looks. max_retries=0 disables the SDK's built-in retrying so a network fault appears immediately instead of after a silent wait. And sys.prefix != sys.base_prefix is the standard way to ask "am I inside a virtual environment", which catches the single most common cause of layer 1 and layer 2 confusion.
Where to go next
Each of the four guides in this section takes one layer and goes deep. Start with Read a Python Traceback in Five Minutes if the red text still feels unreadable, since every other fix depends on that skill. Then work through Fix ModuleNotFoundError: No Module Named openai for layers 1 and 2, and Fix SSL: CERTIFICATE_VERIFY_FAILED in Python plus Fix Connection and Timeout Errors with AI APIs for layer 3.
Layer 4 problems split between errors and economics. The error side lives in Understanding LLM APIs; the economics side, including why a retry loop can quietly triple a bill, lives in Managing AI API Costs and Tokens. Read the cost guide before you deploy anything that retries automatically.
Back to Python AI Fundamentals for Non-Developers.
Related guides
- Read a Python Traceback in Five Minutes — the reading skill every other fix in this series depends on.
- Fix ModuleNotFoundError: No Module Named openai — the interpreter and package layers, solved for good.
- Fix Connection and Timeout Errors with AI APIs — timeouts, retries, and backoff that behaves politely.
- Create a Python Virtual Environment for AI — prevent most layer 1 and 2 errors before they happen.
- Managing AI API Costs and Tokens — keep retry loops and test calls from surprising you on the bill.