Network programs spend time waiting. Concurrency lets multiple tasks make progress during overlapping periods; parallelism means work actually executes at the same time. After testing, we can make a reproducible HTTP exercise and keep its input checks separate from scheduling. Everything here runs locally with the standard library.
Create a predictable HTTP fixture
Make a fresh, otherwise empty http-demo directory. Save this as make_fixture.py and run python make_fixture.py:
import json
from pathlib import Path
Path("sample.json").write_text(
json.dumps({"topic": "loops", "minutes": 25}), encoding="utf-8"
)
In that directory, start the local server:
python -m http.server 8765 --bind 127.0.0.1
Leave this terminal running. Binding to loopback keeps the exercise local. This development server serves files from its working directory; it is not a production application server. Open a second terminal, activate your course environment, and enter the same directory.
Validate before trusting a response
Save this as http_demo.py:
import json
from concurrent.futures import ThreadPoolExecutor
from urllib.error import HTTPError, URLError
from urllib.request import urlopen
def fetch_minutes(url: str) -> int:
with urlopen(url, timeout=2) as response:
if response.status != 200:
raise ValueError("expected HTTP 200")
if response.headers.get_content_type() != "application/json":
raise ValueError("expected JSON content type")
payload = response.read(4097)
if len(payload) > 4096:
raise ValueError("response exceeds 4096 bytes")
row = json.loads(payload.decode("utf-8"))
if not isinstance(row, dict):
raise ValueError("expected a session object")
topic = row.get("topic")
minutes = row.get("minutes")
if not isinstance(topic, str) or not topic.strip():
raise ValueError("expected a nonempty topic")
if type(minutes) is not int or minutes <= 0:
raise ValueError("expected positive integer minutes")
return minutes
def main() -> None:
urls = ["http://127.0.0.1:8765/sample.json"] * 3
try:
with ThreadPoolExecutor(max_workers=2) as pool:
print(list(pool.map(fetch_minutes, urls)))
except HTTPError as error:
print(f"HTTP status: {error.code}")
except (URLError, OSError, ValueError) as error:
print(f"Fetch failed: {error}")
if __name__ == "__main__":
main()
Run python http_demo.py; expect [25, 25, 25]. Check status, content type, size, text encoding, JSON syntax, and record shape independently. A successful request alone proves none of the later checks. HTTPError is a subclass of URLError, so catch it first when distinguishing HTTP status failures. The urllib reference explains the client API.
The timeout applies to blocking socket operations; it is not a strict total deadline across redirects and a slowly arriving body. The size check reads one byte beyond the allowed size to detect overflow without loading an unlimited body. Use fixed trusted URLs in this exercise. A service accepting arbitrary URLs needs additional destination and redirect controls.
Bound work and collect results
Two workers allow at most two calls to run at once. map yields results in input order, even when completion order differs; worker exceptions surface while consuming results. The executor's context waits for started work before returning. A timeout while waiting for a future does not forcibly stop its running thread.
This input has only three URLs. On the Python 3.12 API, executor map can eagerly submit its input, so worker count alone does not bound a huge queue. For a large source, submit bounded batches and apply a total request budget. Read the executor reference.
With CPython's global interpreter lock enabled, threads overlap I/O waits but generally do not speed up Python CPU loops. CPU-heavy work may benefit from processes, which add startup and serialization costs. Process-pool functions must be importable, and process startup belongs behind a main guard. The threading documentation explains the execution limits.
Understand the async boundary
An async def function returns a coroutine. An event loop schedules coroutines, and await lets a coroutine suspend at an awaitable operation. Blocking urlopen directly inside a coroutine still blocks its event-loop thread. To reuse our blocking client, save this separate async_demo.py:
import asyncio
from http_demo import fetch_minutes
async def main() -> None:
url = "http://127.0.0.1:8765/sample.json"
results = await asyncio.gather(
asyncio.to_thread(fetch_minutes, url),
asyncio.to_thread(fetch_minutes, url),
)
print(results)
if __name__ == "__main__":
asyncio.run(main())
Run it with the server active; expect [25, 25]. This finite example uses two tasks. Large async workloads also need bounds. Cancellation of an awaiting coroutine does not kill an already-running thread; keep the client's own timeout. See asyncio tasks and threads.
Pitfalls and practice
Avoid shared mutable totals between workers; collect results and combine them in one place. Do not retry every failure indefinitely. Retries need a limit, delay, and knowledge of whether repeating an operation is safe.
Exercise: request a missing file, then change the fixture's duration to true.
Solution guidance: the missing file reports HTTP 404; valid JSON with a boolean reaches record validation and fails there. Restore the fixture, rerun both clients, and stop the server with Ctrl+C when finished.

