Programming with PythonPython for Automation and the Web › Day 78

Hands-on lab — Day 78: HTTP in Python with requests

Commands

Setup

cd labs/sections/programming-with-python/day-078-http-in-python-with-requests
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/python3 -c "import requests; print(requests.__version__)"

Run

.venv/bin/python3 examples/raw_socket_demo.py
.venv/bin/python3 examples/demo.py
.venv/bin/python3 examples/stdlib_demo.py
.venv/bin/python3 examples/httpx_demo.py   # exits 0 whether or not httpx is installed
.venv/bin/pytest examples -q
.venv/bin/pytest examples/test_without_a_server.py -q   # no server started at all
PYTHONPATH=tests .venv/bin/pytest examples -q   # every non-loopback socket blocked
.venv/bin/pytest starter -q

Test

bash tests/run_tests.sh

File tree

examples/client.py
examples/conftest.py
examples/demo_server.py
examples/demo.py
examples/fake_session.py
examples/httpx_demo.py
examples/raw_socket_demo.py
examples/stdlib_demo.py
examples/test_client.py
examples/test_without_a_server.py
expected-output/FIELDS.md
expected-output/pytest-runs.txt
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/client.py
starter/conftest.py
starter/NOTES.md
starter/test_client.py
tests/run_tests.sh
tests/sitecustomize.py
troubleshooting.md

Lab README

Day 078 lab — Talk to a Server You Control

Lesson

Purpose

Day 78 is the day your program stops being alone on your machine. Every lab before this one read a file, computed something, and printed it. This one asks another process for data over a network protocol, and everything that makes distributed systems hard shows up at once: a call that might never return, a server that says no, a server that says "not now, try again in a second", a response too large to hold in memory, and a status code that means failure arriving inside a request that succeeded perfectly.

You will build a client against a server you control. examples/demo_server.py is about two hundred lines of standard library — http.server, socket, threading — bound to 127.0.0.1 on a port the operating system picks at run time. It can produce every case that matters on demand: a 200 with JSON, a 404, a 500, a 301 redirect, a 429 carrying Retry-After, an endpoint that takes three seconds so a timeout can really fire, and a 512 KiB body so streaming is not a hypothetical.

Nothing in this lab touches the internet after the one-time install. That is not a convenience; it is the design. A lab that hit a real public API would be slow, would be flaky, would break the day that API changed, would fail for you on a plane, and would quietly teach you to hammer somebody else's server. And it is not merely promised: tests/sitecustomize.py blocks every non-loopback socket, tests/run_tests.sh runs the whole example suite under that guard, and one further check proves the guard is not vacuous by confirming a request to a public site is refused under it.

The last exercise is the one that pays off. Because every function in client.py takes session as a parameter — Day 74's boundary argument applied to the network — the whole client can be tested with a forty-line fake and no server at all. The test suite proves that by copying three files to an empty temporary directory, deliberately leaving demo_server.py behind, and running the fake-session suite there.

Learning objectives

  • Read an HTTP request and response as the bytes they actually are, using a plain socket and no library.
  • Send a GET with a query string using params=, and explain what an f-string would have done to a value containing a space and an ampersand.
  • Tell .text, .content and .json() apart, and know when each is wrong.
  • Treat a 404 and a 500 as data rather than as crashes, and turn either into one sentence a human can act on.
  • Set a timeout on every request, and watch one fire against an endpoint that deliberately takes three seconds.
  • Implement retry with exponential backoff and jitter, honour Retry-After, and say precisely which status codes deserve a retry and which never do.
  • Use a Session for shared headers and connection reuse, and prove the reuse by reading the server's own count of accepted TCP connections.
  • Stream a large body to disk a chunk at a time instead of loading it.
  • Read a credential from the environment and never from a source file.
  • Test a networked function with an injected fake session, with no server, no socket and no patching.

Prerequisites

  • Day 74: boundaries, test doubles, and the argument that you inject a boundary rather than patch it. This lab is that argument made concrete.
  • Day 71 to 73: pytest, fixtures, pytest.raises, parametrization.
  • Day 66: exception hierarchies — ReadingsError and its two subclasses.
  • Day 69: dataclasses and type hints.
  • Day 65: reading and writing JSON.
  • Day 43: creating a virtual environment with python3 -m venv.
  • Days 1 to 40 gave you the network layers this lab sits on top of: an address, a port, a TCP connection, and a protocol carried over it.

Supported operating systems

  • macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0, requests 2.34.2, pytest 9.1.1, bash 3.2.57).
  • Linux — fully supported (any distribution with Python 3.10+ and bash).
  • Windows — use WSL and follow the Linux path. Several headings contain an em dash, so a UTF-8 terminal is needed for them to render; the status codes, byte counts, exit codes and assertions are unaffected.

Hardware requirements

Any computer that runs Python 3. The largest thing the lab moves is a 512 KiB response, and it moves it in 8 KiB chunks. The test suite finishes in a few seconds, of which most is two tests waiting 0.4 seconds each for a timeout to fire on purpose. No GPU, no special memory, and no internet access at test time.

Required software

  • python3 (3.10 or newer; tested on 3.14.0).
  • requests 2.34.2 and pytest 9.1.1 — installed below.
  • bash for the test runner (preinstalled on macOS and Linux).
  • The server needs nothing installed: http.server, socket, threading, json and urllib.parse all ship with Python.

Free and open-source options

Everything here is free and open source: Python, bash, the standard library, requests (Apache 2.0) and pytest (MIT). See requirements/README.md for the per-package detail. No account, no API key, no purchase.

The lesson's Alternatives section compares four libraries. Two — urllib.request and http.client — need no installation at all, and examples/stdlib_demo.py runs them here so you can see exactly what requests is saving you. The fourth, httpx, is deliberately not a dependency: examples/httpx_demo.py runs the comparison if httpx happens to be installed and prints a short explanation if it is not.

Installation

cd labs/sections/programming-with-python/day-078-http-in-python-with-requests
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/python3 -c "import requests; print(requests.__version__)"

Those last two should print pytest 9.1.1 and 2.34.2. .venv/ is ignored by version control — never commit it. If you already have both packages elsewhere, skip the virtual environment and run the suite as PYTEST=/path/to/pytest bash tests/run_tests.sh.

File structure

day-078-http-in-python-with-requests/
├── README.md                          ← you are here
├── metadata.yml                       ← machine-readable lab metadata
├── examples/
│   ├── demo_server.py                 ← the server you control: stdlib only, 127.0.0.1, ephemeral port
│   ├── client.py                      ← the reference client: timeouts, retries, streaming, injected session
│   ├── fake_session.py                ← a scripted stand-in for requests.Session — the Day 74 payoff
│   ├── raw_socket_demo.py             ← HTTP as bytes, with no HTTP library at all
│   ├── demo.py                        ← the whole story in one run, eight sections
│   ├── stdlib_demo.py                 ← the same calls in urllib.request and http.client
│   ├── httpx_demo.py                  ← the same calls in httpx, if you have it
│   ├── conftest.py                    ← one local server for the whole test session
│   ├── test_client.py                 ← 28 tests against the local server
│   └── test_without_a_server.py       ← 20 tests against a fake session, no server anywhere
├── starter/
│   ├── client.py                      ← YOUR working file (exercises 1-6)
│   ├── test_client.py                 ← YOUR working file (exercise 7)
│   ├── conftest.py                    ← provided complete: the server fixture
│   └── NOTES.md                       ← YOUR written answers (exercise 8)
├── tests/
│   ├── run_tests.sh                   ← 58 checks; exits 0 only if all pass
│   └── sitecustomize.py               ← the offline guard: blocks every non-loopback socket
├── expected-output/
│   ├── sample-run.txt                 ← real captured runs of all four demos
│   ├── pytest-runs.txt                ← real captured runs of the suites, five ways
│   ├── test-run.txt                   ← real captured run of the test suite
│   └── FIELDS.md                      ← required behaviour, and what varies between runs
├── requirements/
│   ├── requirements.txt               ← requests==2.34.2, pytest==9.1.1
│   └── README.md                      ← what each dependency is for, and what is stdlib
├── troubleshooting.md
└── security.md

How to run

From this directory, with the virtual environment installed.

## 1. HTTP with no HTTP library: type a request by hand, read the raw response.
.venv/bin/python3 examples/raw_socket_demo.py

## 2. The whole lab in one run: eight sections, one local server.
.venv/bin/python3 examples/demo.py

## 3. The same work in the standard library — nothing installed, nothing needed.
.venv/bin/python3 examples/stdlib_demo.py

## 4. And in httpx, if it happens to be installed. Exits 0 either way.
.venv/bin/python3 examples/httpx_demo.py

## 5. The reference suite: 28 tests against the local server, 20 against a fake.
.venv/bin/pytest examples -q

## 6. The 20 that need no server at all. Read the file; it is the point of the day.
.venv/bin/pytest examples/test_without_a_server.py -q

## 7. Prove the whole suite is offline: every non-loopback socket blocked.
PYTHONPATH=tests .venv/bin/pytest examples -q

## 8. Your task: exercises 1-6 in starter/client.py, 7 in starter/test_client.py,
##    8 in starter/NOTES.md. Unfinished exercises are skipped, so this is green
##    from the first minute.
.venv/bin/pytest starter -q

## 9. Check your work.
bash tests/run_tests.sh

What the commands do

  • examples/raw_socket_demo.py — opens a plain TCP socket to the local test server, sends a request line, three headers and a blank line typed out by hand, and prints both directions byte for byte. Read this before anything else. After it, requests stops being magic and becomes a convenience over text you have already seen.
  • examples/demo.py — eight sections. (1) the pieces of a request and a response, including .content versus .text versus .json(). (2) params= against an f-string, with a value containing a space and an ampersand — the f-string smuggles in a second query parameter and the server proves it. (3) a 404 as a successful response, and the three ways to handle it. (4) a redirect followed and unfollowed. (5) a read timeout of 0.5 s firing against a 3 s endpoint. (6) retry with backoff against a 429, with the sleep injected so it takes milliseconds. (7) five requests through a Session opening one TCP connection where five bare calls open five. (8) 512 KiB streamed in 64 chunks.
  • examples/stdlib_demo.py — the same four calls in urllib.request and http.client, so the comparison in the lesson is something you have watched rather than read. Note especially that urllib.request raises on a 404 where requests returns a response.
  • examples/httpx_demo.py — the modern alternative, including the difference that matters most: httpx.Client() has a default timeout of 5 seconds and requests has none.
  • pytest examples -q — 48 tests. 28 open a real socket to the local test server; 20 open nothing at all.
  • PYTHONPATH=tests pytest examples -q — the same 48, with socket.connect and socket.getaddrinfo replaced so that any attempt to reach a non-loopback address raises. Still 48 passed.
  • bash tests/run_tests.sh — 58 checks while the starter is unfinished, 53 once you complete every exercise. Exits 0 only if all of them pass.

Expected output

See expected-output/sample-run.txt and expected-output/pytest-runs.txt — real captured sessions. The heart of it:

$ python3 examples/raw_socket_demo.py

  bytes sent (147 bytes)
  ----------------------
    GET /api/readings?station=ALPHA HTTP/1.1\r\n
    Host: 127.0.0.1:54037\r\n
    User-Agent: day078-raw-socket/1.0\r\n
    Accept: application/json\r\n
    Connection: close\r\n
    \r\n          <- the blank line: headers finished
$ python3 examples/demo.py

2. params= versus gluing strings together
=========================================
  station value    : 'ALPHA ONE&station=BRAVO'
  params=          : /api/search?station=ALPHA+ONE%26station%3DBRAVO
    server parsed  : {'station': ['ALPHA ONE&station=BRAVO']}
  f-string         : /api/search?station=ALPHA%20ONE&station=BRAVO
    server parsed  : {'station': ['ALPHA ONE', 'BRAVO']}
  the f-string smuggled a second parameter in. params= encoded it.

7. Session and connection reuse
===============================
  5 calls, one Session      : 1 TCP connection(s)
  5 calls, requests.get()   : 5 TCP connection(s)

Only the port number, the Date header and the elapsed times vary between runs, and all three are explained in expected-output/FIELDS.md, which also lists the exact required behaviour of every endpoint and every client function.

Validation steps

  1. python3 examples/raw_socket_demo.py exits 0 and shows a request that starts GET /api/readings?station=ALPHA HTTP/1.1 and a response that starts HTTP/1.1 200 OK.
  2. python3 examples/demo.py section 2 shows station=ALPHA+ONE%26station%3DBRAVO for params= and two parsed values for the f-string.
  3. Section 5 of the same run raises ReadTimeout in about 0.50 s against an endpoint that would have taken 3 s.
  4. Section 7 shows 1 TCP connection(s) for the Session and 5 without one. If yours shows 5 and 5, you used requests.get inside the loop.
  5. Section 8 shows 524288 bytes read as 64 chunks of at most 8192.
  6. pytest examples -q reports 48 passed in under two seconds.
  7. pytest examples/test_without_a_server.py -q reports 20 passed in about 0.04 s — no server was started for any of them.
  8. PYTHONPATH=tests pytest examples -q still reports 48 passed. Then prove the guard is not vacuous: PYTHONPATH=tests .venv/bin/python3 -c "import requests; requests.get('https://example.com', timeout=2)" must fail with NetworkBlocked.
  9. Every exercise in starter/client.py is complete, pytest starter -q reports no skips, and starter/NOTES.md is filled in with your own numbers and sentences rather than blanks.
  10. bash tests/run_tests.sh reports 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

Expected final line while the starter is unfinished: 58 checks, 0 failure(s). Once every exercise is complete, eight structural checks are replaced by three behavioural ones and the line becomes 53 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it is usable in continuous integration. A full captured run is in expected-output/test-run.txt.

Three of the checks are worth reading the runner for:

  • the offline proof. The whole example suite is re-run with tests/sitecustomize.py on PYTHONPATH, which replaces socket.connect, socket.connect_ex and socket.getaddrinfo so any non-loopback address raises. A separate check then confirms a request to a public site under the same guard is refused, so the guard cannot be silently doing nothing.
  • the no-server proof. Three files — client.py, fake_session.py and test_without_a_server.py — are copied to an empty temporary directory, deliberately without demo_server.py or conftest.py, and pytest is run there. Twenty tests pass. That is only possible because the boundary is a parameter.
  • the signature check. inspect.signature confirms that fetch_readings, get_with_retry and stream_to_file all take a keyword-only session, and that get_with_retry takes an injectable sleep and jitter. If a future edit hard-codes requests.get, this fails.

Cleanup

The lab writes nothing into your working directory. The streamed file goes into pytest's tmp_path or a tempfile.TemporaryDirectory, and the server is shut down in a finally block by a context manager. The runner passes -p no:cacheprovider, so pytest leaves no cache directory behind.

rm -rf .venv                 # remove the virtual environment when you are done
git checkout -- starter/     # optional: reset your work

Troubleshooting

See troubleshooting.md for the full list. The five you are most likely to meet: ModuleNotFoundError: No module named 'requests', which always means the interpreter running your script is not the one the package is installed in; a call that never returns, which always means a missing timeout=; ConnectTimeout where you expected ReadTimeout, because the tuple is (connect, read) in that order; a streaming loop that reports one chunk instead of 64, because something read .content before the loop; and ReadingsUnavailable: gave up after 4 attempts in the retry exercise, which means the flaky endpoint was still armed from an earlier test and needs /control/reset.

Security notes

See security.md. Short version: the server binds 127.0.0.1 and not 0.0.0.0, which is the difference between a private fixture and a service open to everyone on the coffee-shop Wi-Fi. No credential appears in any file; make_session reads READINGS_TOKEN from the environment and the one test that needs it sets a fake value through monkeypatch for the duration of that test. This lab uses plain HTTP only because it talks to itself over loopback — anything leaving your machine must use HTTPS, and requests verifies certificates by default for a reason. And the day's own security point: a retry loop with no backoff, no jitter and no attempt cap is a small denial-of-service tool pointed at whoever you are calling.

Extension exercises

  1. Add a 503 with a Retry-After date. Retry-After may legally be a number of seconds or an HTTP date. Add an endpoint that sends the date form, then make get_with_retry handle both. Note that the reference implementation currently ignores what it cannot parse as a float, and decide whether that is a reasonable default or a bug.
  2. Measure what a connection actually costs. Time 50 requests through one Session and 50 through requests.get, and record both. Then read the lesson's note about TLS and predict how the gap would change over HTTPS.
  3. Make the timeout fire on connect instead of read. Point the client at an address that will not answer — a port on 127.0.0.1 with nothing listening is the safe way — and catch ConnectionError. Then find an address that accepts and never replies, and produce a ConnectTimeout. Write down which exception you got for which cause.
  4. Add a paginated endpoint that returns 50 readings at a time with a next link, and write a client function that follows it to exhaustion. Cap the number of pages, and say in a comment what happens without the cap if the server has a bug that always returns the same next.
  5. Break the boundary and watch the suite notice. Change fetch_readings to call requests.get directly instead of using the session parameter, then run bash tests/run_tests.sh. Both the signature check and the no-server suite fail. Put it back, and write one sentence explaining what the failure told you.
  6. Write the client you will need in Course 07. Sketch a complete(prompt) method against a model API: a Session with an Authorization header from the environment, a timeout, retry on 429 and 5xx with backoff and jitter, and a streaming mode that yields tokens as they arrive. Test all of it with a FakeSession and no model. You now have the skeleton of every model client in the rest of the course.
  • Previous day: Day 77 — Quality Gates for a Python Project (labs/sections/programming-with-python/day-077-quality-gates-for-a-python-project/).
  • Next day: Day 79 — Web Scraping Responsibly (labs/sections/programming-with-python/day-079-web-scraping-responsibly/).
  • Week 12 project: the Personal Automation Toolkit (labs/sections/programming-with-python/projects/week-12/), which needs a client that sets timeouts, retries the right statuses, and can be tested without a network.

Expected output

FIELDS.md

# Expected output — Day 078 lab

Real captured runs from the authoring machine (macOS 26.5.1, Apple Silicon,
Python 3.14.0, requests 2.34.2, pytest 9.1.1, httpx 0.28.1, bash 3.2.57,
2026-07-19). Every byte below came out of a command that really ran, against
a server this lab started on 127.0.0.1. **No capture in this directory
involved the internet.**

## Files

- `sample-run.txt` — `raw_socket_demo.py`, `demo.py`, `stdlib_demo.py` and
  `httpx_demo.py`, each run end to end.
- `pytest-runs.txt` — the example suite five ways: whole, fake-session only,
  with timings, with every non-loopback socket blocked, and the starter.
- `test-run.txt` — a full run of `bash tests/run_tests.sh`.

## What is deterministic and what is not

