Common Mistakes Building AI-Powered Python Apps
Every part in this series flagged one mistake in its own Callout, as it came up while building ten different applications. This part collects all 14 in one place, each with the code that causes it and the code that fixes it. It's specific to building AI features into a Python web app — not agent design (see Common Mistakes Building AI Agents) or retrieval pipelines (see Common Mistakes with RAG and Embeddings), which are different layers with their own failure patterns.
Mistake 1: hardcoding API keys, or exposing them to a frontend (part 1, part 14)
# app.py — committed straight into the file
ANTHROPIC_API_KEY = "sk-ant-api03-abc123..."// static/chat.js — ships to every visitor's browser as plain text
const res = await fetch("https://api.anthropic.com/v1/messages", {
headers: { "x-api-key": "sk-ant-api03-abc123..." },
});# app.py
import os
from dotenv import load_dotenv
load_dotenv()
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]// static/chat.js — calls your own backend, which holds the real key server-side
const res = await fetch("/api/chat", { method: "POST", body: JSON.stringify({ message }) });Cost: a key that ends up in a public repo's history or a browser's network tab gets extracted and used on your bill within hours of exposure — this is the single most expensive mistake on this list to leave unfixed.
Mistake 2: a global variable holding conversation state in a multi-user app (part 3)
conversation_history = [] # module-level — shared by every visitor
@app.route("/chat", methods=["POST"])
def chat():
conversation_history.append({"role": "user", "content": request.json["message"]})
reply = call_ai(conversation_history)
return jsonify({"reply": reply})@app.route("/chat", methods=["POST"])
def chat():
session_id = request.json["session_id"]
history = redis_client.get_conversation(session_id)
history.append({"role": "user", "content": request.json["message"]})
reply = call_ai(history)
redis_client.save_conversation(session_id, history)
return jsonify({"reply": reply})Cost: with a module-level list, visitor B's message gets appended to the same history visitor A is reading from — one person's conversation genuinely leaks into another's the moment two requests overlap.
Mistake 3: putting AI logic directly inside a Django view (part 4)
def chat_view(request):
client = Anthropic(api_key=settings.ANTHROPIC_API_KEY)
response = client.messages.create(model="claude-sonnet-5", messages=[...])
return JsonResponse({"reply": response.content[0].text})# services/ai.py — reusable from a view, a management command, or a Celery task
def get_ai_reply(message: str) -> str:
client = Anthropic(api_key=settings.ANTHROPIC_API_KEY)
response = client.messages.create(model="claude-sonnet-5", messages=[{"role": "user", "content": message}])
return response.content[0].text
# views.py
def chat_view(request):
return JsonResponse({"reply": get_ai_reply(request.POST["message"])})Cost: logic locked inside a view can't be called from a management command for a batch job, or from a background task — it gets copy-pasted instead of reused, and the two copies drift apart the first time one gets a bug fix the other doesn't.
Mistake 4: an unauthenticated AI API endpoint (part 7)
@app.post("/summarize")
def summarize(request: SummarizeRequest):
return call_ai(request.text)@app.post("/summarize")
def summarize(request: SummarizeRequest, api_key: str = Depends(verify_api_key)):
return call_ai(request.text)Cost: this is genuine, ongoing financial exposure, not a theoretical one — every unauthorized call to an endpoint that wraps a paid LLM API costs real per-token money, and an open endpoint gets found by scanners quickly.
Mistake 5: rate limiting by IP alone for a B2B API (part 7)
@limiter.limit("100/hour") # keyed by request.remote_addr by default
def summarize(): ...@limiter.limit("100/hour", key_func=lambda: request.headers["X-Client-Id"])
def summarize(): ...Cost: every legitimate client behind the same corporate NAT or cloud provider's outbound IP gets throttled together as if they were one caller — a large customer's whole team hits the ceiling because of traffic from a client they've never heard of.
Mistake 6: a vague summarization or translation instruction (part 8, part 10)
system = "Summarize this."system = (
"Summarize the following support ticket in exactly 2 sentences: "
"the customer's core issue, and the resolution they're asking for. "
"Do not include pleasantries or ticket metadata."
)Cost: a vague instruction produces an inconsistent output shape — sometimes a paragraph, sometimes bullet points, sometimes with a pleasantry attached — which breaks any downstream code expecting a predictable structure.
Mistake 7: sending an entire large document in one request (part 9)
response = client.messages.create(
model="claude-sonnet-5",
messages=[{"role": "user", "content": entire_200_page_pdf_text}],
) # fails outright once this exceeds the model's context windowchunks = split_into_chunks(document_text, max_tokens=4000)
chunk_summaries = [summarize_chunk(c) for c in chunks]
final_summary = summarize_chunk("\n".join(chunk_summaries))Cost: this isn't graceful degradation — it's a hard failure the moment a document crosses the context-window limit, and it happens unpredictably depending on which document a user happens to upload.
Mistake 8: trusting AI translation for legally or contractually significant text (part 10)
def translate_for_delivery(text: str) -> str:
return ai_translate(text) # ships straight out, regardless of what the text isdef translate_for_delivery(text: str, is_legally_binding: bool) -> str:
translation = ai_translate(text)
if is_legally_binding:
return queue_for_human_review(translation)
return translationCost: fluent-sounding output is not the same as professionally reviewed output — a mistranslated clause in a contract or terms-of-service document carries real stakes that a marketing blurb doesn't, and the code should treat them differently.
Mistake 9: treating an entire creative-generation task as equally safe to fabricate (part 14)
system = "Write a customer testimonial for this product."system = (
"Write promotional copy for this product. Do not invent specific "
"customer names, quotes, statistics, or any claim that reads as "
"factual — write '(NEEDS REAL EXAMPLE)' as a placeholder instead."
)Cost: an invented testimonial or statistic in generated marketing copy is a specific factual claim, not a creative liberty — publishing it is a real, avoidable legal and trust problem, not a style issue.
Mistake 10: describing brand voice with adjectives instead of real examples (part 15)
system = "Write in a friendly, professional, approachable tone."system = (
"Match the tone of these three real examples of our brand voice:\n"
f"1. {brand_example_1}\n2. {brand_example_2}\n3. {brand_example_3}"
)Cost: "friendly, professional, approachable" technically matches almost anything — output that satisfies the adjectives can still not sound genuinely on-brand, because adjectives don't encode the actual sentence rhythm or word choice real examples do.
Mistake 11: fully automating a sent, external communication with no human review (part 16)
def send_followup_email(lead):
body = generate_email(lead)
send_email(lead.email, body) # goes out immediately, unrevieweddef queue_followup_email(lead):
body = generate_email(lead)
draft = EmailDraft.objects.create(lead=lead, body=body, status="pending_review")
notify_sales_rep(draft)Cost: an AI-generated email with an incorrect claim or an off tone reaching an external prospect under the company's name is a real reputational risk — a review queue costs one extra click per email and removes it.
Mistake 12: caching a genuinely unique, one-off request like a chatbot turn (part 18)
@cache.memoize(timeout=3600)
def chat_response(user_message: str) -> str:
return call_ai(user_message) # near-zero cache hit rate — chat turns rarely repeat verbatim@cache.memoize(timeout=3600)
def answer_faq(question_key: str) -> str: # a normalized, genuinely repeated key
return call_ai(FAQ_PROMPTS[question_key])Cost: caching adds real complexity — a cache key strategy, invalidation, memory overhead — for zero benefit when the input essentially never repeats. It earns its cost on genuinely repeated inputs like FAQ lookups, not free-form chat.
Mistake 13: a test suite that calls the real API on every run (part 19)
def test_summarize_endpoint():
response = client.post("/summarize", json={"text": "some text"})
assert response.status_code == 200 # hits the real Anthropic API every CI rundef test_summarize_endpoint(mock_ai_client):
mock_ai_client.messages.create.return_value = FAKE_SUMMARY_RESPONSE
response = client.post("/summarize", json={"text": "some text"})
assert response.status_code == 200Cost: a real-API test suite is slow, costs real money on every run, and fails on transient network issues that have nothing to do with an actual code bug — exactly the kind of flakiness that trains a team to ignore failing CI. Part 19 covers mocking this properly.
Mistake 14: deploying a .env file instead of platform-managed secrets (part 20)
COPY .env .env# read from the platform's injected environment at runtime — nothing
# secret is ever copied into the image
ANTHROPIC_API_KEY = os.environ["ANTHROPIC_API_KEY"]Fly.io: fly secrets set ANTHROPIC_API_KEY=...
Render: set in the dashboard's Environment tab
Vercel: vercel env add ANTHROPIC_API_KEYCost: COPY .env .env bakes the secret into an image layer that persists in the image's history even if a later layer deletes the file — anyone with pull access to that image can extract it. Part 20 covers platform-managed secrets directly.
The thread connecting all fourteen
Nearly every mistake above comes from treating an AI-powered feature like ordinary application code in the one dimension where it genuinely isn't — per-request cost, non-determinism, and higher-stakes external-facing output — while everything else about it (structure, testing, security) follows completely ordinary software engineering discipline.
Assuming AI-specific concerns replace normal software engineering discipline rather than adding to it. Every project in this series still needed ordinary error handling, testing, and security — the AI-specific concerns (grounding, cost, non-determinism) are additions on top of that foundation, not a replacement for it.
FAQ
Which of these is the most expensive to leave unfixed? Mistake 1 (exposed API keys) and Mistake 4 (unauthenticated endpoints) are the two with unbounded financial exposure — both are worth checking before anything else on this list, since either one turns into a real bill within hours of being found.
Do these apply outside of Python specifically? The failure patterns themselves — global state, unauthenticated endpoints, vague instructions — aren't Python-specific. The code examples are Python because that's what this series builds in, but the same 14 categories apply in any language.
How is this different from the AI agents mistakes page? This page covers building AI features into a conventional web app — chatbots, summarizers, translators, content generators. Common Mistakes Building AI Agents covers a different layer: autonomous tool-use loops, multi-step planning, and agent-specific memory and security concerns. A Django app that calls Claude once per request and returns the result is this page's territory, not that one's.
Is caching AI responses ever a good idea? Yes — for genuinely repeated inputs like FAQ answers or a fixed set of report summaries (see Mistake 12 and part 18). The mistake is applying it to inputs that vary too much for a cache hit to ever happen.
Do I need to mock the AI API in every test? For unit and integration tests that run on every commit, yes — see part 19. A small, separate suite of real-API smoke tests run on a schedule (not every CI run) is a reasonable way to still catch real API changes.
Next, and last: the capstone — combining several of this series' tools into one AI toolkit for Bright Leaf Coffee and GreenDesk.