HTTP requests, venv and packaging
A useful Python tool is two skills: HTTP calls with requests (always a timeout, raise_for_status, env-var keys) and venv isolation that keeps one project deps from breaking another.
You write a 30-line Python script that calls an LLM API to summarise a batch of tickets. It works on your laptop. You hand it to a teammate; pip install requests fails — their global Python already pins an incompatible urllib3 for another project. You fix that, and the script hangs forever on one request because the provider had a blip and you never set a timeout. Then a security review finds the API key hard-coded in the file you just pushed. None of these are hard problems — they are the three things a JS/TS dev coming to Python keeps tripping over: HTTP done carelessly, no environment isolation, and secrets in source.
In the next few minutes you will see exactly why each of the three teammate failures happens and the minimal change that prevents each one from reaching production.
HTTP with requests: the four things you must not skip
requests is Python’s de-facto HTTP client — it is to Python what fetch is to the browser, except synchronous and far more ergonomic than the standard-library urllib. A GET with query params and a JSON response is three lines:
import requests
resp = requests.get(
"https://api.example.com/search",
params={"q": "python", "limit": 10},
headers={"Accept": "application/json"},
timeout=10,
)
resp.raise_for_status() # turn a 4xx/5xx into an exception
data = resp.json() # parsed dict, like fetch's await resp.json()A POST that sends JSON is the same shape — pass json= and requests serialises the body and sets Content-Type: application/json for you:
resp = requests.post(url, json={"name": "ada"}, timeout=10)Two habits separate a script that survives production from one that hangs or lies:
- Always pass
timeout=. This is the senior point, and the one beginners miss. By defaultrequestswaits forever. A single slow or dead upstream will freeze your whole script — or every worker in a pool — with no error and no recovery.fetchhas the same gap (you need anAbortController); in Python you just pass a number. - Call
raise_for_status(). Unlikefetch, which rejects only on network failure and happily resolves a 500,requestsdoes not raise on a 4xx/5xx by default —resplooks fine andresp.json()may return an error body you treat as data.raise_for_status()converts a bad status into anHTTPErroryou can catch. (fetchis the mirror image: you must checkresp.okyourself.)
For many parallel calls or an async app, reach for httpx — same API surface as requests, but it adds async/await and HTTP/2. A retry with backoff is worth wiring in for any real upstream: on a 429 or 503, wait 0.5s, 1s, 2s… and retry a few times rather than failing on the first blip. (Libraries like tenacity, or urllib3’s Retry adapter, do this for you.)
The AI-glue payoff: calling an LLM HTTP API safely
Most “AI integration” is exactly this pattern: POST a JSON body to a provider endpoint with an auth header, then parse the JSON that comes back. The senior detail is where the key lives — never in source.
import os
import requests
api_key = os.environ.get("LLM_API_KEY") # read from the environment, not the file
if not api_key:
raise RuntimeError("LLM_API_KEY is not set")
resp = requests.post(
"https://api.provider.example/v1/chat/completions",
headers={"Authorization": f"Bearer {api_key}"},
json={
"model": "some-model",
"messages": [{"role": "user", "content": "Summarise this ticket: ..."}],
},
timeout=30, # LLMs are slow; give them room, but bound it
)
resp.raise_for_status()
reply = resp.json()["choices"][0]["message"]["content"]os.environ["LLM_API_KEY"] is Python’s process.env.LLM_API_KEY. You set it in your shell, a .env loaded by python-dotenv, or your deploy platform’s secret store — and the key never appears in the repo, in git history, or in a screen-share. Hard-coding it is the single most common way credentials leak. The generous timeout=30 matters here too: model responses are slow, so a tight timeout cancels good requests, but no timeout means one stuck generation hangs your tool indefinitely.
| Node / TypeScript | Python | Note |
|---|---|---|
fetch() | requests / httpx | requests is sync; httpx adds async + HTTP/2 |
await resp.json() | resp.json() | synchronous; no await |
check resp.ok yourself | resp.raise_for_status() | neither raises on 4xx/5xx by default |
AbortController | timeout= | requests waits forever without it |
process.env.X + dotenv | os.environ[“X”] + python-dotenv | keep secrets out of source |
package.json | pyproject.toml / requirements.txt | project + dependency manifest |
node_modules/ | .venv/ | per-project install location |
npm install | pip install (or uv) | resolve + install deps |
Your script calls an LLM API and occasionally hangs for minutes with no error. What is the most likely cause?
A provider returns a 500 with an error JSON body. Your code does resp.json() without raise_for_status(). What happens?
Environments and packaging: why venv exists
Ask yourself: how many Python projects do you or your teammates have on one machine? Each one has opinions about which library versions it needs — and by default Python lets them all fight over the same global install folder. Here is the teammate’s failure from the hook. Python, by default, installs packages into one global site-packages shared by every project on the machine. Project A needs urllib3==1.26; project B needs urllib3==2.x. Installed globally, the second pip install overwrites the first — and now whichever project loads the wrong version breaks. There is no node_modules per project to keep them apart.
A virtual environment (venv) is that per-project boundary. It is a lightweight, isolated copy of the interpreter with its own site-packages, so each project’s dependencies live in their own folder and never collide:
python -m venv .venv # create an isolated env in ./.venv
source .venv/bin/activate # macOS/Linux (Windows: .venv\Scripts\activate)
pip install requests httpx # installs INTO .venv, not globallyWhile activated, python and pip point at .venv — analogous to how node_modules scopes packages to one project, except a venv isolates the interpreter itself, not just the libraries. You .gitignore the .venv/ folder (like node_modules/) and commit a manifest instead so anyone can recreate it.
Pinning: requirements.txt and the move to pyproject.toml
Isolation is half the story; reproducibility is the other half. If you pip install requests today and your teammate does it next month, you may get different versions. Pin them. The classic file is requirements.txt:
requests==2.32.3
httpx==0.27.2Recreate the exact set with pip install -r requirements.txt inside a fresh venv. This is package.json + a lockfile, split across convention rather than one file. The modern, standardised home for project metadata and dependencies is pyproject.toml (PEP 621) — declaring deps there, and using a fast resolver like uv or poetry, gives you a real lockfile and one-command setup, much closer to the npm install experience. For a quick script requirements.txt is fine; for anything you ship or share, prefer pyproject.toml.
▸Why this works
Why not just pip install --user or install globally and skip the venv? Because “global” is shared mutable state across every project and even the OS’s own Python tooling. One project’s pin silently changes another’s runtime, upgrades become Russian roulette, and on some systems pip into the system Python is blocked outright (PEP 668’s “externally-managed-environment”). A venv per project makes dependencies explicit, disposable, and reproducible — delete .venv, recreate from the manifest, done.
You're starting a small Python tool that two teammates will also run. How do you manage its dependencies?
Order the steps to set up and run an HTTP tool that calls an LLM API safely:
- 1 python -m venv .venv — create the isolated environment
- 2 source .venv/bin/activate — activate it so pip/python point at it
- 3 pip install -r requirements.txt — install pinned deps into the venv
- 4 export LLM_API_KEY=… (or load a .env) — key in the environment, not source
- 5 python tool.py — POST with auth header + timeout, then raise_for_status()
- 01Walk through making a safe POST to an LLM API in Python: what must you set, and how does each differ from JS fetch?
- 02Your teammate's pip install breaks another project. Explain why, and the venv + manifest fix end to end.
A useful Python tool is two skills working together. First, HTTP: requests is Python’s fetch, but synchronous — requests.get/post with params/json, and resp.json() with no await. The senior habits are non-negotiable: always pass timeout= (requests waits forever otherwise, and one stalled upstream hangs the whole script), always call raise_for_status() (requests does not raise on a 4xx/5xx, so an error body sails through as data), and for many or async calls reach for httpx plus a retry-with-backoff on 429/503. The AI-glue payoff is the same shape: POST a JSON body with an Authorization: Bearer header to a provider, read the key from os.environ so it never lives in source, and give slow models a generous-but-bounded timeout. Second, environments: Python installs globally by default, so two projects’ dependency pins collide — a venv per project is the isolating boundary (its own interpreter and site-packages), gitignored like node_modules. Pin versions in a requirements.txt, or better a pyproject.toml with uv/poetry, so the exact environment is reproducible on any machine. Isolation plus pinning is what turns “works on my laptop” into a tool your teammates can actually run. Now when you see a hung script or a teammate’s broken install, you know which two missing lines to add before anything else: timeout= and venv.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.