| Varies | Where | Why |
| --- | --- | --- |
| The port, e.g. `127.0.0.1:54037` | every capture | The lab binds port `0`, so the operating system picks a free port each run. That is deliberate: a hard-coded 8000 would collide with whatever you already have running. |
| The `Date:` response header | `raw_socket_demo.py` section 2 | HTTP servers stamp the current time. |
| Elapsed times, e.g. `0.0012s` | `demo.py` sections 1 and 6, pytest durations | Wall-clock measurements on a loopback socket. The magnitudes are the point, not the digits. |
| The sha256 of the streamed body's *prefix* | never | It is fixed: the body is a repeated constant line. |

Everything else — every status code, every byte count, every chunk count,
every connection count, every assertion — is identical on every run and on
every machine, because the server is a fixture rather than a service.

## Required behaviour — the local test server

| Endpoint | Status | Notes |
| --- | --- | --- |
| `GET /api/readings` | 200 | JSON: `count` 6, all stations |
| `GET /api/readings?station=ALPHA` | 200 | JSON: `count` 4 |
| `GET /api/readings?station=NOWHERE` | 404 | JSON body with `detail: no station named NOWHERE` |
| `GET /api/search?...` | 200 | echoes `raw_query` and the server's parse of it |
| `GET /api/missing` | 404 | `detail: no such station` |
| `GET /api/broken` | 500 | `detail: the server fell over` |
| `GET /old/readings` | 301 | `Location: /api/readings` |
| `GET /api/flaky` | 429 then 200 | `Retry-After: 1`; arm it with `/control/reset?fail=N` |
| `GET /api/slow?seconds=3` | 200 after 3 s | exists so a timeout can really fire |
| `GET /api/large?kb=512` | 200 | exactly `512 * 1024` = 524288 bytes |
| `POST /api/echo` | 201 | echoes method, Content-Type, User-Agent, body size, parsed JSON |
| `GET /control/stats` | 200 | `connections`, `requests`, `flaky_calls` |

`GET /api/large?kb=8` returns exactly 8192 bytes — the body is a 1023-byte
line plus a newline, repeated `kb` times, so every byte count is derivable.

## Required behaviour — the client

| Call | Result |
| --- | --- |
| `fetch_readings(base, "ALPHA", session=s)` | 4 `Reading` objects; `readings[0] == Reading("ALPHA", 0, 12.0)` |
| `summarise(...)` of those | `{"count": 4.0, "min": 12.0, "max": 22.0, "mean": 17.0}` — check by hand: (12+14+20+22)/4 = 17 |
| `fetch_readings(base, "NOWHERE", session=s)` | raises `StationNotFound`, message contains `NOWHERE`, no traceback text |
| `describe_failure(404 response)` | `HTTP 404 (your request was rejected) — no such station` |
| `describe_failure(500 response)` | `HTTP 500 (the server failed) — the server fell over` |
| `session.get(slow, timeout=(3.05, 0.4))` | raises `requests.exceptions.ReadTimeout` in about 0.40 s, not 3 s |
| `backoff_delays(6, jitter=lambda: 1.0)` | `[0.5, 1.0, 2.0, 4.0, 8.0]` |
| `backoff_delays(4, jitter=lambda: 0.0)` | `[0.25, 0.5, 1.0]` |
| `backoff_delays(0)` | raises `ValueError` |
| `get_with_retry(flaky, attempts=4)` after `reset?fail=2` | 200 on attempt **3**; two waits requested; `Retry-After: 1` overrides both |
| `get_with_retry(flaky, attempts=3)` after `reset?fail=99` | raises `ReadingsUnavailable` containing `after 3 attempts` |
| `get_with_retry(missing, attempts=4)` | returns the 404 immediately; zero waits |
| `stream_to_file(large?kb=512, chunk_size=8192)` | `(524288, 64, <64-hex-char digest>)` |
| `make_session()` with no `READINGS_TOKEN` | sends no `Authorization` header |
| `make_session()` with `READINGS_TOKEN` set | sends `Authorization: Bearer …` |

## Required behaviour — the numbers the lesson quotes

| Claim | Where it is proved |
| --- | --- |
| 5 requests through one `Session` open **1** TCP connection | `demo.py` section 7; `test_one_session_reuses_one_connection_for_many_requests` |
| 5 calls to `requests.get` open **5** | the same two places |
| `params=` sends `station=ALPHA+ONE%26station%3DBRAVO` | `demo.py` section 2; `test_params_are_encoded_not_concatenated` |
| An f-string sends `station=ALPHA%20ONE&station=BRAVO`, and the server parses **two** values | the same two places |
| `httpx.Client()` has a default timeout of `Timeout(timeout=5.0)`; `requests` has none | `httpx_demo.py` section 2 |
| `urllib.request` *raises* on a 404 where `requests` returns a response | `stdlib_demo.py` section 2 |

## Test counts

| Command | Result |
| --- | --- |
| `pytest examples -q` | `48 passed`, exit 0, about 1.4 s |
| `pytest examples/test_without_a_server.py -q` | `20 passed`, exit 0, about 0.04 s |
| `PYTHONPATH=tests pytest examples -q` | `48 passed` with every non-loopback socket blocked |
| `pytest starter -q` (exercises unfinished) | `2 passed, 13 skipped`, exit 0 |
| `bash tests/run_tests.sh` | `58 checks, 0 failure(s).`, exit 0 |

Roughly a second of the example suite's 1.4 s is two tests that deliberately
wait 0.4 s each for a timeout to fire. Everything else is in the noise.

## The offline guarantee

`tests/sitecustomize.py` replaces `socket.socket.connect`,
`socket.socket.connect_ex` and `socket.getaddrinfo` so that any attempt to
resolve a hostname or reach an address that is not the loopback interface
raises `NetworkBlocked`. `tests/run_tests.sh` runs the entire example suite
with that file on `PYTHONPATH`, and separately proves the guard is not
vacuous by confirming that a request to a public site under the same guard
fails. Both checks are in section 5 of `test-run.txt`.

## Platform notes

- **macOS and Linux** — identical. `python3`, `bash` and `mktemp -d` behave
  the same, and `http.server` is the same code on both.
- **Windows** — use WSL and follow the Linux path. Several headings contain
  an em dash, so a UTF-8 terminal is needed for them to render; the status
  codes, byte counts and exit codes are unaffected.
- **Python version** — verified on 3.14.0. Python 3.10 or newer is required
  for the `X | None` annotation style used throughout.
- **httpx** — `examples/httpx_demo.py` ran here against httpx 0.28.1, which
  is installed on the authoring machine but is **not** a dependency of this
  lab. On a machine without it the demo prints a short explanation and exits
  0, and the test suite accepts that outcome.

pytest-runs.txt

$ pytest examples -q

................................................                         [100%]
48 passed in 1.43s

$ pytest examples/test_without_a_server.py -q   # no server involved at all

....................                                                     [100%]
20 passed in 0.04s

$ pytest examples -q --durations=5

................................................                         [100%]
============================= slowest 5 durations ==============================
0.49s teardown examples/test_without_a_server.py::test_summarise_is_pure_and_needs_no_session_at_all
0.40s call     examples/test_client.py::test_a_timeout_is_a_requestexception_so_one_except_catches_the_family
0.40s call     examples/test_client.py::test_a_read_timeout_fires_against_the_slow_endpoint
0.01s call     examples/test_client.py::test_retry_succeeds_after_exactly_the_expected_number_of_attempts
0.01s call     examples/test_client.py::test_requests_get_without_a_session_opens_a_connection_every_time
48 passed in 1.43s

$ PYTHONPATH=tests pytest examples -q   # every non-loopback socket blocked

................................................                         [100%]
48 passed in 1.44s

$ pytest starter -q   # exercises unfinished

.ssssssssssss.s                                                          [100%]
2 passed, 13 skipped in 0.56s

sample-run.txt

$ python3 examples/raw_socket_demo.py

1. The request, typed out by hand
=================================
  Four parts: a request line, some headers, a blank line, and
  (for a GET) no body. Every line ends with carriage return +
  line feed, and the blank line is what says 'headers finished'.

  bytes sent (147 bytes)
  ----------------------
    GET /api/readings?station=ALPHA HTTP/1.1\r\n
    Host: 127.0.0.1:55272\r\n
    User-Agent: day078-raw-socket/1.0\r\n
    Accept: application/json\r\n
    Connection: close\r\n
    \r\n          <- the blank line: headers finished

2. The response, exactly as it arrived
======================================
  Three parts: a status line, some headers, a blank line, then
  the body. The body here is JSON, but HTTP neither knows nor
  cares — Content-Type is what says so.

  status line and headers (142 bytes)
  -----------------------------------
    HTTP/1.1 200 OK\r\n
    Server: DayLab/1.0\r\n
    Date: Sun, 19 Jul 2026 13:30:25 GMT\r\n
    Content-Type: application/json; charset=utf-8\r\n
    Content-Length: 255\r\n
    \r\n          <- the blank line: headers finished

  body (255 bytes)
  ------------------
    {"station":"ALPHA","count":4,"query_seen":{"station":["ALPHA"]},"readings":[{"station":"ALPHA","hour":0,"celsius":12.0},{"station":"ALPHA","hour":6,"celsius":14.0},{"station":"ALPHA","hour":12,"celsius":20.0},{"station":"ALPHA","hour":18,"celsius":22.0}]}

3. Reading the pieces back
==========================
    HTTP version : HTTP/1.1
    status code  : 200
    reason phrase: OK
    header       : Server = DayLab/1.0
    header       : Date = Sun, 19 Jul 2026 13:30:25 GMT
    header       : Content-Type = application/json; charset=utf-8
    header       : Content-Length = 255

  That is the entire protocol. Everything `requests` adds is
  convenience on top of these bytes: building the request line,
  encoding the query string, pooling the connection, decoding
  the body, and turning a status code into an exception.

$ python3 examples/demo.py


1. A request and a response, in the pieces that matter
======================================================
  request method   : GET
  request path     : /api/readings?station=ALPHA
  request headers  : 4 sent
  status code      : 200 OK
  content type     : application/json; charset=utf-8
  .content is      : bytes, 255 bytes
  .text is         : str, 255 characters
  .json() is       : dict with keys ['count', 'query_seen', 'readings', 'station']
  elapsed          : 0.0010s

2. params= versus gluing strings together
=========================================
  station value    : 'ALPHA ONE&station=BRAVO'
  params=          : /api/search?station=ALPHA+ONE%26station%3DBRAVO
    server parsed  : {'station': ['ALPHA ONE&station=BRAVO']}
  f-string         : /api/search?station=ALPHA%20ONE&station=BRAVO
    server parsed  : {'station': ['ALPHA ONE', 'BRAVO']}
  the f-string smuggled a second parameter in. params= encoded it.

3. Status codes: a 404 is a successful response
===============================================
  the call itself  : returned normally, no exception
  status_code      : 404
  bool(response)   : False   <- False for 4xx and 5xx
  described        : HTTP 404 (your request was rejected) — no such station
  raise_for_status : HTTPError: 404 Client Error: Not Found
  the client raises: StationNotFound: no station named 'NOWHERE'

4. A redirect, followed and unfollowed
======================================
  final status     : 200
  final url path   : readings
  history          : [301]
  unfollowed       : 301, Location: /api/readings
  301 is permanent — a client may cache it. 302 is temporary.

5. The timeout that is not there by default
===========================================
  asked for        : 3 seconds of server work
  read timeout     : 0.5s
  raised after     : 0.50s — ReadTimeout
  with no timeout= at all, that call waits for the full 3s, and
  against a server that never answers it waits forever.

6. Retry with backoff — and what must never be retried
======================================================
  server sent      : 429, 429, then 200
  final status     : 200 on attempt 3
  waits requested  : [1.0, 1.0]  (Retry-After: 1 overrode the schedule)
  real time taken  : 0.002s — the sleep was injected
  schedule alone   : [0.5, 1.0, 2.0, 4.0]
  with half jitter : [0.25, 0.5, 1.0, 2.0]
  404 retryable?   : False
  a 404 will be a 404 on the tenth try. Retrying it is a bug.

7. Session and connection reuse
===============================
  5 calls, one Session      : 1 TCP connection(s)
  5 calls, requests.get()   : 5 TCP connection(s)
  each extra connection is a handshake — and, over TLS, several.

8. Streaming a large body instead of loading it
===============================================
  bytes written    : 524288
  chunks read      : 64 of at most 8192 bytes
  peak held        : one chunk, not 524288 bytes
  sha256           : 918b1e0da676100095229452e431e466...
  and the summary  : {'count': 4.0, 'min': 12.0, 'max': 22.0, 'mean': 17.0}

$ python3 examples/stdlib_demo.py


1. urllib.request — a GET with a query string
=============================================
  status      : 200 OK
  content type: application/json; charset=utf-8
  count       : 4
  first row   : {'station': 'ALPHA', 'hour': 0, 'celsius': 12.0}
  note        : you encoded the query, decoded the bytes, and
                parsed the JSON yourself. requests does all three.

2. urllib.request — a 404 is an EXCEPTION, not a status
=======================================================
  raised      : HTTPError
  status      : 404
  detail      : no such station
  note        : this is the big behavioural difference. urllib
                raises on 4xx and 5xx; requests returns a
                response and lets you decide.

3. urllib.request — a POST with a JSON body
===========================================
  status      : 201
  server saw  : method=POST bytes=43
  server saw  : content_type=application/json
  echoed json : {'station': 'ALPHA', 'note': 'hand rolled'}

4. http.client — the layer underneath both
==========================================
  status      : 200 OK
  headers     : 4 of them
  body bytes  : 164
  count       : 2
  connections : 1 opened for those 2 requests
  note        : http.client speaks the protocol and nothing
                more — no redirects, no pooling, no decoding.

5. What each layer costs you
============================
  http.client     : the protocol, exactly. You manage everything.
  urllib.request  : redirects and a bit of convenience; verbose,
                    and it raises on 4xx/5xx.
  requests        : params=, .json(), Session pooling, retries via
                    an adapter, streaming. One pip install away.

$ python3 examples/httpx_demo.py

httpx 0.28.1

1. The requests-shaped API, unchanged
=====================================
  status        : 200
  path sent     : /api/readings?station=ALPHA
  count         : 4
  .text / .content / .json() all exist, same as requests

2. The default timeout — the difference that matters
====================================================
  httpx.Client() default timeout : Timeout(timeout=5.0)
  requests has no default timeout at all. A missing timeout=
  in requests hangs; in httpx it gives up after 5 seconds.

3. raise_for_status, and a 404
==============================
  status        : 404
  is_success    : False
  raised        : HTTPStatusError

4. Streaming
============
  bytes         : 262144 in 32 chunk(s)

5. What httpx adds
==================
  * a default timeout;
  * the same API in sync and async (httpx.AsyncClient);
  * HTTP/2 support, if you install the extra: pip install 'httpx[http2]'
    and pass http2=True. Without that extra it speaks HTTP/1.1,
    exactly like requests.
  * a strict URL and header model, which catches some mistakes
    requests would let through.

test-run.txt

$ bash tests/run_tests.sh

Day 078 — Talk to a Server You Control

1. The tools
  ok: pytest --version reports a pytest ( pytest 9.1.1 )
  ok: requests is importable ( 2.34.2 )

2. The local test server behaves as the lab claims
  ok: the server binds 127.0.0.1 and nothing else
  ok: the port is ephemeral, not a hard-coded 8000
  ok: /api/readings answers 200
  ok: /api/missing answers 404
  ok: /api/broken answers 500
  ok: /old/readings answers 301
  ok: POST /api/echo answers 201
  ok: /api/flaky answers 429 once armed
  ok: the 429 carries a Retry-After header
  ok: /api/large?kb=8 returns exactly 8192 bytes

3. The demonstrations run
  ok: examples/raw_socket_demo.py exits 0
  ok: the hand-typed request line really is HTTP text
  ok: the raw response begins with a status line
  ok: examples/stdlib_demo.py exits 0 — the standard library really can do this
  ok: urllib.request raises on a 404 where requests returns a response
  ok: http.client reuses one connection for two requests
  ok: examples/demo.py exits 0
  ok: demo.py shows one Session using one connection for five calls
  ok: demo.py shows five bare calls opening five connections
  ok: demo.py's timeout section really raises ReadTimeout
  ok: params= percent-encodes a value containing a space and an ampersand
  ok: streaming reads 512 KiB as 64 chunks, not one body
  ok: examples/httpx_demo.py exits 0 whether or not httpx is installed

4. The reference suite
  ok: pytest examples exits 0
  ok: pytest examples reports 48 passed
  ok: behaviour asserted: a_read_timeout_fires_against_the_slow_endpoint
  ok: behaviour asserted: retry_succeeds_after_exactly_the_expected_number_of_attempts
  ok: behaviour asserted: a_missing_station_raises_a_domain_error_with_a_clean_message
  ok: behaviour asserted: one_session_reuses_one_connection_for_many_requests
  ok: behaviour asserted: streaming_writes_the_whole_body_in_many_small_chunks
  ok: a read timeout of 0.4s fires in 0.41s against a 3s endpoint

5. Nothing here touches the internet — proved, not promised
  ok: the whole example suite passes with all non-loopback sockets blocked
  ok: the offline guard is real (a request to a public site is blocked)
  ok: no example or starter file names a real remote host
  ok: no file hard-codes port 8000 (the collision waiting to happen)

6. The Day 74 payoff: tests that need no server at all
  ok: the fake-session suite passes with no server module present at all
  ok: that suite is 20 real tests, not a placeholder
  ok: the isolated directory really lacks the server
  ok: and it passes with every non-loopback socket blocked
  ok: every networked function takes session (and sleep, and jitter) as parameters

7. Retry policy: the statuses, checked one at a time
  ok: 429 and 5xx are retryable; 4xx and every success code are not
  ok: the backoff doubles, caps at 8s, jitters into the top half of each slot

8. Your work in starter/
  ok: pytest starter exits 0
  (exercises unfinished — structural checks only)
  ok: starter/client.py defines fetch_readings for you to fill in
  ok: starter/client.py defines describe_failure for you to fill in
  ok: starter/client.py defines backoff_delays for you to fill in
  ok: starter/client.py defines get_with_retry for you to fill in
  ok: starter/client.py defines make_session for you to fill in
  ok: starter/client.py defines stream_to_file for you to fill in
  ok: unfinished exercises are skipped, so the suite is green from minute one
  ok: starter/test_client.py carries the exercise-7 fake-session section

9. The captured output matches what the code does now
  ok: expected-output/sample-run.txt exists and is not empty
  ok: expected-output/pytest-runs.txt exists and is not empty
  ok: expected-output/test-run.txt exists and is not empty
  ok: expected-output/FIELDS.md exists and is not empty
  ok: the captured pytest run agrees with today's count of 48

58 checks, 0 failure(s).

Source files

