0%

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 sends the second request for you. Whether to follow, where exactly to go, which and body to send there, and when to give up are all the client’s decisions. This lesson writes a client that makes them: 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 and an optional body instead of a GET to practice-api.

  • url_parts(url) from Lesson 2 gives the host, the and the target.

  • with contextlib.closing(make_connection(host, port, timeout=timeout)) as conn: makes the connection and closes it on every path, also when getresponse() raises TimeoutError.

  • conn.request(method, target, body=body, headers=headers) sends the request. body is None for a request with no body.

  • Inside the block, build the header from response.getheaders() with lowercase names and of values, exactly as get_json does, 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:

StatusNext method
303GET, whatever the method was (RFC 9110 also allows keeping a HEAD)
301, 302GET when the method was POST; otherwise the same method
307, 308the 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 instead of sending one more request. With max_hops=3 against the circle, it sends four requests and raises after the fourth:

RequestSentReplyHops
1GET /a307, location /bhop 1
2GET /b307, location /ahop 2
3GET /a307, location /bhop 3
4GET /b307, location /aa 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 , in response.history, which Chapter 10 shows.

Here is the that puts it all together:

  1. Send the request with request_once.

  2. If the status is not in REDIRECTS, or there is no location, stop: this record is the answer.

  3. If len(history) already equals max_hops, raise ValueError.

  4. Append {"status": ..., "url": ...} for this reply to history.

  5. Work out the method with next_method. If it changed, set the body to None and leave the request’s content-type out of the headers.

  6. Resolve the location against the current URL with resolve_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 returns method unchanged.

  • request_once(method, url, *, body, headers, make_connection, timeout) splits url with url_parts, opens one connection with make_connection(host, port, timeout=timeout) inside with contextlib.closing(...) as conn:, sends conn.request(method, target, body=body, headers=headers), and returns a with "status", "reason", "headers" (lowercase names, each to a of values), "body" (read inside the block) and "url" (the it was given).

  • follow(method, url, *, body, headers, make_connection, max_hops, timeout) sends the request with request_once and, while the reply’s status is in REDIRECTS and it has a location header: raises if it has already followed max_hops redirects; appends {"status": ..., "url": ...} for the reply to a history list; uses next_method for the next method, and when the method changes sets the body to None and leaves the request’s content-type header out; resolves the location against the current URL with resolve_location. It returns the last record with "history" added. headers=None becomes {}.

Run python pl_http.py follow in the to watch 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 you have not seen: a relative 308 for a 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 , a 3xx without 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.