1"""Chapter 3 practice: a tiny HTTP server for a bookstore (Level 3 Build) and the
2'sessions stored on one server' bug behind a load balancer (Level 4 Debug).
3Standard library only. Run: python3 ch03_client_server.py"""
4import itertools
5import json
6import secrets
7import threading
8import urllib.error
9import urllib.request
10from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
11
12# ---- Level 3: a tiny bookstore API -------------------------------------------------
13
14BOOKS = {"42": {"id": "42", "title": "Designing Systems", "price": 499}}
15CART = []
16
17
18class BookstoreHandler(BaseHTTPRequestHandler):
19 def _send(self, status, body=None):
20 data = b"" if body is None else json.dumps(body).encode()
21 self.send_response(status)
22 self.send_header("Content-Type", "application/json")
23 self.send_header("Content-Length", str(len(data)))
24 self.end_headers()
25 self.wfile.write(data)
26
27 def do_GET(self):
28 if self.path.startswith("/books/"):
29 book = BOOKS.get(self.path.rsplit("/", 1)[1])
30 return self._send(200, book) if book else self._send(404, {"error": "no such book"})
31 self._send(404, {"error": "not found"})
32
33 def do_POST(self):
34 if self.path != "/cart":
35 return self._send(405, {"error": "method not allowed"})
36 length = int(self.headers.get("Content-Length", 0))
37 try:
38 item = json.loads(self.rfile.read(length) or b"{}")
39 except json.JSONDecodeError:
40 return self._send(400, {"error": "body is not valid JSON"})
41 if item.get("book_id") not in BOOKS:
42 return self._send(400, {"error": "unknown book_id"})
43 CART.append(item)
44 self._send(201, {"cart_size": len(CART)})
45
46 def do_DELETE(self):
47 self._send(405, {"error": "method not allowed"})
48
49 def log_message(self, *args): # keep test output quiet
50 pass
51
52
53def request(method, url, body=None):
54 data = None if body is None else (body if isinstance(body, bytes) else json.dumps(body).encode())
55 req = urllib.request.Request(url, data=data, method=method,
56 headers={"Content-Type": "application/json"})
57 try:
58 with urllib.request.urlopen(req) as r:
59 return r.status, json.loads(r.read() or b"null")
60 except urllib.error.HTTPError as e:
61 return e.code, json.loads(e.read() or b"null")
62
63
64# ---- Level 4: sessions behind a load balancer ---------------------------------------
65
66class BuggyAppServer:
67 """Keeps logged-in sessions in its own memory."""
68
69 def __init__(self):
70 self.sessions = {} # BUG: each server has its own, separate notebook
71
72 def login(self, user):
73 sid = secrets.token_hex(8)
74 self.sessions[sid] = user
75 return sid
76
77 def whoami(self, sid):
78 return self.sessions.get(sid) # None means "please log in again"
79
80
81class SharedStore:
82 """Stands in for Redis: one notebook every server can read."""
83
84 def __init__(self):
85 self.data = {}
86
87
88class FixedAppServer:
89 def __init__(self, store):
90 self.store = store # fix: every server uses the same shared store
91
92 def login(self, user):
93 sid = secrets.token_hex(8)
94 self.store.data[sid] = user
95 return sid
96
97 def whoami(self, sid):
98 return self.store.data.get(sid)
99
100
101def round_robin(servers):
102 return itertools.cycle(servers)
103
104
105def run_checks():
106 server = ThreadingHTTPServer(("127.0.0.1", 0), BookstoreHandler)
107 threading.Thread(target=server.serve_forever, daemon=True).start()
108 base = f"http://127.0.0.1:{server.server_address[1]}"
109 try:
110 assert request("GET", base + "/books/42") == (200, BOOKS["42"])
111 assert request("GET", base + "/books/7")[0] == 404
112 assert request("POST", base + "/cart", {"book_id": "42"}) == (201, {"cart_size": 1})
113 assert request("POST", base + "/cart", {"book_id": "nope"})[0] == 400
114 assert request("POST", base + "/cart", b"{not json")[0] == 400
115 assert request("DELETE", base + "/books/42")[0] == 405
116 finally:
117 server.shutdown()
118
119 # Level 4: two servers, round-robin load balancer.
120 lb = round_robin([BuggyAppServer(), BuggyAppServer()])
121 sid = next(lb).login("asha") # login lands on server A
122 seen = [next(lb).whoami(sid) for _ in range(4)] # B, A, B, A
123 assert seen == [None, "asha", None, "asha"], seen # logged out on every other click
124
125 store = SharedStore()
126 lb = round_robin([FixedAppServer(store), FixedAppServer(store)])
127 sid = next(lb).login("asha")
128 assert [next(lb).whoami(sid) for _ in range(4)] == ["asha"] * 4
129
130 print("All checks pass. Buggy sessions:", seen)
131
132
133if __name__ == "__main__":
134 run_checks()