0%

requests and httpx, Now That You Know · practice

Every Feature Is a Mechanism

In Lesson 1 a session carried a for you. Every other feature of requests that this course uses is also something you have already written by hand, in wire.py or in your server. Here they are side by side:

requestsWhat it doesYour version
response.historythe redirects followed on the way, oldest firstfollow and its history, Lesson 6.4
allow_redirects=Falsereturn the first 3xx instead of following ithttp.client, which never follows, Lesson 3.1
auth=(user, password)the Authorization: Basic linebasic_header, Lesson 7.5
session.cookiesthe jar: keep Set-Cookie, send Cookiemake_cookie_opener, Lesson 7.3
raise_for_status()raise for a 4xx or 5xx statusclassify and its client and server error classes, Chapter 3
the session’s connection poolseveral requests on one kept-open connectionfetch_many, Lesson 9.4
.json()decode the body, read to the length the headers gaveread_body and json_body, Chapters 1 and 3
timeout=(1.0, 2.0)a bounded connect, then bounded readsthe timeouts of Lessons 3.1 and 3.5

The table has no row for a cache. Keeping a response, deciding whether it is fresh, and sending If-None-Match were your cached_get in Lesson 8.5, and requests does none of it. More on that below.

Take the first two rows in code. After response = session.get("http://books.test/v0/books/2"), response.history holds the 308 response, and response.url is the final , http://books.test/books/2. Each response in history has its own status_code and url, the same two facts your follow kept in its history entries. With allow_redirects=False, requests behaves like http.client: the 308 itself comes back, response.is_redirect is True, and response.next is the request it would have sent next.

Most rows behave exactly as your code did. Three do not: the 302 rule, the head() default, and the encoding auth= uses. Those are worth knowing before you trust the library.

Where the library differs from you

Your follow changes the only after a 303, or after a 301 or 302 for a POST; after a 307 or 308 it keeps the method and the body. requests agrees on 301, 307, and 308, and on 303 for every method except HEAD, which it keeps. On 302 it does something else: it turns every method except HEAD into GET, so a PUT answered with 302 is sent on as a GET with no body. Its says why, in substance: browsers do it, despite the standard. RFC 9110 allows a client to change only a POST to GET after a 301 or 302. httpx, in Lesson 3, has the same browser rule.

That is one reason your server uses 308 for the old /v0 paths: under a 308, every client keeps the method and the body.

A server answers PUT /books/2 with 302 and location: /books/2-new. What does requests send next?

The other difference is a default. session.get, post, put, and delete follow redirects unless you pass allow_redirects=False. session.head does the opposite: it returns a 3xx as it is, unless you pass allow_redirects=True.

session.head(url) meets a 308. What comes back?

Credentials are bytes

auth=(user, password) builds the same header as your basic_header: user:password, then . The difference is the step in between. Your basic_header encoded the text as , and your server decodes it as UTF-8. requests encodes text credentials as Latin-1, the 256-character encoding once assumed.

For ASCII names that changes nothing, because ASCII bytes are the same in both. For zoë it does:

What does this print?

print("zoë".encode("latin-1"), "zoë".encode("utf-8"))

Your server’s strict UTF-8 decode cannot read Latin-1’s lone \xeb, so a login as zoë gets 401 with the right password. A character Latin-1 does not have at all, such as ✓, is worse: requests raises UnicodeEncodeError before anything is sent.

The fix is to hand requests bytes, which it sends as they are:

credentials = (user.encode("utf-8"), password.encode("utf-8"))
response = session.post(base_url + "/login", auth=credentials, timeout=timeout)

httpx, in Lesson 3, encodes text credentials as UTF-8 and needs no such step.

No cache in requests

A session keeps cookies, headers, and connections. It does not keep responses. Two session.get(url) calls a second apart send two full requests, even when the first reply said cache-control: max-age=60. Revalidating is still your job: you hold the tag and send it.

response = session.get(url, headers={"if-none-match": etag}, timeout=timeout)

A 304 means your copy is still current; a 200 brings a new body and a new tag.

Two session.get(url) calls a second apart; the first reply had cache-control: max-age=60 and an etag. What does the second call send?

Your task

In side_by_side.py:

  1. redirect_trail(response) returns one line, f"{r.status_code} {r.url}", for each response in response.history, oldest first, and then one for response itself. With no redirect, that is one line.

  2. revalidate(url, etag, *, session=None, timeout=TIMEOUT) sends one GET url with the header if-none-match: etag and timeout=timeout, and returns the . Use client = requests if session is None else session, the line from Python in Practice, and let requests follow any redirect.

  3. Change who_am_i to pass its credentials as UTF-8 bytes.

Then run:

python pl_http.py mechanisms

pl_http adds a user, zoë, with the password pässwörd ✓, to your server’s state. Before your change, the last part ends in requests’ refusal:

--- your who_am_i() as zoë ---
raised UnicodeEncodeError: 'latin-1' codec can't encode character '\u2713' in position 9: ordinal not in range(256)

After it, the trail shows the old path’s 308 and the book, and the tag you hold decides between 200 and 304:

--- pl_http's session.get() of /v0/books/2?x=1, then your redirect_trail() ---
value: ['308 http://books.test/v0/books/2?x=1', '200 http://books.test/books/2?x=1']
--- requests and replies ---
request 1  GET /books/2  (If-None-Match: "stale")  ->  200 OK  (connection 1)
--- your revalidate() of /books/2 with the tag "stale" ---
value: 200

The next part sends the book’s current tag and gets value: 304, and who_am_i as zoë returns value: 'zoë'. Press Check my work. Lesson 3 does the same jobs with httpx, whose defaults differ from requests’ in five ways.

Task

Read a redirect trail, revalidate by hand, and send credentials as .

In side_by_side.py:

  1. redirect_trail(response) returns a of : one f"{r.status_code} {r.url}" for each response in response.history, oldest first, then one for response itself.

  2. revalidate(url, etag, *, session=None, timeout=TIMEOUT) sends one GET url with the header if-none-match: etag, exactly as given, and timeout=timeout, and returns the reply’s status_code. Use client = requests if session is None else session. Let requests follow redirects, as it does by default: do not pass allow_redirects=False.

  3. who_am_i passes its credentials as UTF-8 bytes: auth=(user.encode("utf-8"), password.encode("utf-8")). Everything else in it stays as in Lesson 1.

In the , python pl_http.py mechanisms adds the user zoë to your server, then runs your redirect_trail on a request for an old /v0 path, your revalidate with a stale tag and with the current one, and your who_am_i as zoë.

Then press Check my work. It runs your on a real requests session whose requests are answered by a small server inside the check, in memory, with users, passwords, a base , a book, and tags you have not seen: two users whose names or passwords are not ASCII must log in; redirect_trail must list a 308 and the book it leads to, and keep the order of a longer history; revalidate through a 308 must get 304 for the current tag and 200 for a stale one, sending the tag and the timeout on both hops, with a session and without one. Nothing from your terminal is graded.