The project
You have spent five lessons learning what happens between typing a URL and seeing a page. Now you will build it. MiniWeb is a pretend internet that runs inside one Python program. It has no real network, but every piece does the job its real counterpart does, using the same rules you studied: URLs are parsed, names are resolved through a cache, requests and responses are real HTTP text, a server routes by method and path, and a browser follows redirects, reuses cached pages and refuses a certificate that does not match the name.
Why simulate it? Because writing the parts forces you to know them exactly. You cannot write parse_request without knowing where the headers end, and you cannot write a cache without deciding when an entry expires. When you meet a real framework later, you will recognise each part as a larger version of something you wrote yourself.
You will fill in a starter file with eight pieces. A built-in self-check reports PASS or FAIL for five groups of behaviour. Then you will run a demo of your own that shows a whole page load, a redirect, a cache hit and three kinds of failure.
What you build
Everything lives in one file. Here are the pieces and exactly how each must behave.
1. parse_url(url)
Takes an absolute URL string and returns a dictionary with the keys scheme, host, port (an integer), path, query and fragment.
- A missing path becomes
/. - A missing port uses the default for the scheme: 80 for
httpand 443 forhttps. - Missing query and fragment are empty strings, and the stored values do not include the
?or#. - A URL without
://raisesValueError. A scheme other thanhttporhttpsraisesValueError.
Example: parse_url("https://shop.example/a/b?x=1&y=2#top") returns scheme https, host shop.example, port 443, path /a/b, query x=1&y=2, fragment top.
2. DNS class
Created with a zone: a dictionary from name to (ip, ttl_seconds). It has a cache and counts how many times it had to ask the zone.
resolve(name, now)returns the IP address.nowis a fake clock in seconds, so your output does not depend on real time.- If the cache holds the name and
nowis strictly less than the expiry time, return the cached address and do not count a lookup. - Otherwise look in the zone, increase
upstream_lookupsby one, store the address with expirynow + ttland return it. - A name that is not in the zone raises
LookupError("NXDOMAIN: <name>").
3. build_request and parse_request
build_request(method, host, target, body="") returns the raw request text, using \r\n line endings:
GET /search?q=shoes HTTP/1.1
Host: shop.example
The first line is the method, the target and HTTP/1.1. Then a Host header. If a body is present, a Content-Length header with the byte length of the body follows. A blank line ends the headers, then the body.
parse_request(raw) does the reverse and returns a dictionary with method, path, query (the text after ?, or empty), headers (names in lower case) and body.
4. build_response and parse_response
build_response(status, body="", headers=None) returns raw response text. The status line is HTTP/1.1 <code> <reason>. Use these reasons: 200 OK, 301 Moved Permanently, 404 Not Found, 405 Method Not Allowed and 500 Internal Server Error, and Unknown for any other code. A Content-Length header comes first, followed by the extra headers.
parse_response(raw) returns status (an integer), headers (lower-case names) and body.
5. Server class
Created with certificate_for, the host name on the server's certificate. It has a route table.
route(method, path, fn)registers a function that takes the parsed request and returns a raw response string.handle(raw)parses the request and finds the route for its method and path.- If there is no route for that path with any method, return a 404 response.
- If the path exists but not for that method, return a 405 response.
- If the route function raises any exception, return a 500 response with the body
Server error. A server must never crash because one handler failed.
6. Browser class
Created with a DNS object and a network: a dictionary from IP address to Server. It has a clock (starting at 0), a cache and a log (a list of text lines).
fetch(url, max_redirects=5) loads a page and returns the parsed response. In order, it does this:
- If a fresh response for exactly this URL is in the cache, log
cache hit <url>and return it. - Parse the URL and resolve the host through the DNS object.
- Find the server at the IP address. If there is none, raise
ConnectionError("no server at <ip>"). - If the scheme is
httpsand the server'scertificate_fordiffers from the host, raiseConnectionError("certificate does not match <host>"). - Build a GET request (target is the path plus
?querywhen there is a query), give it to the server and parse the response. LogGET <url> -> <status> (ip <ip>). - If the status is 301, build the new URL from the same scheme and host plus the
Locationheader, and repeat from step 1. - If the response has a
max-ageheader greater than zero, store it in the cache untilclock + max-age. - After more than
max_redirectsredirects, raiseRuntimeError("too many redirects").
Requirements checklist
Copy this list into your notes and tick items as the self-check passes.
-
parse_urlreturns all six parts and applies default ports and the default path -
parse_urlraisesValueErrorfor a missing or unsupported scheme -
DNS.resolveuses the cache until the TTL runs out, and counts only real lookups -
DNS.resolveraisesLookupErrorfor an unknown name -
build_requestandparse_requestagree with each other on a POST with a body -
Content-Lengthcounts bytes, not characters -
build_responseandparse_responseagree, with lower-case header names after parsing -
Server.handlegives 404 for unknown paths, 405 for wrong methods and 500 when a handler fails -
Browser.fetchfollows a 301 redirect and logs every hop -
Browser.fetchserves a repeat request from the cache whilemax-ageallows -
Browser.fetchrejects a certificate whose name does not match the host - All five self-check groups print
PASS - Your own demo prints a log with at least one redirect, one cache hit and three different errors or statuses
The starter file
Paste your work into this box and press Run. Right now every group prints FAIL, because each function raises NotImplementedError. Your job is to turn the five lines green, one piece at a time. Work in the order of the sections above, since later pieces use earlier ones.
When every piece is done, the output is:
PASS parse_url
PASS DNS cache
PASS messages
PASS Server
PASS BrowserYour own demo
The self-check proves the pieces work alone. The demo proves they work together. Below the self-check, add code that builds a small pretend internet and loads pages through it. Use this setup, or invent your own:
- A server certified for
shop.examplewith routes:GET /(200, bodyWelcome, headermax-age: 60),GET /old(301,Location: /),GET /search(200, bodyresults forfollowed by the query) andGET /boom(a handler that divides by zero). - A DNS zone that knows
shop.exampleat203.0.113.5(TTL 300) andfake.exampleat203.0.113.6(TTL 300). - A second server at
203.0.113.6whose certificate saysother.example.
Then fetch these URLs in order, adding 10 to the browser clock after each: /, / again, /old, /search?q=shoes, /missing, /boom. After them fetch https://fake.example/ and https://nowhere.example/ inside try blocks and print the error each raises. Finally print the log and the number of upstream DNS lookups.
Example output
If your pieces follow the spec, the demo prints exactly this:
200 Welcome
200 Welcome
200 Welcome
200 results for q=shoes
404 Not Found
500 Server error
ConnectionError: certificate does not match fake.example
LookupError: NXDOMAIN: nowhere.example
--- log
GET https://shop.example/ -> 200 (ip 203.0.113.5)
cache hit https://shop.example/
GET https://shop.example/old -> 301 (ip 203.0.113.5)
cache hit https://shop.example/
GET https://shop.example/search?q=shoes -> 200 (ip 203.0.113.5)
GET https://shop.example/missing -> 404 (ip 203.0.113.5)
GET https://shop.example/boom -> 500 (ip 203.0.113.5)
DNS upstream lookups: 2Read the log with the lessons in mind. The first line is a real fetch. The second request for / is a cache hit, because max-age is 60 and only 10 seconds passed. Fetching /old is a 301, and its redirect target / is again served from the cache (the clock is at 20). The search was a fresh request, and the 404 and 500 were produced by the server's own logic and not by the browser. DNS did exactly two upstream lookups: one for shop.example (every later request used the cache) and one for fake.example. The name nowhere.example failed before any lookup was counted.
Grading rubric
60 percent: it works for the normal path.
parse_url, the message builders and parsers, and theServerpass their checks.- A browser fetch of a normal page returns the right status and body.
- Redirects and caching may be missing or flawed, and error handling may be incomplete.
- Code is in one file and runs.
80 percent: it follows the whole spec.
- All five self-check groups print
PASS. - Redirects, caching with
max-age, the TTL-aware DNS cache and the certificate check all work, and the demo log matches the example. - Errors are the right types with the right messages.
- Functions are short, named for what they do, and free of repeated code. Parsing of requests and responses shares logic instead of copying it.
100 percent: it is careful and you can explain it.
- Everything in 80, plus the stretch items below chosen and tested: at least two.
- Edge cases are handled on purpose: a URL with a port but no path, a header value containing a colon, a body with a non-ASCII character whose byte length differs from its character length, and a redirect loop that ends in
RuntimeErrorinstead of running forever. - You wrote at least three extra tests of your own beyond the starter's.
- You can explain, in your own words, what each of the eight pieces corresponds to in the real internet, using the module's vocabulary.
Hints
Try for a few minutes before opening each hint. They go from a nudge to nearly the answer.
Hint 1: parse_url
Peel the URL from the end. First split off the fragment at the first #. Then split off the query at the first ?. Then split the scheme off at ://. What remains is the host (maybe with a port) and the path. rest.partition("/") gives you the host and port part, and then the path (add the slash back). partition(":") on the host part gives you the port, if any. Doing the splits in this order matters because a query or fragment can itself contain a slash.
Hint 2: DNS.resolve
Check the cache first. The condition for a hit is name in self.cache and now < self.cache[name][1]. On a miss, raise LookupError if the name is not in the zone, otherwise increase the counter, store (ip, now + ttl) and return the ip. The test at time 100 with TTL 100 checks that an entry expiring at exactly 100 is already stale.
Hint 3: building and parsing messages
Build a list of lines, then join them with "\r\n", then add "\r\n\r\n" and the body. For the content length use len(body.encode()), which counts bytes. To parse, use raw.partition("\r\n\r\n") to separate head and body, split the head by "\r\n", split the first line on spaces, and split every header line on its first colon only. The status line needs split(" ", 2) so that a reason with spaces stays whole. Parse request and response with the same small helper for headers to avoid repeating yourself.
Hint 4: Server.handle
Look up (method, path) in self.routes. If it is missing, check whether any route has that path ({p for (_m, p) in self.routes}). If yes, 405, otherwise 404. Wrap the call to the route function in try and except Exception and return a 500 response in the except branch.
Hint 5: Browser.fetch
Use a for loop that runs at most max_redirects + 1 times. Inside, do the steps in order and continue after a redirect. Put the raise RuntimeError after the loop, which is reached only when every round ended in a redirect. Remember that the certificate check applies only to https, and that the redirect URL is scheme + "://" + host + location. Store a cache entry only when max-age is above zero.
Full reference solution
Look only after you have tried. This is one correct solution, with the demo at the bottom. It was run, and it prints the output shown above.
Solution
# MiniWeb: a pretend internet that runs in one program.
DEFAULT_PORTS = {"http": 80, "https": 443}
def parse_url(url):
rest, fragment = (url.split("#", 1) + [""])[:2]
rest, query = (rest.split("?", 1) + [""])[:2]
if "://" not in rest:
raise ValueError("missing scheme: " + url)
scheme, rest = rest.split("://", 1)
if scheme not in DEFAULT_PORTS:
raise ValueError("unsupported scheme: " + scheme)
host_port, slash, path = rest.partition("/")
path = "/" + path
host, _, port = host_port.partition(":")
port = int(port) if port else DEFAULT_PORTS[scheme]
return {"scheme": scheme, "host": host, "port": port,
"path": path, "query": query, "fragment": fragment}
class DNS:
def __init__(self, zone):
self.zone = zone # name -> (ip, ttl seconds)
self.cache = {} # name -> (ip, expires_at)
self.upstream_lookups = 0
def resolve(self, name, now):
if name in self.cache and now < self.cache[name][1]:
return self.cache[name][0]
if name not in self.zone:
raise LookupError("NXDOMAIN: " + name)
ip, ttl = self.zone[name]
self.upstream_lookups += 1
self.cache[name] = (ip, now + ttl)
return ip
def build_request(method, host, target, body=""):
lines = [f"{method} {target} HTTP/1.1", f"Host: {host}"]
if body:
lines.append(f"Content-Length: {len(body.encode())}")
return "\r\n".join(lines) + "\r\n\r\n" + body
def parse_headers(lines):
headers = {}
for line in lines:
k, v = line.split(":", 1)
headers[k.strip().lower()] = v.strip()
return headers
def parse_request(raw):
head, _, body = raw.partition("\r\n\r\n")
lines = head.split("\r\n")
method, target, _version = lines[0].split(" ")
path, _, query = target.partition("?")
return {"method": method, "path": path, "query": query,
"headers": parse_headers(lines[1:]), "body": body}
def build_response(status, body="", headers=None):
reasons = {200: "OK", 301: "Moved Permanently", 404: "Not Found",
405: "Method Not Allowed", 500: "Internal Server Error"}
lines = [f"HTTP/1.1 {status} {reasons.get(status, 'Unknown')}"]
all_headers = {"Content-Length": str(len(body.encode()))}
all_headers.update(headers or {})
lines += [f"{k}: {v}" for k, v in all_headers.items()]
return "\r\n".join(lines) + "\r\n\r\n" + body
def parse_response(raw):
head, _, body = raw.partition("\r\n\r\n")
status_line, *header_lines = head.split("\r\n")
_version, code, _reason = status_line.split(" ", 2)
return {"status": int(code), "headers": parse_headers(header_lines), "body": body}
class Server:
def __init__(self, certificate_for):
self.certificate_for = certificate_for # host name on its certificate
self.routes = {} # (method, path) -> function(request)
def route(self, method, path, fn):
self.routes[(method, path)] = fn
def handle(self, raw):
req = parse_request(raw)
fn = self.routes.get((req["method"], req["path"]))
if fn is None:
paths = {p for (_m, p) in self.routes}
if req["path"] in paths:
return build_response(405, "Method Not Allowed")
return build_response(404, "Not Found")
try:
return fn(req)
except Exception:
return build_response(500, "Server error")
class Browser:
def __init__(self, dns, network):
self.dns = dns
self.network = network # ip -> Server
self.cache = {} # url -> (response, expires_at)
self.log = []
self.clock = 0
def fetch(self, url, max_redirects=5):
for _ in range(max_redirects + 1):
if url in self.cache and self.clock < self.cache[url][1]:
self.log.append(f"cache hit {url}")
return self.cache[url][0]
u = parse_url(url)
ip = self.dns.resolve(u["host"], self.clock)
server = self.network.get(ip)
if server is None:
raise ConnectionError("no server at " + ip)
if u["scheme"] == "https" and server.certificate_for != u["host"]:
raise ConnectionError("certificate does not match " + u["host"])
target = u["path"] + ("?" + u["query"] if u["query"] else "")
raw = server.handle(build_request("GET", u["host"], target))
resp = parse_response(raw)
self.log.append(f"GET {url} -> {resp['status']} (ip {ip})")
if resp["status"] == 301:
url = u["scheme"] + "://" + u["host"] + resp["headers"]["location"]
continue
age = int(resp["headers"].get("max-age", "0"))
if age:
self.cache[url] = (resp, self.clock + age)
return resp
raise RuntimeError("too many redirects")
# ---- build the pretend internet -------------------------------------------
shop = Server("shop.example")
shop.route("GET", "/", lambda r: build_response(200, "Welcome", {"max-age": "60"}))
shop.route("GET", "/old", lambda r: build_response(301, "", {"Location": "/"}))
shop.route("GET", "/search", lambda r: build_response(200, "results for " + r["query"]))
shop.route("GET", "/boom", lambda r: 1 / 0)
dns = DNS({"shop.example": ("203.0.113.5", 300), "fake.example": ("203.0.113.6", 300)})
network = {"203.0.113.5": shop, "203.0.113.6": Server("other.example")}
browser = Browser(dns, network)
for url in ["https://shop.example/", "https://shop.example/",
"https://shop.example/old", "https://shop.example/search?q=shoes",
"https://shop.example/missing", "https://shop.example/boom"]:
resp = browser.fetch(url)
print(resp["status"], resp["body"])
browser.clock += 10
for url in ["https://fake.example/", "https://nowhere.example/"]:
try:
browser.fetch(url)
except Exception as e:
print(type(e).__name__ + ":", e)
print("--- log")
print("\n".join(browser.log))
print("DNS upstream lookups:", dns.upstream_lookups)Why it is built this way:
parse_urlremoves the fragment and then the query before looking at the rest, because those two parts can contain slashes and colons that would confuse later splits.DNS.resolvechecksnow < expiry, so an entry is dead at exactly its expiry time. The counter increases only when the zone is asked.parse_headersis shared by both parsers, so lower-casing and splitting on the first colon happen in one place.Server.handlecatches every exception from a handler. A server that crashes on one bad route takes all the others down with it.Browser.fetchis a loop and not a recursion, so the redirect limit is a plain counter and a redirect loop ends withRuntimeError.
Stretch extensions
For the top band of the rubric, add at least two of these, and write a test for each.
- Redirect types. Handle 302, 307 and 308 as well as 301. Cache nothing for temporary redirects, and only a 301 or 308 in a separate redirect cache.
- Conditional requests. Give responses an
ETagheader. When a cached entry is stale, sendIf-None-Matchand let the server answer 304 with no body. On a 304 the browser reuses the cached body and refreshes the expiry. - Cookies. Let a server send
Set-Cookie: session=abc123. The browser stores it per host and sendsCookie: session=abc123on later requests to the same host. Add a/meroute that answersHello, <user>only when the cookie is present, and a 401 otherwise. - Relative redirects and full URLs. Allow
Locationto be a full URL on another host, not only a path, and useurllib.parse.urljointo resolve both kinds. - Query parsing. Make
parse_requestreturn the query as a dictionary usingurllib.parse.parse_qs, and add a/searchroute that readsqfrom it. - A real TLS check. Replace the single
certificate_forstring with a chain such as the one in lesson 5: a certificate name, an issuer and a list of trusted roots. Reject self-signed and unknown issuers with different error messages. - Statistics. Make the browser count cache hits, redirects and requests per host and print a summary table.
- A real server. With Python's
http.servermodule, serve one of your routes onlocalhost:8000and fetch it withcurl -i. Compare the real response with the one yourbuild_responsemakes.
How to know you are done
You are done when the five self-check lines say PASS, your demo output matches the example, every box in the requirements checklist is ticked, and you can answer these without looking: Which of your pieces is the resolver? Where does the TTL decision happen? Why is a 405 different from a 404? Why does the browser compare the certificate name with the host name? What would break if Content-Length were wrong? If you can answer them, you have the model that the rest of web development builds on.