examples/client.py (9218 bytes)
"""The reference client — the shape every HTTP call in this course will take.

Three rules are baked into every function here, and they are the three that
separate a script from something you can leave running:

  1. Every request has a TIMEOUT. `requests` has no default one. A call
     without a timeout can wait forever on a socket that will never answer,
     and "forever" is not an exaggeration.
  2. Every function takes its `session` as a PARAMETER. That is Day 74's
     boundary argument applied to the network: a function that calls
     `requests.get` directly can only be tested with a real server or a
     patch, while a function that takes a session can be tested with a
     fifteen-line fake and no server at all.
  3. Retries are only for the failures worth retrying — 429 and 5xx. A 400,
     a 401 or a 404 will give the same answer however many times you ask.

Nothing here knows anything about a particular host. `base_url` is passed
in, which is why the same client works against the local test server and
would work against a real API.
"""

from __future__ import annotations

import hashlib
import os
import random
import time
from dataclasses import dataclass
from typing import Any, Callable, Iterable, Protocol

import requests

# 10 seconds to finish reading the body, 3.05 to get the connection open.
# The odd 3.05 is the documented habit: connect timeouts slightly larger
# than a multiple of 3 line up with the TCP retransmission window.
DEFAULT_TIMEOUT: tuple[float, float] = (3.05, 10.0)

# The families worth trying again. Everything else is a permanent answer.
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})


class ReadingsError(Exception):
    """Base class for everything this client raises on purpose."""


class ReadingsUnavailable(ReadingsError):
    """The server could not be reached, or kept failing."""


class StationNotFound(ReadingsError):
    """The server answered clearly: there is no such station."""


class HttpSession(Protocol):
    """The slice of `requests.Session` this client actually uses.

    Writing the boundary down as a Protocol costs three lines and buys two
    things: mypy checks that a fake really implements it, and a reader can
    see exactly how much of `requests` the code depends on.
    """

    def get(self, url: str, **kwargs: Any) -> Any: ...

    def post(self, url: str, **kwargs: Any) -> Any: ...


@dataclass(frozen=True)
class Reading:
    station: str
    hour: int
    celsius: float


def make_session(user_agent: str = "day078-lab/1.0 (course exercise)") -> requests.Session:
    """A Session with the headers every request from this client should carry.

    A Session is two things at once: a place to put shared configuration,
    and a pool of open TCP connections. The second is the one people forget,
    and it is worth roughly the whole cost of the handshake per request.
    """
    session = requests.Session()
    session.headers.update(
        {
            "User-Agent": user_agent,
            "Accept": "application/json",
        }
    )
    token = os.environ.get("READINGS_TOKEN")
    if token:
        # Read from the environment, never written in the file. A token in
        # source control is a token you have to rotate.
        session.headers["Authorization"] = f"Bearer {token}"
    return session


def fetch_readings(
    base_url: str,
    station: str,
    *,
    session: HttpSession,
    timeout: tuple[float, float] | float = DEFAULT_TIMEOUT,
) -> list[Reading]:
    """Fetch one station's readings. The whole point is the `session=` parameter.

    Note `params=` rather than string concatenation. `requests` percent-encodes
    the values for you, so a station called `ALPHA ONE&BRAVO` produces a legal
    URL instead of a second query parameter you did not mean to send.
    """
    response = session.get(
        f"{base_url}/api/readings",
        params={"station": station},
        timeout=timeout,
    )
    if response.status_code == 404:
        raise StationNotFound(f"no station named {station!r}")
    response.raise_for_status()
    payload = response.json()
    return [
        Reading(station=row["station"], hour=int(row["hour"]), celsius=float(row["celsius"]))
        for row in payload["readings"]
    ]


