Redirects · practice
Follow Redirects with a Hop Limit
Your server now answers GET /v0/books/2?x=1 with 308 Permanent Redirect and location: /books/2?x=1, and http.client hands that reply back and stops. Nothing in follow, in wire.py, built from three small
One request
request_once(method, url, *, body, headers, make_connection, timeout) sends one request and returns one record. You have written it before: it is get_json from Chapter 3, Lesson 1, with any method, any GET to practice-api.
url_parts(url)from Lesson 2 gives the host, theand the target. with contextlib.closing(make_connection(host, port, timeout=timeout)) as conn:makes the connectionand closes it on every path, also when getresponse()raisesTimeoutError.conn.request(method, target, body=body, headers=headers)sends the request.bodyisNonefor a request with no body.Inside the block, build the header
from response.getheaders()with lowercase names andof values, exactly as get_jsondoes, and read the body.
The record has "status", "reason", "headers", "body", and one new key, "url": the URL this request went to. A follower needs it, because after a redirect the URL that answered is no longer the one it started with.
The method after a redirect
Lesson 1 sorted the five redirect statuses by what they do to the method. next_method(status, method) turns that table into code and returns the method for the next request:
| Status | Next method |
|---|---|
| 303 | GET, whatever the method was (RFC 9110 also allows keeping a HEAD) |
| 301, 302 | GET when the method was POST; otherwise the same method |
| 307, 308 | the same method |
When the method changes, the body goes too: a GET carries none. So does the request’s content-type header, which described a body that is no longer sent. When the method stays, the body and every header go to the new address unchanged. That is the whole point of 307 and 308.
follow("PUT", "http://books.test/v0/books/2", body=...) gets a 308 with location: /books/2. What does it send next?
Stop
A server can send a client in a circle: /a redirects to /b, and /b back to /a. A follower that trusts every location sends requests until something breaks. So follow counts. It takes max_hops, the most redirects it will follow, 5 by default (MAX_HOPS). A hop is one redirect followed. When max_hops redirects have already been followed and the reply is yet another redirect, follow raises max_hops=3 against the circle, it sends four requests and raises after the fourth:
| Request | Sent | Reply | Hops |
|---|---|---|---|
| 1 | GET /a | 307, location /b | hop 1 |
| 2 | GET /b | 307, location /a | hop 2 |
| 3 | GET /a | 307, location /b | hop 3 |
| 4 | GET /b | 307, location /a | a fourth hop would pass the limit: ValueError |
What does this print?
moves = {"/a": "/b", "/b": "/a"}
path = "/a"
requests = 1
while path in moves and requests < 4:
path = moves[path]
requests = requests + 1
print(path, requests)
Only a reply that is a redirect counts. REDIRECTS, at the top of wire.py, lists the five statuses follow follows. A reply with any other status is final, and so is a 3xx without a location header, which gives the client nowhere to go. 304 Not Modified is a 3xx too, but it is not in the list: it is an answer, not a pointer.
The history key
follow returns the final record with one more key, "history": a list with one dict per redirect followed, each holding the redirect’s "status" and the "url" that sent it. An empty list means there was no redirect. requests keeps the same information, as a list of the response.history, which Chapter 10 shows.
Here is the
Send the request with
request_once.If the status is not in
REDIRECTS, or there is nolocation, stop: this record is the answer.If
len(history)already equalsmax_hops, raiseValueError.Append
{"status": ..., "url": ...}for this reply tohistory.Work out the method with
next_method. If it changed, set the body toNoneand leave the request’scontent-typeout of the headers.Resolve the
locationagainst the current URL withresolve_location, make that the current URL, and go back to step 1.
A while True: loop that ends with break at step 2 fits this shape. Resolving against the current URL, not the first one, is what made the third URL in Lesson 2’s quiz /books/3.
Real clients do one more thing: they drop credentials such as an Authorization header, so a login meant for one server is never handed to another. RFC 9110 (section 15.4) asks for more than libraries usually do: it asks a client to drop Authorization and Cookie on any automatic redirect, while libraries such as requests drop them only when the host changes. Chapter 7 comes back to it.
Your task
In wire.py, write next_method, request_once and follow. follow turns headers=None into {} before the first request, closes every connection through request_once, and passes timeout to every one. Then run the helper:
python pl_http.py follow
It calls wire.follow("GET", "http://books.test/v0/books/2", make_connection=...), where the connection it hands over goes to your own server on this machine, so books.test is never looked up. Each request and the record your follow returned are printed:
request 1 GET /v0/books/2 -> 308 Permanent Redirect
request 2 GET /books/2 -> 200 OK
--- your client returned ---
status: 200
...
url: 'http://books.test/books/2'
history: [{'status': 308, 'url': 'http://books.test/v0/books/2'}]
The lines left out are the reason, the headers and the body of book 2.
Then press Check my work. Your client and your server now agree on one redirect. The next lesson hands the same job to a library and compares its rules with yours.
Task
Write next_method, request_once and follow in wire.py.
next_method(status, method)returns"GET"after a 303 for any, and after a 301 or 302 for a POST; otherwise it returnsmethodunchanged.request_once(method, url, *, body, headers, make_connection, timeout)splitsurlwithurl_parts, opens one connection withmake_connection(host, port, timeout=timeout)insidewith contextlib.closing(...) as conn:, sendsconn.request(method, target, body=body, headers=headers), and returns awith "status","reason","headers"(lowercase names, each to aof values), "body"(read inside the block) and"url"(theit was given). follow(method, url, *, body, headers, make_connection, max_hops, timeout)sends the request withrequest_onceand, while the reply’s status is inREDIRECTSand it has alocationheader: raisesif it has already followed max_hopsredirects; appends{"status": ..., "url": ...}for the reply to a history list; usesnext_methodfor the next method, and when the method changes sets the body toNoneand leaves the request’scontent-typeheader out; resolves thelocationagainst the current URL withresolve_location. It returns the last record with"history"added.headers=Nonebecomes{}.
Run python pl_http.py follow in the follow against your own server.
Then press Check my work. It calls next_method directly, then request_once and follow with a stand-in for make_connection whose connections answer with scripted replies, on hosts and PUT, a 303 and a 301 after a POST, a 301 after a PUT, a 307 after a POST, two redirects in a row whose second location is relative to the second URL, an absolute location on another host, exactly max_hops redirects, one more than that, a location, a 304, an https location, and a TimeoutError halfway. It checks every request’s host, port, timeout, method, target, body and content-type, the record and its history, that ValueError comes after exactly max_hops + 1 requests, and that every connection was closed. It stops a follower that opens more than 20 connections. Nothing from your terminal is graded.