Fundamentals

Debugging Python AI Errors

Fix the errors that stop beginner AI scripts: read the traceback bottom-up, reproduce it in five lines, name the broken layer, then guard it so it stays fixed.

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.

The four-step debugging loop for Python AI scripts A cycle of four stages: read the error message from the bottom of the traceback, reproduce it in a five-line script, name which layer broke, then fix it and add a guard, looping back to the first stage if the error survives. 1. Read the error last line first 2. Reproduce it five-line script 3. Name the layer env, package, net, API 4. Fix and guard try/except or retry still broken? repeat
Work the loop clockwise and change one thing per pass; the arrow back to step one is what keeps you from stacking untested fixes on top of each other.

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:

  1. 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.
  2. The last line of your own code. Scan upwards for the first file path that belongs to you, not to site-packages. Here it is summarise.py, line 12. That is where you called into the library, and that is where your fix will go.
  3. 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.

Decision tree mapping a Python exception name to the layer that broke Five ordered questions about the exception name, each with a yes branch to the layer responsible: packages, TLS, network, the API itself, and finally the interpreter or your own code when nothing else matches. ModuleNotFound or ImportError? Layer 2: packages wrong venv or no pip yes no SSL certificate verify failed? Layer 3: TLS certificate store yes no Timeout, connect or read error? Layer 3: network add retry and timeout yes no 401, 429 or 400 from the SDK? Layer 4: the API key, quota, request yes no None of these read your own frame Layer 1 or you interpreter or logic
Ask the questions in order and stop at the first yes; the layer you land on tells you which three or four things are worth checking and lets you ignore everything else.

Each layer has a one-command test you can run before changing anything:

LayerQuestion to answerCommand that answers it
1. InterpreterWhich Python is running?python -c "import sys; print(sys.executable)"
2. PackagesIs the SDK importable here?python -c "import openai; print(openai.__version__)"
3. Network + TLSCan I reach the host at all?python -c "import httpx; print(httpx.get('https://api.openai.com/v1').status_code)"
4. APIIs 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.

An unguarded AI script compared with a guarded one A three-row comparison matrix contrasting a script with no error handling against one with typed try and except blocks, across what you see when it fails, what happens after a network blip, and how long the next failure takes to fix. Symptom No guard With a guard What you see when it fails Raw traceback twenty red lines One clear line naming the layer After a blip on the network Script dies you rerun by hand Retries twice then reports why Next failure time to diagnose Start from zero re-read everything Message says which layer broke
A guard does not prevent failures; it changes what a failure costs you, turning a silent crash into either an automatic recovery or a one-line explanation.

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.

ExceptionLayerWhat it actually meansFirst thing to check
ModuleNotFoundError2This interpreter has no package by that namesys.executable vs where pip installed
ImportError2The package exists but the name inside it does notInstalled version vs the code you copied
AuthenticationError4Request arrived; the key was rejected (401)Key loaded, not truncated, still active
RateLimitError4Too many requests or no quota left (429)Request rate, and the billing dashboard
BadRequestError4The request is malformed or too long (400)Model name, parameters, prompt size
APIConnectionError3The request never reached the providerDNS, proxy, firewall, certificates
APITimeoutError3No reply arrived inside the time limitConnection speed, timeout= value
JSONDecodeError4Text you tried to parse was not valid JSONPrint 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

  1. ModuleNotFoundError: No module named 'openai' — the interpreter running your file is not the one you installed into. Run python -m pip install openai in the same shell where the script fails, or activate your environment first. Full walkthrough: Fix ModuleNotFoundError: No Module Named openai.
  2. 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 bundled Install Certificates.command. Never disable verification to make it go away; see Fix SSL: CERTIFICATE_VERIFY_FAILED in Python.
  3. openai.APITimeoutError: Request timed out — the connection opened but no reply arrived in time. Set an explicit timeout= on the client, retry with backoff, and shorten very large prompts, which take longer to process.
  4. 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.
  5. json.decoder.JSONDecodeError: Expecting value: line 1 column 1 — you asked json.loads to 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.
  6. openai.BadRequestError mentioning 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.

Frequently asked questions

Which line of a Python traceback should I read first?

Read the very last line first. It holds the exception type and the message, which together tell you what went wrong. The lines above it are the path your program took to get there, useful only once you know what the failure actually was.

Why does my script say No module named openai when pip says it is installed?

You installed the package into one Python and ran the script with another. That happens when a virtual environment is not activated, or when your editor points at a different interpreter. Install with the same interpreter that runs the file, using python -m pip install openai.

What is the difference between APIConnectionError and AuthenticationError?

APIConnectionError means your request never reached the provider, so the problem is your network, proxy, or certificates. AuthenticationError means the request arrived and was rejected, so the problem is the key itself. One is a plumbing fault, the other is a permission fault.

Should I wrap every AI API call in try/except?

Wrap the calls that run unattended, such as scheduled jobs and batch scripts. Catch the specific exception classes you can act on, retry the temporary ones, and print a message naming the layer that broke. Bare except blocks hide real bugs and make the next failure harder to find.

How do I tell a temporary AI API error from a permanent one?

Timeouts, connection drops, and rate limits are temporary, so waiting and retrying usually clears them. Bad keys, missing modules, wrong parameters, and oversized prompts are permanent, so retrying only wastes time. Fix permanent errors in code, retry temporary ones with a growing delay.