def describe_failure(response: Any) -> str:
    """Turn a failed response into one sentence a human can act on.

    A traceback is for the programmer. This is for the person running the
    program, and it is the difference between "it crashed" and "the server
    said the station does not exist".
    """
    families = {
        3: "redirection",
        4: "your request was rejected",
        5: "the server failed",
    }
    family = families.get(response.status_code // 100, "unexpected status")
    detail = ""
    content_type = response.headers.get("Content-Type", "")
    if content_type.startswith("application/json"):
        try:
            body = response.json()
        except ValueError:
            body = None
        if isinstance(body, dict) and "detail" in body:
            detail = f" — {body['detail']}"
        elif isinstance(body, dict) and "error" in body:
            detail = f" — {body['error']}"
    return f"HTTP {response.status_code} ({family}){detail}"


def backoff_delays(
    attempts: int,
    *,
    base: float = 0.5,
    cap: float = 8.0,
    jitter: Callable[[], float] = random.random,
) -> list[float]:
    """The waits between `attempts` tries: exponential, capped, with jitter.

    The jitter is not decoration. Without it, a hundred clients that all saw
    the same outage retry at the same instant, and the server that was
    recovering is knocked over again by a synchronised wave. `jitter` is a
    parameter so a test can pass `lambda: 0.0` and assert exact numbers.
    """
    if attempts < 1:
        raise ValueError("attempts must be at least 1")
    delays = []
    for i in range(attempts - 1):
        raw = min(cap, base * (2**i))
        delays.append(round(raw * (0.5 + 0.5 * jitter()), 4))
    return delays


def get_with_retry(
    url: str,
    *,
    session: HttpSession,
    attempts: int = 4,
    timeout: tuple[float, float] | float = DEFAULT_TIMEOUT,
    sleep: Callable[[float], None] = time.sleep,
    jitter: Callable[[], float] = random.random,
    params: dict[str, Any] | None = None,
) -> Any:
    """GET with retries on 429 and 5xx — and on nothing else.

    `sleep` and `jitter` arrive as parameters for exactly the reason Day 74
    gave: a test can pass a recording sleep and prove the schedule in
    microseconds, without anything ever waiting.
    """
    delays = backoff_delays(attempts, jitter=jitter)
    last_error: Exception | None = None
    for attempt in range(1, attempts + 1):
        try:
            response = session.get(url, params=params, timeout=timeout)
        except requests.exceptions.RequestException as exc:
            # A transport-level failure: DNS, connection refused, timeout.
            # There is no status code here because there is no response.
            last_error = exc
        else:
            if response.status_code not in RETRY_STATUSES:
                return response
            last_error = ReadingsUnavailable(describe_failure(response))
            # A server that says how long to wait knows better than we do.
            retry_after = response.headers.get("Retry-After")
            if retry_after is not None and attempt <= len(delays):
                try:
                    delays[attempt - 1] = min(float(retry_after), 8.0)
                except ValueError:
                    pass
        if attempt <= len(delays):
            sleep(delays[attempt - 1])
    raise ReadingsUnavailable(f"gave up after {attempts} attempts: {last_error}")


def stream_to_file(
    url: str,
    destination: str,
    *,
    session: HttpSession,
    chunk_size: int = 8192,
    timeout: tuple[float, float] | float = DEFAULT_TIMEOUT,
) -> tuple[int, int, str]:
    """Download a body without ever holding all of it in memory.

    Returns (bytes written, chunks read, sha256 hex digest). `stream=True`
    means the response headers have arrived and the body has not; the body
    is pulled a chunk at a time as you iterate.
    """
    digest = hashlib.sha256()
    total = 0
    chunks = 0
    with session.get(url, stream=True, timeout=timeout) as response:
        response.raise_for_status()
        with open(destination, "wb") as handle:
            for chunk in response.iter_content(chunk_size=chunk_size):
                if not chunk:
                    continue
                handle.write(chunk)
                digest.update(chunk)
                total += len(chunk)
                chunks += 1
    return total, chunks, digest.hexdigest()


def summarise(readings: Iterable[Reading]) -> dict[str, float]:
    """Pure function, no network. The part worth testing without a server."""
    values = [r.celsius for r in readings]
    if not values:
        raise ReadingsError("cannot summarise an empty set of readings")
    return {
        "count": float(len(values)),
        "min": min(values),
        "max": max(values),
        "mean": round(sum(values) / len(values), 4),
    }
examples/conftest.py (855 bytes)
"""Fixtures for the example suite: one local server for the whole session.

The server is started once, on an ephemeral port, and shut down when the
last test finishes. Starting it per test would be correct but wasteful; the
tests that need a clean counter call `/control/reset` themselves.
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Iterator

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parent))

from demo_server import CountingServer, base_url, running_server  # noqa: E402


@pytest.fixture(scope="session")
def server() -> Iterator[CountingServer]:
    with running_server() as srv:
        yield srv


@pytest.fixture(scope="session")
def base(server: CountingServer) -> str:
    """The address the local test server actually bound to, this run."""
    return base_url(server)
examples/demo_server.py (11981 bytes)
"""A small HTTP server you control, built only from the standard library.

Everything in this lab talks to THIS server, on the loopback address
127.0.0.1, on a port the operating system picks at run time. Nothing here
opens a connection to the internet, and nothing here needs one.

Why a local server instead of a real public API:

  * it is fast — no DNS lookup, no round trip across the world;
  * it is deterministic — the same bytes every run, so a test can assert;
  * it is honest — you can ask it for a 500, a 429 or a two-second delay,
    and no real service has to be harmed to produce them;
  * it works on a plane, and it will still work in five years.

The endpoints exist to produce the interesting cases:

  GET  /api/readings          200 with a JSON body
  GET  /api/readings?station= 200, filtered; also echoes the parsed query
  GET  /api/missing           404 with a JSON error body
  GET  /api/broken            500 with a JSON error body
  GET  /old/readings          301 permanent redirect to /api/readings
  GET  /api/flaky             429 with Retry-After for the first N calls,
                              then 200 (call /control/reset to arm it)
  GET  /api/slow?seconds=2    sleeps, then 200 — lets a timeout really fire
  GET  /api/large?kb=512      a large body, for streaming
  POST /api/echo              echoes method, headers and body back as JSON
  GET  /control/reset?fail=2  arms the flaky endpoint, resets counters
  GET  /control/stats         how many TCP connections and requests so far

Run it on its own if you want to poke at it by hand:

    python3 examples/demo_server.py

It prints the address it bound to and serves until you press Ctrl-C.
"""

from __future__ import annotations

import contextlib
import json
import socket
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Iterator
from urllib.parse import parse_qs, urlparse

READINGS: list[dict[str, object]] = [
    {"station": "ALPHA", "hour": 0, "celsius": 12.0},
    {"station": "ALPHA", "hour": 6, "celsius": 14.0},
    {"station": "ALPHA", "hour": 12, "celsius": 20.0},
    {"station": "ALPHA", "hour": 18, "celsius": 22.0},
    {"station": "BRAVO", "hour": 0, "celsius": 3.0},
    {"station": "BRAVO", "hour": 12, "celsius": 9.0},
]


class CountingServer(ThreadingHTTPServer):
    """A threading server that counts the TCP connections it accepts.

    The connection count is what makes "a Session reuses one connection"
    something a test can PROVE rather than something a lesson asserts.
    `get_request` is called once per accepted connection, not once per
    request, so the two numbers diverge exactly when keep-alive works.
    """

    daemon_threads = True
    allow_reuse_address = True

    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        self.lock = threading.Lock()
        self.connections = 0
        self.requests = 0
        self.flaky_remaining = 0
        self.flaky_calls = 0

    def get_request(self):  # noqa: D102 - inherited contract
        conn, addr = super().get_request()
        with self.lock:
            self.connections += 1
        return conn, addr


class DemoHandler(BaseHTTPRequestHandler):
    # HTTP/1.1 is what makes keep-alive possible. With HTTP/1.0 the server
    # closes after every response and connection reuse cannot be shown.
    protocol_version = "HTTP/1.1"
    server_version = "DayLab/1.0"
    sys_version = ""

    # ---- plumbing ---------------------------------------------------------

    def log_message(self, fmt: str, *args) -> None:
        """Silence the default stderr access log; tests want clean output."""

    def version_string(self) -> str:
        """The Server header. Kept short and stable so captures are diffable."""
        return self.server_version

    def _send(
        self,
        status: int,
        body: bytes,
        content_type: str = "application/json",
        extra_headers: dict[str, str] | None = None,
    ) -> None:
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Content-Length", str(len(body)))
        for name, value in (extra_headers or {}).items():
            self.send_header(name, value)
        self.end_headers()
        self.wfile.write(body)

    def _send_json(
        self,
        status: int,
        payload: object,
        extra_headers: dict[str, str] | None = None,
    ) -> None:
        body = json.dumps(payload, indent=None, separators=(",", ":")).encode("utf-8")
        self._send(status, body, "application/json; charset=utf-8", extra_headers)

    # ---- routing ----------------------------------------------------------

    def do_GET(self) -> None:  # noqa: N802 - name fixed by BaseHTTPRequestHandler
        parsed = urlparse(self.path)
        query = parse_qs(parsed.query)
        with self.server.lock:
            self.server.requests += 1

        if parsed.path == "/api/readings":
            self._readings(query)
        elif parsed.path == "/api/search":
            # Echoes the query string exactly as the server parsed it. This
            # is how you see what your client really sent, which is the
            # whole argument for `params=` over string concatenation.
            self._send_json(200, {"raw_query": parsed.query, "parsed": dict(query)})
        elif parsed.path == "/api/missing":
            self._send_json(
                404,
                {"error": "not_found", "detail": "no such station", "path": parsed.path},
            )
        elif parsed.path == "/api/broken":
            self._send_json(500, {"error": "internal", "detail": "the server fell over"})
        elif parsed.path == "/old/readings":
            self._send(
                301,
                b"",
                "text/plain; charset=utf-8",
                {"Location": "/api/readings"},
            )
        elif parsed.path == "/api/flaky":
            self._flaky()
        elif parsed.path == "/api/slow":
            time.sleep(float(query.get("seconds", ["2"])[0]))
            self._send_json(200, {"slept": float(query.get("seconds", ["2"])[0])})
        elif parsed.path == "/api/large":
            self._large(query)
        elif parsed.path == "/control/reset":
            with self.server.lock:
                self.server.flaky_remaining = int(query.get("fail", ["2"])[0])
                self.server.flaky_calls = 0
            self._send_json(200, {"flaky_remaining": self.server.flaky_remaining})
        elif parsed.path == "/control/stats":
            with self.server.lock:
                stats = {
                    "connections": self.server.connections,
                    "requests": self.server.requests,
                    "flaky_calls": self.server.flaky_calls,
                }
            self._send_json(200, stats)
        else:
            self._send_json(404, {"error": "not_found", "path": parsed.path})

    def do_POST(self) -> None:  # noqa: N802 - name fixed by the base class
        parsed = urlparse(self.path)
        with self.server.lock:
            self.server.requests += 1
        length = int(self.headers.get("Content-Length", "0"))
        raw = self.rfile.read(length) if length else b""

        if parsed.path != "/api/echo":
            self._send_json(404, {"error": "not_found", "path": parsed.path})
            return

        try:
            parsed_body: object = json.loads(raw.decode("utf-8")) if raw else None
        except json.JSONDecodeError:
            parsed_body = None

        self._send_json(
            201,
            {
                "method": "POST",
                "path": parsed.path,
                "content_type": self.headers.get("Content-Type"),
                "user_agent": self.headers.get("User-Agent"),
                "authorization_seen": self.headers.get("Authorization") is not None,
                "body_bytes": length,
                "json": parsed_body,
            },
        )

    # ---- individual endpoints --------------------------------------------

    def _readings(self, query: dict[str, list[str]]) -> None:
        station = query.get("station", [None])[0]
        known = {str(r["station"]) for r in READINGS}
        if station is not None and station not in known:
            self._send_json(
                404,
                {
                    "error": "not_found",
                    "detail": f"no station named {station}",
                    "known": sorted(known),
                },
            )
            return
        rows = [r for r in READINGS if station is None or r["station"] == station]
        self._send_json(
            200,
            {
                "station": station,
                "count": len(rows),
                "query_seen": {k: v for k, v in query.items()},
                "readings": rows,
            },
        )

    def _flaky(self) -> None:
        with self.server.lock:
            self.server.flaky_calls += 1
            attempt = self.server.flaky_calls
            if self.server.flaky_remaining > 0:
                self.server.flaky_remaining -= 1
                remaining = self.server.flaky_remaining
                rate_limited = True
            else:
                remaining = 0
                rate_limited = False
        if rate_limited:
            self._send_json(
                429,
                {"error": "rate_limited", "attempt": attempt, "still_failing": remaining},
                {"Retry-After": "1"},
            )
        else:
            self._send_json(200, {"ok": True, "attempt": attempt})

    def _large(self, query: dict[str, list[str]]) -> None:
        kilobytes = int(query.get("kb", ["512"])[0])
        # A repeating 1 KiB line, so the body is large but perfectly
        # predictable: every byte is derivable from `kb`.
        line = (b"x" * 1023) + b"\n"
        body = line * kilobytes
        self._send(200, body, "text/plain; charset=utf-8")


def wait_until_accepting(host: str, port: int, timeout: float = 5.0) -> None:
    """Poll the port until a connection succeeds, or give up loudly.

    This is the readiness loop that replaces `time.sleep(1)`. A fixed sleep
    is both too slow (usually) and too short (sometimes), which is the
    recipe for a test that fails once a fortnight on a loaded machine.
    """
    deadline = time.monotonic() + timeout
    last_error: OSError | None = None
    while time.monotonic() < deadline:
        try:
            with socket.create_connection((host, port), timeout=0.25):
                return
        except OSError as exc:  # not up yet
            last_error = exc
            time.sleep(0.01)
    raise RuntimeError(f"the local test server never became ready on port {port}: {last_error}")


@contextlib.contextmanager
def running_server() -> Iterator[CountingServer]:
    """Start the server on an ephemeral port and shut it down afterwards.

    Binding port 0 asks the operating system for any free port, which is the
    only way to avoid colliding with whatever the learner already has
    running. The real port is read back from `server_address` afterwards.
    """
    server = CountingServer(("127.0.0.1", 0), DemoHandler)
    thread = threading.Thread(target=server.serve_forever, name="demo-server", daemon=True)
    thread.start()
    try:
        wait_until_accepting(*server.server_address[:2])
        yield server
    finally:
        server.shutdown()
        server.server_close()
        thread.join(timeout=5)


def base_url(server: CountingServer) -> str:
    host, port = server.server_address[:2]
    return f"http://{host}:{port}"


if __name__ == "__main__":
    with running_server() as srv:
        print(f"serving on {base_url(srv)} — press Ctrl-C to stop")
        try:
            while True:
                time.sleep(0.5)
        except KeyboardInterrupt:
            print("\nstopped.")
examples/demo.py (7676 bytes)
"""The whole lab in one run: eight sections, one local server, no internet.

    python3 examples/demo.py

Each section is one of the operational points from the lesson, demonstrated
rather than asserted. Read the timings — they are where the argument lives.
"""

from __future__ import annotations

import sys
import tempfile
import time
from pathlib import Path

import requests

sys.path.insert(0, str(Path(__file__).resolve().parent))

from client import (  # noqa: E402
    DEFAULT_TIMEOUT,
    StationNotFound,
    backoff_delays,
    describe_failure,
    fetch_readings,
    get_with_retry,
    make_session,
    stream_to_file,
    summarise,
)
from demo_server import base_url, running_server  # noqa: E402


class RecordingSleep:
    """A spy, straight out of Day 74. It records waits and never waits."""

    def __init__(self) -> None:
        self.waits: list[float] = []

    def __call__(self, seconds: float) -> None:
        self.waits.append(round(seconds, 4))


def section(title: str) -> None:
    print()
    print(title)
    print("=" * len(title))


def main() -> int:
    with running_server() as server:
        root = base_url(server)
        session = make_session()

        section("1. A request and a response, in the pieces that matter")
        response = session.get(
            f"{root}/api/readings", params={"station": "ALPHA"}, timeout=DEFAULT_TIMEOUT
        )
        print(f"  request method   : {response.request.method}")
        print(f"  request path     : {response.request.path_url}")
        print(f"  request headers  : {len(response.request.headers)} sent")
        print(f"  status code      : {response.status_code} {response.reason}")
        print(f"  content type     : {response.headers['Content-Type']}")
        print(f"  .content is      : {type(response.content).__name__}, {len(response.content)} bytes")
        print(f"  .text is         : {type(response.text).__name__}, {len(response.text)} characters")
        print(f"  .json() is       : {type(response.json()).__name__} with keys {sorted(response.json())}")
        print(f"  elapsed          : {response.elapsed.total_seconds():.4f}s")

        section("2. params= versus gluing strings together")
        awkward = "ALPHA ONE&station=BRAVO"
        good = session.get(f"{root}/api/search", params={"station": awkward}, timeout=DEFAULT_TIMEOUT)
        bad = session.get(f"{root}/api/search?station={awkward}", timeout=DEFAULT_TIMEOUT)
        print(f"  station value    : {awkward!r}")
        print(f"  params=          : {good.request.path_url}")
        print(f"    server parsed  : {good.json()['parsed']}")
        print(f"  f-string         : {bad.request.path_url}")
        print(f"    server parsed  : {bad.json()['parsed']}")
        print("  the f-string smuggled a second parameter in. params= encoded it.")

        section("3. Status codes: a 404 is a successful response")
        missing = session.get(f"{root}/api/missing", timeout=DEFAULT_TIMEOUT)
        print(f"  the call itself  : returned normally, no exception")
        print(f"  status_code      : {missing.status_code}")
        print(f"  bool(response)   : {bool(missing)}   <- False for 4xx and 5xx")
        print(f"  described        : {describe_failure(missing)}")
        try:
            missing.raise_for_status()
        except requests.exceptions.HTTPError as exc:
            print(f"  raise_for_status : {type(exc).__name__}: {str(exc).split(' for url')[0]}")
        try:
            fetch_readings(root, "NOWHERE", session=session)
        except StationNotFound as exc:
            print(f"  the client raises: StationNotFound: {exc}")

        section("4. A redirect, followed and unfollowed")
        followed = session.get(f"{root}/old/readings", timeout=DEFAULT_TIMEOUT)
        print(f"  final status     : {followed.status_code}")
        print(f"  final url path   : {followed.url.rsplit('/', 1)[-1]}")
        print(f"  history          : {[r.status_code for r in followed.history]}")
        raw = session.get(f"{root}/old/readings", timeout=DEFAULT_TIMEOUT, allow_redirects=False)
        print(f"  unfollowed       : {raw.status_code}, Location: {raw.headers['Location']}")
        print("  301 is permanent — a client may cache it. 302 is temporary.")

        section("5. The timeout that is not there by default")
        started = time.monotonic()
        try:
            session.get(f"{root}/api/slow", params={"seconds": 3}, timeout=(3.05, 0.5))
        except requests.exceptions.Timeout as exc:
            waited = time.monotonic() - started
            print(f"  asked for        : 3 seconds of server work")
            print(f"  read timeout     : 0.5s")
            print(f"  raised after     : {waited:.2f}s — {type(exc).__name__}")
        print("  with no timeout= at all, that call waits for the full 3s, and")
        print("  against a server that never answers it waits forever.")

        section("6. Retry with backoff — and what must never be retried")
        session.get(f"{root}/control/reset", params={"fail": 2}, timeout=DEFAULT_TIMEOUT)
        sleeper = RecordingSleep()
        started = time.monotonic()
        result = get_with_retry(
            f"{root}/api/flaky",
            session=session,
            attempts=4,
            sleep=sleeper,
            jitter=lambda: 1.0,
        )
        print(f"  server sent      : 429, 429, then 200")
        print(f"  final status     : {result.status_code} on attempt {result.json()['attempt']}")
        print(f"  waits requested  : {sleeper.waits}  (Retry-After: 1 overrode the schedule)")
        print(f"  real time taken  : {time.monotonic() - started:.3f}s — the sleep was injected")
        print(f"  schedule alone   : {backoff_delays(5, jitter=lambda: 1.0)}")
        print(f"  with half jitter : {backoff_delays(5, jitter=lambda: 0.0)}")
        not_retried = session.get(f"{root}/api/missing", timeout=DEFAULT_TIMEOUT)
        print(f"  404 retryable?   : {not_retried.status_code in {429, 500, 502, 503, 504}}")
        print("  a 404 will be a 404 on the tenth try. Retrying it is a bug.")

        section("7. Session and connection reuse")
        before = server.connections
        with requests.Session() as pooled:
            for _ in range(5):
                pooled.get(f"{root}/api/readings", timeout=DEFAULT_TIMEOUT).close()
        with_session = server.connections - before
        before = server.connections
        for _ in range(5):
            requests.get(f"{root}/api/readings", timeout=DEFAULT_TIMEOUT).close()
        without_session = server.connections - before
        print(f"  5 calls, one Session      : {with_session} TCP connection(s)")
        print(f"  5 calls, requests.get()   : {without_session} TCP connection(s)")
        print("  each extra connection is a handshake — and, over TLS, several.")

        section("8. Streaming a large body instead of loading it")
        with tempfile.TemporaryDirectory() as tmp:
            destination = str(Path(tmp) / "large.txt")
            total, chunks, digest = stream_to_file(
                f"{root}/api/large?kb=512", destination, session=session, chunk_size=8192
            )
            print(f"  bytes written    : {total}")
            print(f"  chunks read      : {chunks} of at most 8192 bytes")
            print(f"  peak held        : one chunk, not {total} bytes")
            print(f"  sha256           : {digest[:32]}...")
        readings = fetch_readings(root, "ALPHA", session=session)
        print(f"  and the summary  : {summarise(readings)}")
        session.close()
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/fake_session.py (3235 bytes)
"""A fake session — the Day 74 payoff, in about forty lines.

`fetch_readings` and `get_with_retry` both take a `session` parameter. That
one design decision means the network can be replaced by this file, and the
tests that use it need no server, no socket, no port, and no waiting.

Compare the two shapes:

    def fetch(station):                      # untestable without a network
        return requests.get(URL, params=...) # or without patching

    def fetch(station, *, session):          # testable with FakeSession
        return session.get(URL, params=...)

FakeResponse implements only the slice of `requests.Response` the client
actually touches: `.status_code`, `.headers`, `.json()`, `.text`, and
`raise_for_status()`. That slice being small is itself information — it
tells you how little of `requests` your code depends on.
"""

from __future__ import annotations

import json as _json
from typing import Any

import requests


class FakeResponse:
    """The part of a response this client uses, and nothing else."""

    def __init__(
        self,
        status_code: int,
        payload: Any = None,
        headers: dict[str, str] | None = None,
        text: str | None = None,
    ) -> None:
        self.status_code = status_code
        self._payload = payload
        self.headers = headers or {"Content-Type": "application/json; charset=utf-8"}
        self.text = text if text is not None else _json.dumps(payload)

    def json(self) -> Any:
        if self._payload is None:
            raise ValueError("no JSON body")
        return self._payload

    def raise_for_status(self) -> None:
        if self.status_code >= 400:
            raise requests.exceptions.HTTPError(f"{self.status_code} error")

    def __enter__(self) -> "FakeResponse":
        return self

    def __exit__(self, *exc_info: object) -> None:
        return None


class FakeSession:
    """A scripted stand-in for `requests.Session`.

    It is a fake, a spy and a stub at once, exactly as Day 74 described:
    it answers from a script, it records every call, and a scripted item
    that happens to be an exception instance is raised instead of returned —
    which is how a test produces a ConnectionError or a Timeout on demand.
    """

    def __init__(self, script: list[Any] | None = None) -> None:
        self._script = list(script or [])
        self.calls: list[dict[str, Any]] = []

    def _next(self, method: str, url: str, kwargs: dict[str, Any]) -> Any:
        self.calls.append({"method": method, "url": url, **kwargs})
        if not self._script:
            raise AssertionError(
                f"the fake session ran out of scripted responses at call {len(self.calls)}"
            )
        item = self._script.pop(0)
        if isinstance(item, Exception):
            raise item
        return item

    def get(self, url: str, **kwargs: Any) -> Any:
        return self._next("GET", url, kwargs)

    def post(self, url: str, **kwargs: Any) -> Any:
        return self._next("POST", url, kwargs)

    @property
    def timeouts(self) -> list[Any]:
        """Every timeout value passed in — so a test can prove one was set."""
        return [call.get("timeout") for call in self.calls]
examples/httpx_demo.py (3767 bytes)
"""The same calls in httpx — the modern alternative, if you have it.

httpx is NOT in this lab's `requirements/requirements.txt`, deliberately:
the lab's argument works with `requests` alone, and a lab should not make
you install a package to make a point about a package. If httpx happens to
be installed, this file runs and shows the differences; if it is not, it
says so and exits 0.

    python3 examples/httpx_demo.py

Two differences are worth watching for:

  * `httpx.Client` has a DEFAULT timeout of five seconds. `requests` has
    none. That single design decision is httpx's strongest argument.
  * the same code shape works with `httpx.AsyncClient` and `await`, which
    is what you want the day you need fifty model calls in flight at once.
"""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from demo_server import base_url, running_server  # noqa: E402

try:
    import httpx
except ImportError:
    print("httpx is not installed in this environment, so this demo has nothing")
    print("to run. That is expected: httpx is not one of this lab's dependencies.")
    print("Install it with `.venv/bin/pip install httpx` if you want to compare.")
    raise SystemExit(0)


def section(title: str) -> None:
    print()
    print(title)
    print("=" * len(title))


def main() -> int:
    print(f"httpx {httpx.__version__}")
    with running_server() as server:
        root = base_url(server)

        section("1. The requests-shaped API, unchanged")
        with httpx.Client(headers={"User-Agent": "day078-httpx/1.0"}, timeout=10.0) as client:
            response = client.get(f"{root}/api/readings", params={"station": "ALPHA"})
            print(f"  status        : {response.status_code}")
            print(f"  path sent     : {response.request.url.raw_path.decode()}")
            print(f"  count         : {response.json()['count']}")
            print(f"  .text / .content / .json() all exist, same as requests")

            section("2. The default timeout — the difference that matters")
            default = httpx.Client()
            print(f"  httpx.Client() default timeout : {default.timeout}")
            default.close()
            print("  requests has no default timeout at all. A missing timeout=")
            print("  in requests hangs; in httpx it gives up after 5 seconds.")

            section("3. raise_for_status, and a 404")
            missing = client.get(f"{root}/api/missing")
            print(f"  status        : {missing.status_code}")
            print(f"  is_success    : {missing.is_success}")
            try:
                missing.raise_for_status()
            except httpx.HTTPStatusError as exc:
                print(f"  raised        : {type(exc).__name__}")

            section("4. Streaming")
            total = 0
            chunks = 0
            with client.stream("GET", f"{root}/api/large?kb=256") as stream:
                for chunk in stream.iter_bytes(8192):
                    total += len(chunk)
                    chunks += 1
            print(f"  bytes         : {total} in {chunks} chunk(s)")

            section("5. What httpx adds")
            print("  * a default timeout;")
            print("  * the same API in sync and async (httpx.AsyncClient);")
            print("  * HTTP/2 support, if you install the extra: pip install 'httpx[http2]'")
            print("    and pass http2=True. Without that extra it speaks HTTP/1.1,")
            print("    exactly like requests.")
            print("  * a strict URL and header model, which catches some mistakes")
            print("    requests would let through.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/raw_socket_demo.py (3657 bytes)
"""HTTP with no HTTP library at all — just a socket and some text.

This is the demystifying one. `requests` is a convenience; underneath it,
an HTTP request is a few lines of ASCII sent down a TCP connection, and an
HTTP response is a few lines of ASCII sent back. You can type it by hand,
and here we do.

Run it:

    python3 examples/raw_socket_demo.py

Everything it prints was really sent and really received over a loopback
connection to the local test server started by this script.
"""

from __future__ import annotations

import socket
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from demo_server import running_server  # noqa: E402


def show(label: str, raw: bytes) -> None:
    print(f"  {label} ({len(raw)} bytes)")
    print("  " + "-" * (len(label) + 12))
    text = raw.decode("utf-8", errors="replace")
    lines = text.split("\r\n")
    while lines and lines[-1] == "":
        lines.pop()
    for line in lines:
        print(f"    {line}\\r\\n")
    print("    \\r\\n          <- the blank line: headers finished")
    print()


def main() -> int:
    with running_server() as server:
        host, port = server.server_address[:2]

        print("1. The request, typed out by hand")
        print("=================================")
        print("  Four parts: a request line, some headers, a blank line, and")
        print("  (for a GET) no body. Every line ends with carriage return +")
        print("  line feed, and the blank line is what says 'headers finished'.")
        print()

        request = (
            "GET /api/readings?station=ALPHA HTTP/1.1\r\n"
            f"Host: {host}:{port}\r\n"
            "User-Agent: day078-raw-socket/1.0\r\n"
            "Accept: application/json\r\n"
            "Connection: close\r\n"
            "\r\n"
        ).encode("ascii")
        show("bytes sent", request)

        with socket.create_connection((host, port), timeout=5.0) as sock:
            sock.sendall(request)
            received = b""
            while True:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                received += chunk

        print("2. The response, exactly as it arrived")
        print("======================================")
        print("  Three parts: a status line, some headers, a blank line, then")
        print("  the body. The body here is JSON, but HTTP neither knows nor")
        print("  cares — Content-Type is what says so.")
        print()

        head, _, body = received.partition(b"\r\n\r\n")
        show("status line and headers", head + b"\r\n")
        print(f"  body ({len(body)} bytes)")
        print("  " + "-" * 18)
        print(f"    {body.decode('utf-8')}")
        print()

        print("3. Reading the pieces back")
        print("==========================")
        lines = head.decode("ascii").split("\r\n")
        version, status, reason = lines[0].split(" ", 2)
        print(f"    HTTP version : {version}")
        print(f"    status code  : {status}")
        print(f"    reason phrase: {reason}")
        for line in lines[1:]:
            name, _, value = line.partition(": ")
            print(f"    header       : {name} = {value}")
        print()
        print("  That is the entire protocol. Everything `requests` adds is")
        print("  convenience on top of these bytes: building the request line,")
        print("  encoding the query string, pooling the connection, decoding")
        print("  the body, and turning a status code into an exception.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/stdlib_demo.py (5405 bytes)
"""The same four calls, without installing anything.

`requests` is not on the machine of every person who will run your script.
`urllib.request` and `http.client` are, because they ship with Python. This
file makes the three-way comparison concrete by doing the same work with
each of them against the same local test server.

Run it:

    python3 examples/stdlib_demo.py

Nothing here imports `requests`, and nothing here leaves 127.0.0.1.
"""

from __future__ import annotations

import http.client
import json
import sys
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from demo_server import base_url, running_server  # noqa: E402


def section(title: str) -> None:
    print()
    print(title)
    print("=" * len(title))


def main() -> int:
    with running_server() as server:
        root = base_url(server)
        host, port = server.server_address[:2]

        section("1. urllib.request — a GET with a query string")
        query = urllib.parse.urlencode({"station": "ALPHA"})
        url = f"{root}/api/readings?{query}"
        req = urllib.request.Request(
            url,
            headers={"User-Agent": "day078-urllib/1.0", "Accept": "application/json"},
        )
        # A timeout is a parameter here too, and it is just as optional and
        # just as necessary. Note what you have to do by hand: build the
        # query string, set the headers, decode the bytes, parse the JSON.
        with urllib.request.urlopen(req, timeout=10.0) as response:
            payload = json.loads(response.read().decode("utf-8"))
        print(f"  status      : {response.status} {response.reason}")
        print(f"  content type: {response.headers['Content-Type']}")
        print(f"  count       : {payload['count']}")
        print(f"  first row   : {payload['readings'][0]}")
        print("  note        : you encoded the query, decoded the bytes, and")
        print("                parsed the JSON yourself. requests does all three.")

        section("2. urllib.request — a 404 is an EXCEPTION, not a status")
        try:
            with urllib.request.urlopen(f"{root}/api/missing", timeout=10.0):
                print("  unreachable")
        except urllib.error.HTTPError as exc:
            body = json.loads(exc.read().decode("utf-8"))
            print(f"  raised      : {type(exc).__name__}")
            print(f"  status      : {exc.code}")
            print(f"  detail      : {body['detail']}")
            print("  note        : this is the big behavioural difference. urllib")
            print("                raises on 4xx and 5xx; requests returns a")
            print("                response and lets you decide.")

        section("3. urllib.request — a POST with a JSON body")
        data = json.dumps({"station": "ALPHA", "note": "hand rolled"}).encode("utf-8")
        req = urllib.request.Request(
            f"{root}/api/echo",
            data=data,
            method="POST",
            headers={
                "Content-Type": "application/json",
                "User-Agent": "day078-urllib/1.0",
            },
        )
        with urllib.request.urlopen(req, timeout=10.0) as response:
            echoed = json.loads(response.read().decode("utf-8"))
        print(f"  status      : {response.status}")
        print(f"  server saw  : method={echoed['method']} bytes={echoed['body_bytes']}")
        print(f"  server saw  : content_type={echoed['content_type']}")
        print(f"  echoed json : {echoed['json']}")

        section("4. http.client — the layer underneath both")
        before = server.connections
        conn = http.client.HTTPConnection(host, port, timeout=10.0)
        try:
            conn.request(
                "GET",
                "/api/readings?station=BRAVO",
                headers={"Accept": "application/json", "User-Agent": "day078-httpclient/1.0"},
            )
            response = conn.getresponse()
            raw = response.read()
            print(f"  status      : {response.status} {response.reason}")
            print(f"  headers     : {len(response.getheaders())} of them")
            print(f"  body bytes  : {len(raw)}")
            print(f"  count       : {json.loads(raw.decode('utf-8'))['count']}")
            # The same connection, used twice. This is what a Session does
            # for you automatically; here it is manual.
            conn.request("GET", "/control/stats", headers={"Accept": "application/json"})
            conn.getresponse().read()
            print(f"  connections : {server.connections - before} opened for those 2 requests")
            print("  note        : http.client speaks the protocol and nothing")
            print("                more — no redirects, no pooling, no decoding.")
        finally:
            conn.close()

        section("5. What each layer costs you")
        print("  http.client     : the protocol, exactly. You manage everything.")
        print("  urllib.request  : redirects and a bit of convenience; verbose,")
        print("                    and it raises on 4xx/5xx.")
        print("  requests        : params=, .json(), Session pooling, retries via")
        print("                    an adapter, streaming. One pip install away.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
examples/test_client.py (10115 bytes)
"""The reference suite, run against the local test server on 127.0.0.1.

Every test here opens a real socket — to a server this process started, on
a port the operating system chose, serving fixture data. None of them
touches the internet, and none of them is slow: the whole file finishes in
well under a second because the only "slow" endpoint is used precisely to
prove that a timeout fires.
"""

from __future__ import annotations

import time

import pytest
import requests

from client import (
    DEFAULT_TIMEOUT,
    RETRY_STATUSES,
    Reading,
    ReadingsUnavailable,
    StationNotFound,
    backoff_delays,
    describe_failure,
    fetch_readings,
    get_with_retry,
    make_session,
    stream_to_file,
    summarise,
)


class RecordingSleep:
    def __init__(self) -> None:
        self.waits: list[float] = []

    def __call__(self, seconds: float) -> None:
        self.waits.append(round(seconds, 4))


@pytest.fixture
def session():
    s = make_session()
    yield s
    s.close()


# --- 1. fetch and parse JSON ------------------------------------------------


def test_fetch_readings_returns_parsed_objects(base, session):
    readings = fetch_readings(base, "ALPHA", session=session)
    assert len(readings) == 4
    assert all(isinstance(r, Reading) for r in readings)
    assert readings[0] == Reading(station="ALPHA", hour=0, celsius=12.0)


def test_summary_of_the_fetched_readings(base, session):
    assert summarise(fetch_readings(base, "ALPHA", session=session)) == {
        "count": 4.0,
        "min": 12.0,
        "max": 22.0,
        "mean": 17.0,
    }


def test_params_are_encoded_not_concatenated(base, session):
    awkward = "ALPHA ONE&station=BRAVO"
    good = session.get(f"{base}/api/search", params={"station": awkward}, timeout=DEFAULT_TIMEOUT)
    assert good.json()["parsed"]["station"] == [awkward]
    assert good.json()["raw_query"] == "station=ALPHA+ONE%26station%3DBRAVO"
    bad = session.get(f"{base}/api/search?station={awkward}", timeout=DEFAULT_TIMEOUT)
    # The unencoded ampersand became a second parameter the caller never meant.
    assert bad.json()["parsed"]["station"] == ["ALPHA ONE", "BRAVO"]


def test_text_content_and_json_are_three_different_things(base, session):
    response = session.get(f"{base}/api/readings", timeout=DEFAULT_TIMEOUT)
    assert isinstance(response.content, bytes)
    assert isinstance(response.text, str)
    assert isinstance(response.json(), dict)
    assert response.content.decode("utf-8") == response.text


# --- 2. a 404 handled without a traceback -----------------------------------


def test_a_404_is_a_successful_response_not_an_exception(base, session):
    response = session.get(f"{base}/api/missing", timeout=DEFAULT_TIMEOUT)
    assert response.status_code == 404
    assert bool(response) is False


def test_a_missing_station_raises_a_domain_error_with_a_clean_message(base, session):
    with pytest.raises(StationNotFound) as caught:
        fetch_readings(base, "NOWHERE", session=session)
    assert "NOWHERE" in str(caught.value)
    assert "Traceback" not in str(caught.value)


def test_describe_failure_says_something_a_human_can_act_on(base, session):
    missing = session.get(f"{base}/api/missing", timeout=DEFAULT_TIMEOUT)
    assert describe_failure(missing) == "HTTP 404 (your request was rejected) — no such station"
    broken = session.get(f"{base}/api/broken", timeout=DEFAULT_TIMEOUT)
    assert describe_failure(broken) == "HTTP 500 (the server failed) — the server fell over"


def test_raise_for_status_raises_on_500_and_is_silent_on_200(base, session):
    with pytest.raises(requests.exceptions.HTTPError):
        session.get(f"{base}/api/broken", timeout=DEFAULT_TIMEOUT).raise_for_status()
    assert session.get(f"{base}/api/readings", timeout=DEFAULT_TIMEOUT).raise_for_status() is None


# --- 3. the timeout really fires --------------------------------------------


def test_a_read_timeout_fires_against_the_slow_endpoint(base, session):
    started = time.monotonic()
    with pytest.raises(requests.exceptions.Timeout):
        session.get(f"{base}/api/slow", params={"seconds": 3}, timeout=(3.05, 0.4))
    elapsed = time.monotonic() - started
    # It gave up on its own schedule, not the server's: well under 3 seconds.
    assert elapsed < 2.0


def test_a_timeout_is_a_requestexception_so_one_except_catches_the_family(base, session):
    with pytest.raises(requests.exceptions.RequestException):
        session.get(f"{base}/api/slow", params={"seconds": 3}, timeout=(3.05, 0.4))


# --- 4. retry with backoff --------------------------------------------------


def test_retry_succeeds_after_exactly_the_expected_number_of_attempts(base, session):
    session.get(f"{base}/control/reset", params={"fail": 2}, timeout=DEFAULT_TIMEOUT)
    sleeper = RecordingSleep()
    response = get_with_retry(
        f"{base}/api/flaky", session=session, attempts=4, sleep=sleeper, jitter=lambda: 1.0
    )
    assert response.status_code == 200
    assert response.json()["attempt"] == 3
    assert len(sleeper.waits) == 2


def test_the_retry_honours_retry_after_and_never_actually_sleeps(base, session):
    session.get(f"{base}/control/reset", params={"fail": 1}, timeout=DEFAULT_TIMEOUT)
    sleeper = RecordingSleep()
    started = time.monotonic()
    get_with_retry(f"{base}/api/flaky", session=session, attempts=3, sleep=sleeper, jitter=lambda: 1.0)
    assert sleeper.waits == [1.0]  # the server's Retry-After: 1
    assert time.monotonic() - started < 0.5


def test_retry_gives_up_and_says_how_many_attempts_it_made(base, session):
    session.get(f"{base}/control/reset", params={"fail": 99}, timeout=DEFAULT_TIMEOUT)
    sleeper = RecordingSleep()
    with pytest.raises(ReadingsUnavailable) as caught:
        get_with_retry(
            f"{base}/api/flaky", session=session, attempts=3, sleep=sleeper, jitter=lambda: 1.0
        )
    assert "after 3 attempts" in str(caught.value)
    assert len(sleeper.waits) == 2
    session.get(f"{base}/control/reset", params={"fail": 0}, timeout=DEFAULT_TIMEOUT)


def test_a_500_is_retried_and_a_404_is_not(base, session):
    assert 500 in RETRY_STATUSES and 429 in RETRY_STATUSES
    assert 404 not in RETRY_STATUSES and 400 not in RETRY_STATUSES and 401 not in RETRY_STATUSES
    sleeper = RecordingSleep()
    response = get_with_retry(
        f"{base}/api/missing", session=session, attempts=3, sleep=sleeper, jitter=lambda: 1.0
    )
    assert response.status_code == 404
    assert sleeper.waits == []  # returned on the first attempt, no waiting


@pytest.mark.parametrize(
    "attempts,expected",
    [(1, []), (2, [0.5]), (4, [0.5, 1.0, 2.0]), (6, [0.5, 1.0, 2.0, 4.0, 8.0])],
)
def test_the_backoff_schedule_doubles_and_caps(attempts, expected):
    assert backoff_delays(attempts, jitter=lambda: 1.0) == expected


def test_jitter_spreads_each_wait_over_half_its_slot():
    assert backoff_delays(4, jitter=lambda: 0.0) == [0.25, 0.5, 1.0]
    assert backoff_delays(4, jitter=lambda: 1.0) == [0.5, 1.0, 2.0]


# --- 5. Session and connection reuse ----------------------------------------


def test_one_session_reuses_one_connection_for_many_requests(base, server):
    before = server.connections
    with requests.Session() as pooled:
        for _ in range(5):
            pooled.get(f"{base}/api/readings", timeout=DEFAULT_TIMEOUT).close()
    assert server.connections - before == 1


def test_requests_get_without_a_session_opens_a_connection_every_time(base, server):
    before = server.connections
    for _ in range(5):
        requests.get(f"{base}/api/readings", timeout=DEFAULT_TIMEOUT).close()
    assert server.connections - before == 5


def test_the_session_carries_shared_headers_to_every_request(base, session):
    echoed = session.post(f"{base}/api/echo", json={"hello": "world"}, timeout=DEFAULT_TIMEOUT)
    assert echoed.status_code == 201
    assert echoed.json()["user_agent"].startswith("day078-lab/1.0")
    assert echoed.json()["content_type"] == "application/json"
    assert echoed.json()["json"] == {"hello": "world"}


def test_no_authorization_header_is_sent_when_the_environment_has_no_token(
    base, session, monkeypatch
):
    monkeypatch.delenv("READINGS_TOKEN", raising=False)
    echoed = session.post(f"{base}/api/echo", json={}, timeout=DEFAULT_TIMEOUT)
    assert echoed.json()["authorization_seen"] is False


def test_a_token_in_the_environment_becomes_an_authorization_header(base, monkeypatch):
    monkeypatch.setenv("READINGS_TOKEN", "not-a-real-secret")
    with make_session() as tokened:
        echoed = tokened.post(f"{base}/api/echo", json={}, timeout=DEFAULT_TIMEOUT)
    assert echoed.json()["authorization_seen"] is True


# --- 6. redirects -----------------------------------------------------------


def test_a_301_is_followed_by_default_and_recorded_in_history(base, session):
    response = session.get(f"{base}/old/readings", timeout=DEFAULT_TIMEOUT)
    assert response.status_code == 200
    assert [r.status_code for r in response.history] == [301]


def test_allow_redirects_false_shows_the_301_and_its_location(base, session):
    response = session.get(f"{base}/old/readings", timeout=DEFAULT_TIMEOUT, allow_redirects=False)
    assert response.status_code == 301
    assert response.headers["Location"] == "/api/readings"
    assert response.history == []


# --- 7. streaming -----------------------------------------------------------


def test_streaming_writes_the_whole_body_in_many_small_chunks(base, session, tmp_path):
    destination = tmp_path / "large.txt"
    total, chunks, digest = stream_to_file(
        f"{base}/api/large?kb=512", str(destination), session=session, chunk_size=8192
    )
    assert total == 512 * 1024
    assert chunks == 64
    assert destination.stat().st_size == total
    assert len(digest) == 64


def test_the_chunk_size_decides_how_much_is_held_at_once(base, session, tmp_path):
    total, chunks, _ = stream_to_file(
        f"{base}/api/large?kb=64", str(tmp_path / "s.txt"), session=session, chunk_size=1024
    )
    assert (total, chunks) == (64 * 1024, 64)
examples/test_without_a_server.py (5505 bytes)
"""The Day 74 payoff: the same client, tested with NO server at all.

Not a local server. Not a mock of `requests`. Not a patch. A forty-line
fake object passed in through the `session=` parameter that was put there
for exactly this purpose.

Every test in this file runs in microseconds, would run identically on a
machine with no network stack, and can produce a ConnectionError, a 429
storm or a malformed body on demand — states that are awkward to arrange
even against a server you control.

The one thing these tests CANNOT prove is that your understanding of the
real server is correct. That is what `test_client.py` is for. Keep both.
"""

from __future__ import annotations

import pytest
import requests

from client import (
    Reading,
    ReadingsUnavailable,
    StationNotFound,
    fetch_readings,
    get_with_retry,
    summarise,
)
from fake_session import FakeResponse, FakeSession

PAYLOAD = {
    "station": "ALPHA",
    "count": 2,
    "readings": [
        {"station": "ALPHA", "hour": 0, "celsius": 12.0},
        {"station": "ALPHA", "hour": 12, "celsius": 22.0},
    ],
}


class RecordingSleep:
    def __init__(self) -> None:
        self.waits: list[float] = []

    def __call__(self, seconds: float) -> None:
        self.waits.append(round(seconds, 4))


def test_fetch_readings_works_against_a_fake_session():
    session = FakeSession([FakeResponse(200, PAYLOAD)])
    assert fetch_readings("http://example.invalid", "ALPHA", session=session) == [
        Reading("ALPHA", 0, 12.0),
        Reading("ALPHA", 12, 22.0),
    ]


def test_the_url_and_params_the_client_would_have_sent():
    session = FakeSession([FakeResponse(200, PAYLOAD)])
    fetch_readings("http://example.invalid", "ALPHA", session=session)
    call = session.calls[0]
    assert call["url"] == "http://example.invalid/api/readings"
    assert call["params"] == {"station": "ALPHA"}


def test_every_call_carries_a_timeout():
    """The check worth having in a real codebase. A missing timeout hangs."""
    session = FakeSession([FakeResponse(200, PAYLOAD)])
    fetch_readings("http://example.invalid", "ALPHA", session=session)
    assert session.timeouts == [(3.05, 10.0)]
    assert all(t is not None for t in session.timeouts)


def test_a_404_becomes_a_domain_exception_with_no_server_involved():
    session = FakeSession([FakeResponse(404, {"error": "not_found"})])
    with pytest.raises(StationNotFound):
        fetch_readings("http://example.invalid", "GHOST", session=session)


def test_a_connection_error_can_be_produced_on_demand():
    session = FakeSession([requests.exceptions.ConnectionError("name resolution failed")])
    with pytest.raises(requests.exceptions.ConnectionError):
        fetch_readings("http://example.invalid", "ALPHA", session=session)


def test_retry_recovers_from_two_429s_and_never_waits():
    session = FakeSession(
        [
            FakeResponse(429, {"error": "rate_limited"}, {"Retry-After": "2"}),
            FakeResponse(429, {"error": "rate_limited"}, {"Retry-After": "2"}),
            FakeResponse(200, PAYLOAD),
        ]
    )
    sleeper = RecordingSleep()
    response = get_with_retry(
        "http://example.invalid/api/flaky",
        session=session,
        attempts=4,
        sleep=sleeper,
        jitter=lambda: 1.0,
    )
    assert response.status_code == 200
    assert len(session.calls) == 3
    assert sleeper.waits == [2.0, 2.0]


def test_retry_survives_a_transport_failure_then_succeeds():
    session = FakeSession(
        [requests.exceptions.ConnectTimeout("no route"), FakeResponse(200, PAYLOAD)]
    )
    sleeper = RecordingSleep()
    response = get_with_retry(
        "http://example.invalid/api/flaky",
        session=session,
        attempts=3,
        sleep=sleeper,
        jitter=lambda: 1.0,
    )
    assert response.status_code == 200
    assert sleeper.waits == [0.5]


def test_retry_gives_up_after_five_503s_and_names_the_count():
    session = FakeSession([FakeResponse(503, {"error": "unavailable"}) for _ in range(5)])
    sleeper = RecordingSleep()
    with pytest.raises(ReadingsUnavailable) as caught:
        get_with_retry(
            "http://example.invalid/api/flaky",
            session=session,
            attempts=5,
            sleep=sleeper,
            jitter=lambda: 1.0,
        )
    assert "after 5 attempts" in str(caught.value)
    assert sleeper.waits == [0.5, 1.0, 2.0, 4.0]


@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422])
def test_a_client_error_is_returned_immediately_and_never_retried(status):
    session = FakeSession([FakeResponse(status, {"error": "no"})])
    sleeper = RecordingSleep()
    response = get_with_retry(
        "http://example.invalid/api/x",
        session=session,
        attempts=4,
        sleep=sleeper,
        jitter=lambda: 1.0,
    )
    assert response.status_code == status
    assert len(session.calls) == 1
    assert sleeper.waits == []


@pytest.mark.parametrize("status", [429, 500, 502, 503, 504])
def test_a_retryable_status_is_tried_again(status):
    session = FakeSession([FakeResponse(status, {"error": "later"}), FakeResponse(200, PAYLOAD)])
    get_with_retry(
        "http://example.invalid/api/x",
        session=session,
        attempts=3,
        sleep=RecordingSleep(),
        jitter=lambda: 1.0,
    )
    assert len(session.calls) == 2


def test_summarise_is_pure_and_needs_no_session_at_all():
    assert summarise([Reading("A", 0, 10.0), Reading("A", 1, 20.0)])["mean"] == 15.0
metadata.yml (1550 bytes)
lesson_id: D078
day: 78
kind: python-program
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-078-http-in-python-with-requests
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/pytest --version
  - '.venv/bin/python3 -c "import requests; print(requests.__version__)"'
run_commands:
  - .venv/bin/python3 examples/raw_socket_demo.py
  - .venv/bin/python3 examples/demo.py
  - .venv/bin/python3 examples/stdlib_demo.py
  - '.venv/bin/python3 examples/httpx_demo.py   # exits 0 whether or not httpx is installed'
  - .venv/bin/pytest examples -q
  - '.venv/bin/pytest examples/test_without_a_server.py -q   # no server started at all'
  - 'PYTHONPATH=tests .venv/bin/pytest examples -q   # every non-loopback socket blocked'
  - .venv/bin/pytest starter -q
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -rf .venv
  - 'git checkout -- starter/  # optional: reset your work'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, requests 2.34.2, pytest 9.1.1, httpx 0.28.1, bash 3.2.57 — bash tests/run_tests.sh -> 58 checks, 0 failure(s), exit 0 (53 checks, 0 failure(s) with the starter exercises completed). requires_network is true for the one-time dependency install ONLY; the tests themselves open no socket to anything but 127.0.0.1, and the suite proves it by re-running every example test with all non-loopback sockets blocked.'
requirements/README.md (3944 bytes)
# Dependencies — Day 078 lab

**Two third-party packages. The server you talk to is not one of them — it is
built entirely from the standard library.**

## The pinned list

`requirements.txt` contains exactly two lines:

```
requests==2.34.2
pytest==9.1.1
```

| Dependency | Version | Licence | Why this lab needs it |
| --- | --- | --- | --- |
| requests | 2.34.2 | Apache License 2.0 (declared in the project's own metadata) | The subject of the day. The lab uses `Session`, `params=`, `timeout=`, `.json()`, `raise_for_status()`, `stream=True` with `iter_content`, `allow_redirects=False`, and the `requests.exceptions` hierarchy. |
| pytest | 9.1.1 | MIT (the `License-Expression` field of the installed distribution's own metadata) | The test runner from Day 71. This lab needs `pytest.raises`, `@pytest.mark.parametrize`, `tmp_path`, `monkeypatch`, session-scoped fixtures, and its exit code. |

Both are free and open source. Neither has a paid tier, an account, or
telemetry. Both versions were installed and verified on the authoring machine
on 2026-07-19.

## What is NOT in the list, and why that matters

**The server.** `examples/demo_server.py` is a subclass of
`http.server.BaseHTTPRequestHandler` served by `ThreadingHTTPServer`, with
`socket`, `threading`, `json` and `urllib.parse` doing the rest. All standard
library. You already have it. That is not a shortcut — it is the point:
Python ships with a working HTTP server, which is why "start a server you
control and test against that" costs you nothing.

**The alternatives the lesson compares.** `urllib.request` and `http.client`
are standard library too, which is why `examples/stdlib_demo.py` runs on a
machine with nothing installed at all. Run it and see:

```bash
python3 examples/stdlib_demo.py
```

**httpx.** `examples/httpx_demo.py` is the fourth alternative, and httpx is
deliberately *not* pinned here. If it happens to be installed, the demo runs
and prints the comparison; if it is not, the demo says so and exits 0. It ran
on the authoring machine against httpx 0.28.1. Install it yourself if you want
to compare:

```bash
.venv/bin/pip install httpx
```

## Install once

From this lab's directory:

```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/python3 -c "import requests; print(requests.__version__)"
```

Those last two commands should print `pytest 9.1.1` and `2.34.2`. You created
your first virtual environment on Day 43; this is the same procedure.
`.venv/` is ignored by version control — never commit it.

## The network, precisely

The install needs the network **once**, to download two packages from the
Python Package Index. That is the only moment any part of this lab reaches
beyond your own machine.

After that, **nothing in this lab touches the internet** — not the demos, not
the example suites, not `tests/run_tests.sh`. Every socket opened goes to
`127.0.0.1`, the loopback interface, on a port the operating system assigned
at run time. This is not a promise; the test suite proves it. Section 5 of
`tests/run_tests.sh` runs the entire example suite with `tests/sitecustomize.py`
loaded, which replaces `socket.connect` and `socket.getaddrinfo` so that any
attempt to resolve a hostname or reach a non-loopback address raises
immediately — and then proves the guard is not vacuous by confirming that a
request to a public site under the same guard is refused.

If you already have both packages somewhere else, skip the virtual
environment and point the suite at that pytest:

```bash
PYTEST=/path/to/pytest bash tests/run_tests.sh
```

The runner resolves `python3` from the same directory as the pytest it found,
so the demos import the same `requests` the tests do.

## Check your Python

```bash
python3 --version
```

Verified on 3.14.0. Python 3.10 or newer is required, because the code uses
the `X | None` annotation syntax throughout.
requirements/requirements.txt (31 bytes)
requests==2.34.2
pytest==9.1.1
starter/client.py (10451 bytes)
"""YOUR FILE — exercises 1 to 6.

Six functions. Each one has a docstring saying exactly what it must do, a
signature that is already right, and a `raise NotImplementedError` you
delete. `examples/client.py` contains a complete reference implementation:
use it when you are stuck, but write yours first — reading a solution feels
like learning and is not.

Run your work at any time:

    .venv/bin/pytest starter -q

Unfinished exercises are skipped, so the suite exits 0 from the first
minute. Check everything at the end with:

    bash tests/run_tests.sh

Two rules that apply to every function below, and to every HTTP call you
ever write after today:

  * pass `timeout=` to every request. `requests` has no default;
  * take `session` as a parameter, never reach for `requests.get` inside
    the function. That parameter is what makes exercise 7 possible.
"""

from __future__ import annotations

import hashlib
import random
import time
from dataclasses import dataclass
from typing import Any, Callable

import requests

# (connect timeout, read timeout). Use this as your default.
DEFAULT_TIMEOUT: tuple[float, float] = (3.05, 10.0)

# The only statuses worth trying again. Everything else is a final answer.
RETRY_STATUSES = frozenset({429, 500, 502, 503, 504})


class ReadingsError(Exception):
    """Base class for this client's own exceptions."""


class ReadingsUnavailable(ReadingsError):
    """Could not be reached, or kept failing."""


class StationNotFound(ReadingsError):
    """The server said clearly: there is no such station."""


@dataclass(frozen=True)
class Reading:
    station: str
    hour: int
    celsius: float


# ---------------------------------------------------------------------------
# Exercise 1 — fetch and parse JSON
# ---------------------------------------------------------------------------
def fetch_readings(
    base_url: str,
    station: str,
    *,
    session: Any,
    timeout: tuple[float, float] | float = DEFAULT_TIMEOUT,
) -> list[Reading]:
    """GET {base_url}/api/readings with the station as a QUERY PARAMETER.

    Steps:
      1. `response = session.get(f"{base_url}/api/readings",
                                 params={"station": station}, timeout=timeout)`
         — use `params=`, not an f-string. Exercise 1b below shows why.
      2. If `response.status_code == 404`, raise `StationNotFound` with a
         message naming the station. This is exercise 2's requirement, and
         it belongs here.
      3. Call `response.raise_for_status()` for everything else that failed.
      4. `payload = response.json()`, then build one `Reading` per item in
         `payload["readings"]`. Each item has keys `station`, `hour`,
         `celsius`.

    Verify by hand once you have it:
        .venv/bin/pytest starter -q -k fetch_readings
    """
    raise NotImplementedError("exercise 1: fetch and parse the JSON body")


# ---------------------------------------------------------------------------
# Exercise 2 — a failure a human can act on, instead of a traceback
# ---------------------------------------------------------------------------
def describe_failure(response: Any) -> str:
    """Turn a failed response into ONE sentence, with no traceback in it.

    Required format, exactly:
        "HTTP 404 (your request was rejected) — no such station"
        "HTTP 500 (the server failed) — the server fell over"

    Steps:
      1. `response.status_code // 100` gives the family: 3, 4 or 5. Map
         3 -> "redirection", 4 -> "your request was rejected",
         5 -> "the server failed", anything else -> "unexpected status".
      2. If `response.headers.get("Content-Type", "")` starts with
         "application/json", try `response.json()` and append
         f" — {body['detail']}" if the body has a "detail" key, or
         f" — {body['error']}" if it only has "error". Wrap the `.json()`
         call in try/except ValueError: a Content-Type header is a claim,
         not a guarantee.
      3. Return the assembled string. Note the em dash: "—", not "-".
    """
    raise NotImplementedError("exercise 2: describe the failure in one sentence")


# ---------------------------------------------------------------------------
# Exercise 3 — the backoff schedule (used by exercise 4)
# ---------------------------------------------------------------------------
def backoff_delays(
    attempts: int,
    *,
    base: float = 0.5,
    cap: float = 8.0,
    jitter: Callable[[], float] = random.random,
) -> list[float]:
    """Return the waits BETWEEN `attempts` tries — so `attempts - 1` of them.

    Requirements:
      * raise `ValueError` if `attempts < 1`;
      * the raw delay for wait number i (counting from 0) is
        `min(cap, base * 2 ** i)` — 0.5, 1.0, 2.0, 4.0, 8.0, 8.0, ...;
      * multiply each raw delay by `(0.5 + 0.5 * jitter())`, so a wait lands
        somewhere in the top half of its slot, and round to 4 places;
      * `jitter` is a PARAMETER so a test can pass `lambda: 1.0` and get the
        exact schedule, or `lambda: 0.0` and get half of it.

    With `jitter=lambda: 1.0`:
        backoff_delays(1) == []
        backoff_delays(4) == [0.5, 1.0, 2.0]
        backoff_delays(6) == [0.5, 1.0, 2.0, 4.0, 8.0]
    """
    raise NotImplementedError("exercise 3: build the exponential backoff schedule")


# ---------------------------------------------------------------------------
# Exercise 4 — retry on 429 and 5xx, and on nothing else
# ---------------------------------------------------------------------------
def get_with_retry(
    url: str,
    *,
    session: Any,
    attempts: int = 4,
    timeout: tuple[float, float] | float = DEFAULT_TIMEOUT,
    sleep: Callable[[float], None] = time.sleep,
    jitter: Callable[[], float] = random.random,
    params: dict[str, Any] | None = None,
) -> Any:
    """GET `url`, retrying only what deserves it. Return the final response.

    Steps:
      1. `delays = backoff_delays(attempts, jitter=jitter)`.
      2. Loop `for attempt in range(1, attempts + 1)`:
         a. call `session.get(url, params=params, timeout=timeout)` inside
            `try: ... except requests.exceptions.RequestException as exc:`.
            A transport failure has NO status code, because there is no
            response — record it and fall through to the sleep;
         b. if the status is NOT in `RETRY_STATUSES`, RETURN the response
            immediately. A 404 is a final answer, and retrying it is a bug;
         c. otherwise remember the failure. If the response carries a
            `Retry-After` header, use `min(float(header), 8.0)` as this
            wait instead of your computed one — the server knows better
            than you do. Guard the float() with try/except ValueError,
            because Retry-After may legally be a date instead of seconds;
         d. if `attempt <= len(delays)`, call `sleep(delays[attempt - 1])`.
      3. After the loop, raise `ReadingsUnavailable` with a message
         containing the exact text f"after {attempts} attempts".

    `sleep` is a parameter for the Day 74 reason: a test passes a recorder
    and proves the schedule in microseconds without anything waiting.
    """
    raise NotImplementedError("exercise 4: retry 429 and 5xx with backoff")


# ---------------------------------------------------------------------------
# Exercise 5 — a Session with shared headers and a token from the environment
# ---------------------------------------------------------------------------
def make_session(user_agent: str = "day078-yours/1.0 (course exercise)") -> requests.Session:
    """Build a `requests.Session` carrying the headers every call should send.

    Steps:
      1. `session = requests.Session()`.
      2. `session.headers.update({...})` with "User-Agent": user_agent and
         "Accept": "application/json".
      3. Read `os.environ.get("READINGS_TOKEN")`. If it is set, add
         `session.headers["Authorization"] = f"Bearer {token}"`. If it is
         not set, add NO Authorization header at all.
      4. Return the session.

    Never write a token into this file. A secret in source control is a
    secret you have to rotate, and you will not enjoy the afternoon.
    """
    raise NotImplementedError("exercise 5: build the session")


# ---------------------------------------------------------------------------
# Exercise 6 — stream a large body instead of loading it
# ---------------------------------------------------------------------------
def stream_to_file(
    url: str,
    destination: str,
    *,
    session: Any,
    chunk_size: int = 8192,
    timeout: tuple[float, float] | float = DEFAULT_TIMEOUT,
) -> tuple[int, int, str]:
    """Download `url` to `destination` without holding the whole body at once.

    Return (bytes written, number of chunks read, sha256 hex digest).

    Steps:
      1. `digest = hashlib.sha256()`, and counters at zero.
      2. `with session.get(url, stream=True, timeout=timeout) as response:`
         — `stream=True` means the headers have arrived and the body has
         not. Call `response.raise_for_status()` inside the block.
      3. `with open(destination, "wb") as handle:` then
         `for chunk in response.iter_content(chunk_size=chunk_size):`
         — skip a falsy chunk, write it, feed it to the digest, and count
         both bytes and chunks.
      4. Return the three values.

    The point: at no moment does more than one chunk exist in memory. Try
    this against a four-gigabyte file without `stream=True` and your
    process will be killed by the operating system.
    """
    raise NotImplementedError("exercise 6: stream the body a chunk at a time")


# ---------------------------------------------------------------------------
# Provided, complete — the pure part, which needs no network at all.
# ---------------------------------------------------------------------------
def summarise(readings: list[Reading]) -> dict[str, float]:
    values = [r.celsius for r in readings]
    if not values:
        raise ReadingsError("cannot summarise an empty set of readings")
    return {
        "count": float(len(values)),
        "min": min(values),
        "max": max(values),
        "mean": round(sum(values) / len(values), 4),
    }


def _digest_unused() -> str:  # pragma: no cover - keeps hashlib imported for you
    return hashlib.sha256(b"").hexdigest()
starter/conftest.py (1261 bytes)
"""Provided for you, complete. Do not edit — this is the harness, not the work.

It puts the lab's `examples/` directory on the import path (so you can import
`demo_server` and `fake_session`), and it starts ONE local HTTP server for the
whole test session on an ephemeral port on 127.0.0.1.

Read `examples/demo_server.py` once before you start. It is the thing you are
writing a client against, it is about two hundred lines of standard library,
and knowing what the server does makes every exercise below easier.
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Iterator

import pytest

LAB_DIR = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(LAB_DIR / "examples"))
sys.path.insert(0, str(Path(__file__).resolve().parent))

from demo_server import CountingServer, base_url, running_server  # noqa: E402


@pytest.fixture(scope="session")
def server() -> Iterator[CountingServer]:
    """The local test server. Started once, shut down when the run ends."""
    with running_server() as srv:
        yield srv


@pytest.fixture(scope="session")
def base(server: CountingServer) -> str:
    """The address it actually bound to this run, e.g. http://127.0.0.1:51234"""
    return base_url(server)
starter/NOTES.md (3176 bytes)
# Your notes — exercise 8

Fill this in after the code works. Sentences, not single words: the writing
is the exercise. Numbers come from your own runs, not from the lesson.

## 1. The status codes you met

Run each of these against the local test server and record what came back.
`pt` is your pytest; use `python3 -c` or the demo, whichever you prefer.

| Endpoint | Status | Retryable? | What your client did |
| --- | --- | --- | --- |
| `/api/readings?station=ALPHA` |  |  |  |
| `/api/readings?station=NOWHERE` |  |  |  |
| `/api/broken` |  |  |  |
| `/old/readings` |  |  |  |
| `/api/flaky` (first call after reset) |  |  |  |

## 2. The timeout

Run the slow endpoint three ways and record the wall-clock time each took:

| Call | Seconds elapsed | What happened |
| --- | --- | --- |
| `timeout=(3.05, 0.4)` against `/api/slow?seconds=3` |  |  |
| `timeout=(3.05, 10.0)` against `/api/slow?seconds=3` |  |  |
| no `timeout=` at all against `/api/slow?seconds=3` |  |  |

Now answer in two or three sentences: what would the third row have done if
the server had accepted the connection and then never sent a single byte?

## 3. Connection reuse

Fill in from your own run of the exercise-5 test:

- five requests through one `Session`: ______ TCP connection(s)
- five calls to `requests.get`: ______ TCP connection(s)

In one sentence: why does this matter more over HTTPS than over plain HTTP?

## 4. Retry

- Your schedule with `jitter=lambda: 1.0` and `attempts=6`: ______
- The same with `jitter=lambda: 0.0`: ______
- Wall-clock time of `test_retry_recovers_from_two_429s_on_the_third_attempt`:
  ______ seconds.

In two or three sentences: why is the jitter there? Describe what happens to
a server that has just come back up when a thousand clients all retry on an
identical, un-jittered schedule.

Then: name one status code you were tempted to retry and should not, and say
what retrying it would actually accomplish.

## 5. Streaming

- Bytes written by exercise 6: ______
- Chunks read: ______
- The most memory your process held at any one moment, approximately: ______

In one sentence: what would have happened if the body had been 4 GB and you
had called `response.content`?

## 6. The boundary — the point of the day

Count these yourself:

| | `test_client.py` (against the server) | your exercise-7 tests (fake session) |
| --- | --- | --- |
| tests |  |  |
| wall-clock seconds |  |  |
| sockets opened |  |  |
| failures they can produce on demand |  |  |

Now the three questions worth writing down properly:

1. What can the server-backed tests prove that the fake-backed ones cannot?
2. What can the fake-backed tests prove that the server-backed ones cannot,
   or can only prove awkwardly?
3. Rewrite `fetch_readings` in your head so it calls `requests.get` directly
   instead of taking `session`. Exactly which of your tests would still be
   possible, and what would you have had to do instead? Answer in sentences,
   and name the Day 74 idea this is an instance of.

## 7. One sentence for a code review

Write the single sentence you would leave on a pull request that adds a call
to a third-party API with no `timeout=` argument.
starter/test_client.py (8955 bytes)
"""YOUR FILE — exercise 7, plus the checks that grade exercises 1 to 6.

Run it at any time:

    .venv/bin/pytest starter -q

Every test for an unfinished exercise is SKIPPED, so this file exits 0 from
the first minute and turns green one exercise at a time. The skip is
decided by reading your `client.py`: as soon as a function no longer says
`raise NotImplementedError`, its tests start running.

The last section is exercise 7, and it is the point of the whole lab.
"""

from __future__ import annotations

import inspect
import time

import pytest
import requests

import client
from fake_session import FakeResponse, FakeSession


def unfinished(fn) -> bool:
    """True while `fn` still contains its `raise NotImplementedError` line."""
    try:
        return "raise NotImplementedError" in inspect.getsource(fn)
    except OSError:  # pragma: no cover - source always available here
        return False


def needs(fn):
    return pytest.mark.skipif(unfinished(fn), reason=f"{fn.__name__} is not written yet")


class RecordingSleep:
    """A spy from Day 74: it records what it was asked to wait, and waits 0."""

    def __init__(self) -> None:
        self.waits: list[float] = []

    def __call__(self, seconds: float) -> None:
        self.waits.append(round(seconds, 4))


@pytest.fixture
def session():
    """A plain Session while exercise 5 is unfinished; yours once it is."""
    s = requests.Session() if unfinished(client.make_session) else client.make_session()
    yield s
    s.close()


# --- provided, and already passing: proof the server is up ------------------


def test_the_local_test_server_answers(base):
    """This one is written for you. If it fails, nothing else will work."""
    response = requests.get(f"{base}/api/readings", timeout=(3.05, 10.0))
    assert response.status_code == 200
    assert response.json()["count"] == 6


# --- exercise 1 -------------------------------------------------------------


@needs(client.fetch_readings)
def test_fetch_readings_parses_four_rows(base, session):
    readings = client.fetch_readings(base, "ALPHA", session=session)
    assert len(readings) == 4
    assert readings[0] == client.Reading("ALPHA", 0, 12.0)
    assert client.summarise(readings)["mean"] == 17.0


@needs(client.fetch_readings)
def test_fetch_readings_uses_params_so_the_value_is_encoded(base, session):
    awkward = "ALPHA ONE&station=BRAVO"
    echoed = session.get(f"{base}/api/search", params={"station": awkward}, timeout=(3.05, 10.0))
    assert echoed.json()["parsed"]["station"] == [awkward], (
        "if this fails you concatenated the query string instead of using params="
    )


# --- exercise 2 -------------------------------------------------------------


@needs(client.fetch_readings)
def test_an_unknown_station_raises_your_exception_not_a_traceback(base, session):
    with pytest.raises(client.StationNotFound) as caught:
        client.fetch_readings(base, "NOWHERE", session=session)
    assert "NOWHERE" in str(caught.value)


@needs(client.describe_failure)
def test_describe_failure_formats_a_404_and_a_500(base, session):
    missing = session.get(f"{base}/api/missing", timeout=(3.05, 10.0))
    assert client.describe_failure(missing) == (
        "HTTP 404 (your request was rejected) — no such station"
    )
    broken = session.get(f"{base}/api/broken", timeout=(3.05, 10.0))
    assert client.describe_failure(broken) == "HTTP 500 (the server failed) — the server fell over"


# --- exercise 3 -------------------------------------------------------------


@needs(client.backoff_delays)
def test_the_schedule_doubles_caps_and_jitters():
    assert client.backoff_delays(1, jitter=lambda: 1.0) == []
    assert client.backoff_delays(4, jitter=lambda: 1.0) == [0.5, 1.0, 2.0]
    assert client.backoff_delays(6, jitter=lambda: 1.0) == [0.5, 1.0, 2.0, 4.0, 8.0]
    assert client.backoff_delays(4, jitter=lambda: 0.0) == [0.25, 0.5, 1.0]
    with pytest.raises(ValueError):
        client.backoff_delays(0)


# --- exercise 4 -------------------------------------------------------------


@needs(client.get_with_retry)
def test_retry_recovers_from_two_429s_on_the_third_attempt(base, session):
    session.get(f"{base}/control/reset", params={"fail": 2}, timeout=(3.05, 10.0))
    sleeper = RecordingSleep()
    started = time.monotonic()
    response = client.get_with_retry(
        f"{base}/api/flaky", session=session, attempts=4, sleep=sleeper, jitter=lambda: 1.0
    )
    assert response.status_code == 200
    assert response.json()["attempt"] == 3
    assert len(sleeper.waits) == 2
    assert time.monotonic() - started < 0.5, "nothing should really have slept"


@needs(client.get_with_retry)
def test_retry_gives_up_and_names_the_attempt_count(base, session):
    session.get(f"{base}/control/reset", params={"fail": 99}, timeout=(3.05, 10.0))
    with pytest.raises(client.ReadingsUnavailable) as caught:
        client.get_with_retry(
            f"{base}/api/flaky", session=session, attempts=3, sleep=RecordingSleep(),
            jitter=lambda: 1.0,
        )
    assert "after 3 attempts" in str(caught.value)
    session.get(f"{base}/control/reset", params={"fail": 0}, timeout=(3.05, 10.0))


@needs(client.get_with_retry)
def test_a_404_is_returned_at_once_and_never_retried(base, session):
    sleeper = RecordingSleep()
    response = client.get_with_retry(
        f"{base}/api/missing", session=session, attempts=4, sleep=sleeper, jitter=lambda: 1.0
    )
    assert response.status_code == 404
    assert sleeper.waits == []


# --- exercise 5 -------------------------------------------------------------


@needs(client.make_session)
def test_the_session_sends_your_user_agent_and_no_token_by_default(base, monkeypatch):
    monkeypatch.delenv("READINGS_TOKEN", raising=False)
    with client.make_session() as s:
        echoed = s.post(f"{base}/api/echo", json={}, timeout=(3.05, 10.0))
    assert echoed.json()["user_agent"].startswith("day078-")
    assert echoed.json()["authorization_seen"] is False


@needs(client.make_session)
def test_a_token_in_the_environment_becomes_an_authorization_header(base, monkeypatch):
    monkeypatch.setenv("READINGS_TOKEN", "not-a-real-secret")
    with client.make_session() as s:
        echoed = s.post(f"{base}/api/echo", json={}, timeout=(3.05, 10.0))
    assert echoed.json()["authorization_seen"] is True


@needs(client.make_session)
def test_one_session_opens_one_connection_for_five_requests(base, server):
    before = server.connections
    with client.make_session() as s:
        for _ in range(5):
            s.get(f"{base}/api/readings", timeout=(3.05, 10.0)).close()
    assert server.connections - before == 1
    before = server.connections
    for _ in range(5):
        requests.get(f"{base}/api/readings", timeout=(3.05, 10.0)).close()
    assert server.connections - before == 5


# --- exercise 6 -------------------------------------------------------------


@needs(client.stream_to_file)
def test_streaming_writes_512_kib_in_64_chunks(base, session, tmp_path):
    destination = tmp_path / "large.txt"
    total, chunks, digest = client.stream_to_file(
        f"{base}/api/large?kb=512", str(destination), session=session, chunk_size=8192
    )
    assert total == 512 * 1024
    assert chunks == 64
    assert destination.stat().st_size == total
    assert len(digest) == 64


# --- the timeout, which needs no exercise: it is one keyword ----------------


def test_a_timeout_really_fires_against_the_slow_endpoint(base, session):
    """Provided and already passing. Read it — this is the habit of the day."""
    started = time.monotonic()
    with pytest.raises(requests.exceptions.Timeout):
        session.get(f"{base}/api/slow", params={"seconds": 3}, timeout=(3.05, 0.4))
    assert time.monotonic() - started < 2.0


# --- exercise 7 — the payoff: tests with NO server at all -------------------
#
# `fake_session.FakeSession` is provided in examples/. Because every function
# in client.py takes `session` as a parameter, these tests need no server, no
# socket and no port. Write at least three more of your own below:
#
#   * one that proves a ConnectionError propagates (script the exception);
#   * one that proves a 503 is retried and a 403 is not;
#   * one that proves EVERY call your client makes carries a timeout.
#
# The last one is the check worth stealing for your real projects.


@needs(client.fetch_readings)
def test_fetch_readings_works_with_no_server_in_sight():
    """Provided, as the model for the ones you write."""
    payload = {"readings": [{"station": "ALPHA", "hour": 0, "celsius": 12.0}]}
    fake = FakeSession([FakeResponse(200, payload)])
    assert client.fetch_readings("http://example.invalid", "ALPHA", session=fake) == [
        client.Reading("ALPHA", 0, 12.0)
    ]
    assert fake.calls[0]["params"] == {"station": "ALPHA"}
    assert fake.timeouts == [(3.05, 10.0)], "every call must carry a timeout"
tests/run_tests.sh (21439 bytes)
#!/usr/bin/env bash
# Tests for the Day 078 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# The suite asserts real behaviour, not the presence of files. The five checks
# worth reading before the rest:
#
#   * "the whole example suite passes with the internet blocked" runs every
#     example test under tests/sitecustomize.py, which replaces
#     socket.connect and socket.getaddrinfo so that any attempt to resolve a
#     name or reach a non-loopback address raises. That turns "this lab is
#     offline" from a promise into a fact;
#   * "the fake-session suite passes with no server module present at all"
#     copies three files to a temporary directory — client.py,
#     fake_session.py and test_without_a_server.py — leaving demo_server.py
#     and conftest.py behind, and runs pytest there. It passes because every
#     client function takes `session` as a parameter. That is Day 74's
#     argument, proved rather than asserted;
#   * "a read timeout really fires" asserts that a request against a
#     three-second endpoint gives up in well under two seconds;
#   * "retry succeeds on exactly the third attempt" pins the count, not just
#     the outcome;
#   * "one Session opens one connection where five bare calls open five"
#     reads the server's own accept counter.
#
# No network, non-interactive, deterministic. Exits 0 only if every check
# passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# Resolve a tool: an explicit override, then this lab's .venv, then PATH.
# Fails loudly with install instructions rather than skipping silently.
resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  echo "  Install this lab's dependencies with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
  echo "  Or point this suite at an existing pytest: PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}

# python3 must be the SAME environment pytest lives in, or `import requests`
# will fail in the demo scripts. Prefer the venv interpreter beside pytest.
python_bin="$(dirname "${pytest_bin}")/python3"
[ -x "${python_bin}" ] || python_bin="$(command -v python3 || true)"
if [ -z "${python_bin}" ]; then
  echo "FAIL: python3 not found on PATH." >&2
  exit 1
fi

if ! "${python_bin}" -c "import requests" >/dev/null 2>&1; then
  echo "FAIL: the 'requests' package is not importable by ${python_bin}." >&2
  echo "  Install it with: .venv/bin/pip install -r requirements/requirements.txt" >&2
  exit 1
fi

echo "Day 078 — Talk to a Server You Control"
echo

# --------------------------------------------------------------------------
echo "1. The tools"
# --------------------------------------------------------------------------

version_line="$("${pytest_bin}" --version 2>&1 | head -1)"
case "${version_line}" in
  pytest*) check "pytest --version reports a pytest ( ${version_line} )" "yes" ;;
  *) check "pytest --version reports a pytest ( ${version_line} )" "no" ;;
esac

requests_version="$("${python_bin}" -c "import requests; print(requests.__version__)" 2>&1)"
case "${requests_version}" in
  2.*) check "requests is importable ( ${requests_version} )" "yes" ;;
  *) check "requests is importable ( ${requests_version} )" "no" ;;
esac

# --------------------------------------------------------------------------
echo
echo "2. The local test server behaves as the lab claims"
# --------------------------------------------------------------------------

server_probe="$(cd "${lab_dir}/examples" && "${python_bin}" - <<'PY' 2>&1
import json
import requests
from demo_server import base_url, running_server

with running_server() as srv:
    root = base_url(srv)
    host, port = srv.server_address[:2]
    s = requests.Session()
    out = {
        "host": host,
        "ephemeral": port != 0 and port != 8000,
        "readings": s.get(f"{root}/api/readings", timeout=5).status_code,
        "missing": s.get(f"{root}/api/missing", timeout=5).status_code,
        "broken": s.get(f"{root}/api/broken", timeout=5).status_code,
        "redirect": s.get(f"{root}/old/readings", timeout=5, allow_redirects=False).status_code,
        "echo": s.post(f"{root}/api/echo", json={"a": 1}, timeout=5).status_code,
    }
    s.get(f"{root}/control/reset", params={"fail": 1}, timeout=5)
    limited = s.get(f"{root}/api/flaky", timeout=5)
    out["flaky"] = limited.status_code
    out["retry_after"] = limited.headers.get("Retry-After")
    out["large"] = len(s.get(f"{root}/api/large?kb=8", timeout=5).content)
    s.close()
print(json.dumps(out))
PY
)"

expect_field() {
  local field="$1" want="$2" label="$3"
  case "${server_probe}" in
    *"\"${field}\": ${want}"*|*"\"${field}\": \"${want}\""*) check "${label}" "yes" ;;
    *) check "${label} (probe said: ${server_probe})" "no" ;;
  esac
}

expect_field host "127.0.0.1" "the server binds 127.0.0.1 and nothing else"
expect_field ephemeral "true" "the port is ephemeral, not a hard-coded 8000"
expect_field readings "200" "/api/readings answers 200"
expect_field missing "404" "/api/missing answers 404"
expect_field broken "500" "/api/broken answers 500"
expect_field redirect "301" "/old/readings answers 301"
expect_field echo "201" "POST /api/echo answers 201"
expect_field flaky "429" "/api/flaky answers 429 once armed"
expect_field retry_after "1" "the 429 carries a Retry-After header"
expect_field large "8192" "/api/large?kb=8 returns exactly 8192 bytes"

# --------------------------------------------------------------------------
echo
echo "3. The demonstrations run"
# --------------------------------------------------------------------------

raw_out="$(cd "${lab_dir}" && "${python_bin}" examples/raw_socket_demo.py 2>&1)"
raw_exit=$?
if [ "${raw_exit}" -eq 0 ]; then
  check "examples/raw_socket_demo.py exits 0" "yes"
else
  check "examples/raw_socket_demo.py exits 0 (got ${raw_exit})" "no"
fi
case "${raw_out}" in
  *"GET /api/readings?station=ALPHA HTTP/1.1"*)
    check "the hand-typed request line really is HTTP text" "yes" ;;
  *) check "the hand-typed request line really is HTTP text" "no" ;;
esac
case "${raw_out}" in
  *"HTTP/1.1 200 OK"*) check "the raw response begins with a status line" "yes" ;;
  *) check "the raw response begins with a status line" "no" ;;
esac

stdlib_out="$(cd "${lab_dir}" && "${python_bin}" examples/stdlib_demo.py 2>&1)"
stdlib_exit=$?
if [ "${stdlib_exit}" -eq 0 ]; then
  check "examples/stdlib_demo.py exits 0 — the standard library really can do this" "yes"
else
  check "examples/stdlib_demo.py exits 0 (got ${stdlib_exit})" "no"
fi
case "${stdlib_out}" in
  *"raised      : HTTPError"*)
    check "urllib.request raises on a 404 where requests returns a response" "yes" ;;
  *) check "urllib.request raises on a 404 where requests returns a response" "no" ;;
esac
case "${stdlib_out}" in
  *"connections : 1 opened for those 2 requests"*)
    check "http.client reuses one connection for two requests" "yes" ;;
  *) check "http.client reuses one connection for two requests" "no" ;;
esac

demo_out="$(cd "${lab_dir}" && "${python_bin}" examples/demo.py 2>&1)"
demo_exit=$?
if [ "${demo_exit}" -eq 0 ]; then
  check "examples/demo.py exits 0" "yes"
else
  check "examples/demo.py exits 0 (got ${demo_exit})" "no"
  printf '%s\n' "${demo_out}" | tail -20
fi
case "${demo_out}" in
  *"5 calls, one Session      : 1 TCP connection(s)"*)
    check "demo.py shows one Session using one connection for five calls" "yes" ;;
  *) check "demo.py shows one Session using one connection for five calls" "no" ;;
esac
case "${demo_out}" in
  *"5 calls, requests.get()   : 5 TCP connection(s)"*)
    check "demo.py shows five bare calls opening five connections" "yes" ;;
  *) check "demo.py shows five bare calls opening five connections" "no" ;;
esac
case "${demo_out}" in
  *"ReadTimeout"*) check "demo.py's timeout section really raises ReadTimeout" "yes" ;;
  *) check "demo.py's timeout section really raises ReadTimeout" "no" ;;
esac
case "${demo_out}" in
  *"station=ALPHA+ONE%26station%3DBRAVO"*)
    check "params= percent-encodes a value containing a space and an ampersand" "yes" ;;
  *) check "params= percent-encodes a value containing a space and an ampersand" "no" ;;
esac
case "${demo_out}" in
  *"chunks read      : 64 of at most 8192 bytes"*)
    check "streaming reads 512 KiB as 64 chunks, not one body" "yes" ;;
  *) check "streaming reads 512 KiB as 64 chunks, not one body" "no" ;;
esac

# httpx is NOT a dependency of this lab. If it happens to be installed the
# comparison runs; if not, the demo says so and exits 0 either way.
httpx_out="$(cd "${lab_dir}" && "${python_bin}" examples/httpx_demo.py 2>&1)"
httpx_exit=$?
if [ "${httpx_exit}" -eq 0 ]; then
  check "examples/httpx_demo.py exits 0 whether or not httpx is installed" "yes"
else
  check "examples/httpx_demo.py exits 0 (got ${httpx_exit})" "no"
fi

# --------------------------------------------------------------------------
echo
echo "4. The reference suite"
# --------------------------------------------------------------------------

examples_out="$(cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
examples_exit=$?
if [ "${examples_exit}" -eq 0 ]; then
  check "pytest examples exits 0" "yes"
else
  check "pytest examples exits 0 (got ${examples_exit})" "no"
  printf '%s\n' "${examples_out}" | tail -25
fi
case "${examples_out}" in
  *"48 passed"*) check "pytest examples reports 48 passed" "yes" ;;
  *) check "pytest examples reports 48 passed" "no" ;;
esac

for selection in \
  "a_read_timeout_fires_against_the_slow_endpoint" \
  "retry_succeeds_after_exactly_the_expected_number_of_attempts" \
  "a_missing_station_raises_a_domain_error_with_a_clean_message" \
  "one_session_reuses_one_connection_for_many_requests" \
  "streaming_writes_the_whole_body_in_many_small_chunks"
do
  if (cd "${lab_dir}" && "${pytest_bin}" examples -q -p no:cacheprovider \
        -k "${selection}" >/dev/null 2>&1); then
    check "behaviour asserted: ${selection}" "yes"
  else
    check "behaviour asserted: ${selection}" "no"
  fi
done

# The timeout check is worth timing as well as running: it must give up on
# its own schedule (0.4 s) rather than waiting for the server's 3 s.
timeout_seconds="$(cd "${lab_dir}" && "${python_bin}" - <<'PY' 2>&1
import time
import requests
from pathlib import Path
import sys
sys.path.insert(0, str(Path("examples").resolve()))
from demo_server import base_url, running_server

with running_server() as srv:
    started = time.monotonic()
    try:
        requests.get(f"{base_url(srv)}/api/slow", params={"seconds": 3}, timeout=(3.05, 0.4))
        print("NO-TIMEOUT-RAISED")
    except requests.exceptions.Timeout:
        print(f"{time.monotonic() - started:.2f}")
PY
)"
awk_ok="$("${python_bin}" -c "
v='''${timeout_seconds}'''.strip()
try:
    print('yes' if 0.1 < float(v) < 2.0 else 'no')
except ValueError:
    print('no')
")"
check "a read timeout of 0.4s fires in ${timeout_seconds}s against a 3s endpoint" "${awk_ok}"

# --------------------------------------------------------------------------
echo
echo "5. Nothing here touches the internet — proved, not promised"
# --------------------------------------------------------------------------

guarded_out="$(cd "${lab_dir}" && PYTHONPATH="${lab_dir}/tests" \
  "${pytest_bin}" examples -q -p no:cacheprovider 2>&1)"
guarded_exit=$?
if [ "${guarded_exit}" -eq 0 ]; then
  check "the whole example suite passes with all non-loopback sockets blocked" "yes"
else
  check "the whole example suite passes with all non-loopback sockets blocked" "no"
  printf '%s\n' "${guarded_out}" | tail -25
fi

# And the guard is not vacuous: with it loaded, a real request must fail.
if (cd "${lab_dir}" && PYTHONPATH="${lab_dir}/tests" "${python_bin}" -c "
import requests
requests.get('https://example.com', timeout=2)
" >/dev/null 2>&1); then
  check "the offline guard is real (a request to a public site is blocked)" "no"
else
  check "the offline guard is real (a request to a public site is blocked)" "yes"
fi

# No lab file may name a real remote host. `example.invalid` is allowed: the
# .invalid top-level domain is reserved by RFC 2606 precisely so that it can
# never resolve, which is why the fake-session tests use it.
offenders="$(grep -rn 'http://\|https://' "${lab_dir}/examples" "${lab_dir}/starter" \
  --include='*.py' \
  | grep -v '127\.0\.0\.1' | grep -v 'example\.invalid' | grep -v '{base' | grep -v '{root' \
  | grep -v '{host}' || true)"
if [ -z "${offenders}" ]; then
  check "no example or starter file names a real remote host" "yes"
else
  check "no example or starter file names a real remote host" "no"
  printf '%s\n' "${offenders}"
fi

if grep -rn ':8000' "${lab_dir}/examples" "${lab_dir}/starter" >/dev/null 2>&1; then
  check "no file hard-codes port 8000 (the collision waiting to happen)" "no"
else
  check "no file hard-codes port 8000 (the collision waiting to happen)" "yes"
fi

# --------------------------------------------------------------------------
echo
echo "6. The Day 74 payoff: tests that need no server at all"
# --------------------------------------------------------------------------

# Copy three files — and deliberately NOT demo_server.py or conftest.py — to a
# temporary directory, and run the fake-session suite there. If it passes, the
# client genuinely has an injectable boundary.
solo="$(mktemp -d "${TMPDIR:-/tmp}/day078-solo.XXXXXX")"
cp "${lab_dir}/examples/client.py" "${lab_dir}/examples/fake_session.py" \
   "${lab_dir}/examples/test_without_a_server.py" "${solo}/"
solo_out="$(cd "${solo}" && "${pytest_bin}" . -q -p no:cacheprovider 2>&1)"
solo_exit=$?
if [ "${solo_exit}" -eq 0 ]; then
  check "the fake-session suite passes with no server module present at all" "yes"
else
  check "the fake-session suite passes with no server module present at all" "no"
  printf '%s\n' "${solo_out}" | tail -25
fi
case "${solo_out}" in
  *"20 passed"*) check "that suite is 20 real tests, not a placeholder" "yes" ;;
  *) check "that suite is 20 real tests, not a placeholder (got: $(printf '%s' "${solo_out}" | tail -1))" "no" ;;
esac
if [ -e "${solo}/demo_server.py" ] || [ -e "${solo}/conftest.py" ]; then
  check "the isolated directory really lacks the server" "no"
else
  check "the isolated directory really lacks the server" "yes"
fi
# And it must run under the offline guard too.
if (cd "${solo}" && PYTHONPATH="${lab_dir}/tests" "${pytest_bin}" . -q -p no:cacheprovider \
      >/dev/null 2>&1); then
  check "and it passes with every non-loopback socket blocked" "yes"
else
  check "and it passes with every non-loopback socket blocked" "no"
fi
rm -rf "${solo}"

# The signatures that make all of that possible.
signature_report="$("${python_bin}" - "${lab_dir}" <<'PY' 2>&1
import inspect
import sys
sys.path.insert(0, f"{sys.argv[1]}/examples")
import client

problems = []
for name in ("fetch_readings", "get_with_retry", "stream_to_file"):
    params = inspect.signature(getattr(client, name)).parameters
    if "session" not in params:
        problems.append(f"{name} has no session parameter")
    if params.get("session") and params["session"].kind is not inspect.Parameter.KEYWORD_ONLY:
        problems.append(f"{name}'s session is not keyword-only")
for name in ("get_with_retry", "backoff_delays"):
    params = inspect.signature(getattr(client, name)).parameters
    if "jitter" not in params:
        problems.append(f"{name} has no injectable jitter")
if "sleep" not in inspect.signature(client.get_with_retry).parameters:
    problems.append("get_with_retry has no injectable sleep")
print("OK" if not problems else "; ".join(problems))
PY
)"
if [ "${signature_report}" = "OK" ]; then
  check "every networked function takes session (and sleep, and jitter) as parameters" "yes"
else
  check "every networked function takes session as a parameter (${signature_report})" "no"
fi

# --------------------------------------------------------------------------
echo
echo "7. Retry policy: the statuses, checked one at a time"
# --------------------------------------------------------------------------

policy="$("${python_bin}" - "${lab_dir}" <<'PY' 2>&1
import sys
sys.path.insert(0, f"{sys.argv[1]}/examples")
from client import RETRY_STATUSES

should = {429, 500, 502, 503, 504}
should_not = {200, 201, 204, 301, 304, 400, 401, 403, 404, 409, 422}
wrong = [s for s in should if s not in RETRY_STATUSES]
wrong += [s for s in should_not if s in RETRY_STATUSES]
print("OK" if not wrong else f"wrong: {sorted(wrong)}")
PY
)"
if [ "${policy}" = "OK" ]; then
  check "429 and 5xx are retryable; 4xx and every success code are not" "yes"
else
  check "429 and 5xx are retryable; 4xx and every success code are not (${policy})" "no"
fi

schedule="$("${python_bin}" - "${lab_dir}" <<'PY' 2>&1
import sys
sys.path.insert(0, f"{sys.argv[1]}/examples")
from client import backoff_delays

full = backoff_delays(6, jitter=lambda: 1.0)
half = backoff_delays(4, jitter=lambda: 0.0)
ok = full == [0.5, 1.0, 2.0, 4.0, 8.0] and half == [0.25, 0.5, 1.0]
try:
    backoff_delays(0)
    ok = False
except ValueError:
    pass
print("OK" if ok else f"full={full} half={half}")
PY
)"
if [ "${schedule}" = "OK" ]; then
  check "the backoff doubles, caps at 8s, jitters into the top half of each slot" "yes"
else
  check "the backoff schedule is wrong (${schedule})" "no"
fi

# --------------------------------------------------------------------------
echo
echo "8. Your work in starter/"
# --------------------------------------------------------------------------

starter_out="$(cd "${lab_dir}" && "${pytest_bin}" starter -q -p no:cacheprovider 2>&1)"
starter_exit=$?
if [ "${starter_exit}" -eq 0 ]; then
  check "pytest starter exits 0" "yes"
else
  check "pytest starter exits 0 (got ${starter_exit})" "no"
  printf '%s\n' "${starter_out}" | tail -25
fi

if grep -q 'raise NotImplementedError' "${lab_dir}/starter/client.py"; then
  echo "  (exercises unfinished — structural checks only)"
  for fn in fetch_readings describe_failure backoff_delays get_with_retry \
            make_session stream_to_file
  do
    if grep -q "^def ${fn}(\|^def ${fn}$" "${lab_dir}/starter/client.py"; then
      check "starter/client.py defines ${fn} for you to fill in" "yes"
    else
      check "starter/client.py defines ${fn} for you to fill in" "no"
    fi
  done
  case "${starter_out}" in
    *skipped*) check "unfinished exercises are skipped, so the suite is green from minute one" "yes" ;;
    *) check "unfinished exercises are skipped, so the suite is green from minute one" "no" ;;
  esac
  if grep -q 'exercise 7' "${lab_dir}/starter/test_client.py"; then
    check "starter/test_client.py carries the exercise-7 fake-session section" "yes"
  else
    check "starter/test_client.py carries the exercise-7 fake-session section" "no"
  fi
else
  echo "  (exercises finished — behavioural checks)"
  case "${starter_out}" in
    *skipped*) check "no exercise is still skipped" "no" ;;
    *) check "no exercise is still skipped" "yes" ;;
  esac
  # Your client must pass the reference suite's own fake-session tests.
  yours="$(mktemp -d "${TMPDIR:-/tmp}/day078-yours.XXXXXX")"
  cp "${lab_dir}/starter/client.py" "${lab_dir}/examples/fake_session.py" \
     "${lab_dir}/examples/test_without_a_server.py" "${yours}/"
  if (cd "${yours}" && "${pytest_bin}" . -q -p no:cacheprovider >/dev/null 2>&1); then
    check "YOUR client passes the reference fake-session suite, with no server" "yes"
  else
    check "YOUR client passes the reference fake-session suite, with no server" "no"
    (cd "${yours}" && "${pytest_bin}" . -q -p no:cacheprovider 2>&1 | tail -20)
  fi
  rm -rf "${yours}"
  if grep -qE '^\s*-\s*five requests through one .Session.: _{4,}' "${lab_dir}/starter/NOTES.md"; then
    check "starter/NOTES.md is filled in rather than left with blanks" "no"
  else
    check "starter/NOTES.md is filled in rather than left with blanks" "yes"
  fi
fi

# --------------------------------------------------------------------------
echo
echo "9. The captured output matches what the code does now"
# --------------------------------------------------------------------------

for capture in sample-run.txt pytest-runs.txt test-run.txt FIELDS.md; do
  if [ -s "${lab_dir}/expected-output/${capture}" ]; then
    check "expected-output/${capture} exists and is not empty" "yes"
  else
    check "expected-output/${capture} exists and is not empty" "no"
  fi
done

if grep -q '48 passed' "${lab_dir}/expected-output/pytest-runs.txt" 2>/dev/null; then
  check "the captured pytest run agrees with today's count of 48" "yes"
else
  check "the captured pytest run agrees with today's count of 48" "no"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
tests/sitecustomize.py (1883 bytes)
"""A guard that makes "this lab never touches the internet" a fact, not a claim.

Python imports `sitecustomize` automatically at interpreter start-up if it can
find it on the import path. `tests/run_tests.sh` puts this directory on
PYTHONPATH for one check, then runs the whole example suite under it. Any
attempt to resolve a hostname or connect to an address that is not the
loopback interface raises immediately and fails the run.

If you ever add an example that talks to a real site, this file will tell you
in the least ambiguous way available.
"""

from __future__ import annotations

import socket

LOOPBACK = {"127.0.0.1", "::1", "0.0.0.0", ""}

_real_connect = socket.socket.connect
_real_connect_ex = socket.socket.connect_ex
_real_getaddrinfo = socket.getaddrinfo


class NetworkBlocked(RuntimeError):
    """Raised instead of opening a connection to anything but the loopback."""


def _check(address: object) -> None:
    if isinstance(address, tuple) and address:
        host = address[0]
        if isinstance(host, str) and host not in LOOPBACK:
            raise NetworkBlocked(f"blocked a connection to {host!r} — this lab is offline")


def _connect(self, address):  # type: ignore[no-untyped-def]
    _check(address)
    return _real_connect(self, address)


def _connect_ex(self, address):  # type: ignore[no-untyped-def]
    _check(address)
    return _real_connect_ex(self, address)


def _getaddrinfo(host, *args, **kwargs):  # type: ignore[no-untyped-def]
    if isinstance(host, str) and host not in LOOPBACK:
        raise NetworkBlocked(f"blocked a name lookup for {host!r} — this lab is offline")
    return _real_getaddrinfo(host, *args, **kwargs)


socket.socket.connect = _connect  # type: ignore[method-assign]
socket.socket.connect_ex = _connect_ex  # type: ignore[method-assign]
socket.getaddrinfo = _getaddrinfo  # type: ignore[assignment]

Troubleshooting

Troubleshooting — Day 078 lab

Every symptom below was produced on the authoring machine at least once while building this lab. The fixes are the real ones.

Installation and tooling

ModuleNotFoundError: No module named 'requests' The interpreter running your script is not the one requests is installed in. This is the single most common problem in the whole lab. Check which is which:

which python3
.venv/bin/python3 -c "import requests, sys; print(requests.__version__, sys.executable)"

Run the demos with the virtual environment's interpreter — .venv/bin/python3 examples/demo.py — or activate the environment first. tests/run_tests.sh sidesteps this by resolving python3 from the same directory as the pytest it found.

FAIL: pytest not found. The runner looked in $PYTEST, then .venv/bin/, then PATH, and found nothing. Create the environment as the README says, or run PYTEST=/path/to/pytest bash tests/run_tests.sh. It fails loudly on purpose: a test suite that skips itself when the tool is missing is worse than one that stops.

ModuleNotFoundError: No module named 'demo_server' pytest was pointed at a file outside the directory that holds the modules, or you ran a starter test without starter/conftest.py present. Run pytest starter or pytest examples from the lab directory, not from somewhere else with a full path to one file.

The server

OSError: [Errno 48] Address already in use This should be impossible here, because the lab binds port 0 and lets the operating system choose. If you see it, you have edited a port number into demo_server.py. Put the 0 back. That is the whole reason it is there.

The tests hang for five seconds and then say the server never became ready. wait_until_accepting polls the port and gives up after five seconds. Either the server thread crashed at start-up (run python3 examples/demo_server.py on its own and read the traceback) or something on your machine is blocking loopback connections — some endpoint-security products do this. Try python3 -c "import socket; socket.create_connection(('127.0.0.1', 22), 1)" and see whether loopback works at all.

A stray Python process is left running after a failed test. It should not be: running_server is a context manager, the thread is a daemon thread, and shutdown() and server_close() run in a finally. If you have interrupted a run with Ctrl-C mid-start, check with ps aux | grep demo_server and stop it. Then read running_server and note that the cleanup is in finally for exactly this reason.

requests

A call never returns and the program appears frozen. You forgot timeout=. requests has no default timeout, so a connection to a host that accepts and then says nothing will wait for as long as your operating system's TCP keepalive allows — often hours. Every request in this lab passes timeout=(3.05, 10.0). Make that a habit today and you will never debug this again.

requests.exceptions.ConnectTimeout when you expected ReadTimeout. The tuple is (connect, read), in that order. timeout=(0.4, 3.05) gives up on the handshake; timeout=(3.05, 0.4) gives up on the body. Against the loopback interface the connection is instant, so the first value can be almost anything and the second is the one that fires.

requests.exceptions.MissingSchema: Invalid URL 'api/readings' You dropped the http:// prefix, usually by building the URL from a base that was empty. Print the URL before you send it.

.json() raises requests.exceptions.JSONDecodeError. The body was not JSON. This almost always means the status code was not what you assumed: an HTML error page from a proxy, or an empty 204. Check response.status_code and response.headers["Content-Type"] before calling .json(), which is exactly what describe_failure does.

raise_for_status() did not raise on a 3xx. It raises for 4xx and 5xx only, and by the time you see the response requests has already followed the redirect. Use allow_redirects=False if you want to see the 301 itself.

Your query string arrived wrong. Print response.request.path_url — it shows exactly what went down the wire. If you see station=ALPHA%20ONE&station=BRAVO where you meant one value, you concatenated instead of using params=. The /api/search endpoint exists to show you the server's side of the same story.

The retry exercise

ReadingsUnavailable: gave up after 4 attempts when you expected success. The flaky endpoint was still armed from a previous test. Call /control/reset?fail=N before each retry test — the reference tests do.

The retry test takes six seconds. You used the real time.sleep instead of passing a recorder in. The sleep parameter exists so the schedule can be asserted without waiting; see RecordingSleep in examples/demo.py.

Your backoff numbers do not match. backoff_delays returns the waits between attempts, so there are attempts - 1 of them, and each is multiplied by 0.5 + 0.5 * jitter(). With jitter=lambda: 1.0 the multiplier is 1.0 and you get the raw schedule; with lambda: 0.0 you get half of it. If your first value is 1.0 rather than 0.5, you started the exponent at 1 instead of 0.

The streaming exercise

The chunk count is 1, not 64. You called response.content (or .text, or .json()) somewhere before iterating. Any of those reads the whole body immediately and defeats stream=True.

The file is empty. iter_content yields nothing after the response has been consumed or closed. Keep the with session.get(...) block open around the whole loop.

The connection-reuse exercise

Five requests still show five connections. You used requests.get(...) inside the loop instead of session.get(...). The module-level functions create and discard a Session per call, which is the whole point of the comparison.

The count is one higher than you expected. The server counts every accepted connection from the moment it started, including the readiness probe. Take a before reading and subtract, as the tests and demo.py do.

The offline guard

NetworkBlocked: blocked a name lookup for '...' Something in the code you just added tried to reach a real host while tests/sitecustomize.py was loaded. That is the guard doing its job. Remove the call; this lab is offline by design.

Security notes

Security notes — Day 078 lab

Today is the first day your code talks to another machine, so the security notes stop being theoretical.

What this lab does and does not reach

Every socket this lab opens goes to 127.0.0.1 — the loopback interface, which never leaves your computer — on a port the operating system assigns at run time. No hostname is ever resolved. No packet leaves the machine.

That is enforced, not merely stated. tests/sitecustomize.py replaces socket.socket.connect, socket.socket.connect_ex and socket.getaddrinfo so that any attempt to reach an address that is not the loopback interface raises NetworkBlocked immediately. Section 5 of tests/run_tests.sh runs the whole example suite under that guard, and then proves the guard is not vacuous by confirming that a request to a public site under the same guard is refused.

The one moment the lab needs the internet is the initial pip install -r requirements/requirements.txt.

The server binds loopback only

CountingServer(("127.0.0.1", 0), DemoHandler) binds the loopback address. Had it bound 0.0.0.0, the server would have been reachable from every other machine on your network — a coffee-shop Wi-Fi network included. That one string is the difference between a private fixture and an open service, and it is worth remembering the next time a framework's quick-start tells you to bind 0.0.0.0 "so you can test from your phone".

The server also has no authentication, no input validation worth the name, and /control/reset will happily be called by anyone who can reach it. It is a test fixture. Do not deploy it, and do not copy it into anything real.

Secrets

make_session() reads READINGS_TOKEN from the environment and adds an Authorization: Bearer … header only if it is set. Nothing in this lab contains a token, and the one test that exercises the header sets READINGS_TOKEN=not-a-real-secret through pytest's monkeypatch fixture, so it exists for the duration of one test and nowhere else.

The rule this rehearses is worth stating plainly, because it is the rule people break first and regret longest:

  • Never write a credential into a source file. Source files get committed, pushed, copied into issue reports, and pasted into chat windows. A token in git history is a token you must rotate, and rewriting history does not help because the old objects have already been fetched.
  • Read credentials from the environment, or from a file outside the repository, or from a secrets manager.
  • Add a .env file to .gitignore before you create it, not after.
  • Assume any token in a URL query string is in somebody's access log. Put credentials in headers, never in the path or the query.
  • When you log a request for debugging, log the URL and the status — not the headers. Authorization and Cookie are the two you will leak.

Transport security

This lab uses plain HTTP because it talks to itself over loopback, where there is nothing to intercept. Anything that leaves your machine must use HTTPS. Over plain HTTP, every header — including Authorization — travels as readable text past every device between you and the server.

requests verifies TLS certificates by default. verify=False turns that off and reduces HTTPS to obfuscation: the traffic is encrypted to whoever answered, which may be an attacker. If a corporate proxy is intercepting your traffic, point requests at that proxy's certificate bundle with verify="/path/to/ca-bundle.pem" or the REQUESTS_CA_BUNDLE environment variable. The correct response to a certificate error is to fix the trust chain, never to disable the check.

Redirects and where your credentials go

A redirect can send you to a different host. requests deliberately strips the Authorization header when a redirect crosses hosts, which is the behaviour you want — but it is worth knowing that the protection exists, because hand-rolled redirect handling frequently forgets it and forwards your bearer token to whatever the first server named in its Location header.

allow_redirects=False is the right setting whenever you care about the difference between where you asked and where you ended up.

Denial of service, in both directions

Against you. A response with no Content-Length and an endless body will fill your memory if you call .content. Streaming with iter_content and a bounded chunk size, as exercise 6 does, is the defence — combined with a limit on how many bytes you are willing to accept before giving up.

Against them. A retry loop with no backoff, no jitter and no attempt cap is a small denial-of-service tool aimed at whoever you are calling. If a server returns 429 it is asking you to slow down; honouring Retry-After is both correct and the fastest route back to being served. This is the same argument Day 79 will make about scraping, and it is worth internalising once rather than twice.

Trust the body no more than the sender

response.json() parses whatever arrived. Nothing guarantees the shape you expect — not the status code, not the Content-Type, and certainly not the documentation. Every field you read from a response is input from another party's machine. Validate it before it reaches anything that matters, which is precisely the job Day 82's pydantic models will do properly.