1"""Chapter 24: APIs and idempotency. Tested with Python 3, standard library only.
2
3Running example: the bookstore's checkout calls a payment API: "charge ₹1,248 for order 881".
4"""
5import hashlib
6import hmac
7import json
8import random
9import secrets
10
11
12# ---------- 24.2 / 24.7 method semantics and the double charge ----------
13
14class Payments:
15 """A toy payment provider."""
16
17 def __init__(self):
18 self.charges = []
19 self.keys = {} # idempotency key -> {"hash", "status", "response"}
20
21 def _do_charge(self, order_id, amount):
22 cid = f"ch_{len(self.charges) + 1}"
23 self.charges.append((cid, order_id, amount))
24 return {"id": cid, "order_id": order_id, "amount": amount}
25
26 # POST without a key: every call creates a new charge
27 def charge(self, order_id, amount):
28 return 201, self._do_charge(order_id, amount)
29
30 # POST with an Idempotency-Key (24.8)
31 def charge_with_key(self, key, order_id, amount, now=0, ttl=86_400):
32 body_hash = hashlib.sha256(json.dumps([order_id, amount]).encode()).hexdigest()
33 rec = self.keys.get(key)
34 if rec and now - rec["at"] > ttl:
35 rec = None # expired: treated as new
36 if rec:
37 if rec["hash"] != body_hash:
38 return 422, {"error": "idempotency key reused with different parameters"}
39 if rec["status"] == "in_progress":
40 return 409, {"error": "a request with this key is still in progress"}
41 return rec["code"], rec["response"] # replay the stored result
42 self.keys[key] = {"hash": body_hash, "status": "in_progress", "at": now} # claim first
43 code, resp = 201, self._do_charge(order_id, amount)
44 self.keys[key].update(status="done", code=code, response=resp)
45 return code, resp
46
47
48def client_with_timeout(api, order_id, amount, use_key):
49 """The first response is lost on the way back (a timeout); the client retries once."""
50 key = f"checkout-{order_id}" if use_key else None
51 for _ in range(2):
52 if use_key:
53 code, resp = api.charge_with_key(key, order_id, amount)
54 else:
55 code, resp = api.charge(order_id, amount)
56 return code, resp
57
58
59# ---------- 24.3 versioning ----------
60
61BOOK = {"id": 42, "title": "Dune", "price_paise": 49900, "author": "Frank Herbert"}
62
63
64def render_v1(book):
65 return {"id": book["id"], "title": book["title"], "price": book["price_paise"] / 100}
66
67
68def render_v2(book):
69 return {"id": book["id"], "title": book["title"],
70 "price": {"amount": book["price_paise"], "currency": "INR"}, # breaking change
71 "author": book["author"]}
72
73
74def v1_client_reads_price(resp):
75 return float(resp["price"])
76
77
78# ---------- 24.4 pagination ----------
79
80def offset_page(rows, offset, limit):
81 return rows[offset:offset + limit]
82
83
84def cursor_page(rows, after_id, limit):
85 """rows sorted by id descending (newest first); cursor = last id seen."""
86 if after_id is None:
87 return rows[:limit]
88 return [r for r in rows if r < after_id][:limit]
89
90
91# ---------- 24.5 errors ----------
92
93def problem(status, title, detail):
94 """RFC 9457 'problem details' body."""
95 return status, {"type": f"https://api.example.com/errors/{title.lower().replace(' ', '-')}",
96 "title": title, "status": status, "detail": detail}
97
98
99def to_status(error):
100 return {"validation": 400, "unauthenticated": 401, "forbidden": 403, "not_found": 404,
101 "conflict": 409, "rate_limited": 429, "bug": 500, "upstream_down": 503}[error]
102
103
104RETRYABLE = {408, 429, 500, 502, 503, 504}
105
106
107# ---------- 24.6 API keys ----------
108
109def issue_key():
110 raw = "sk_live_" + secrets.token_urlsafe(24)
111 stored = hashlib.sha256(raw.encode()).hexdigest() # store only the hash
112 return raw, stored
113
114
115def check_key(presented, stored_hash):
116 return hmac.compare_digest(hashlib.sha256(presented.encode()).hexdigest(), stored_hash)
117
118
119# ---------- 24.9 safe retries ----------
120
121def should_retry(method, status, has_key):
122 if status not in RETRYABLE:
123 return False
124 return method in {"GET", "PUT", "DELETE", "HEAD", "OPTIONS"} or has_key
125
126
127def retry_delays(attempts, base=0.5, cap=20, seed=24):
128 rnd = random.Random(seed)
129 return [rnd.uniform(0, min(cap, base * 2 ** n)) for n in range(attempts)]
130
131
132# ---------- 24.10 webhooks ----------
133
134def sign(secret, timestamp, payload):
135 msg = f"{timestamp}.{payload}".encode()
136 return hmac.new(secret, msg, hashlib.sha256).hexdigest()
137
138
139def verify(secret, timestamp, payload, signature, now, tolerance=300):
140 if abs(now - timestamp) > tolerance:
141 return False # replay of an old message
142 return hmac.compare_digest(sign(secret, timestamp, payload), signature)
143
144
145class WebhookReceiver:
146 def __init__(self, secret):
147 self.secret, self.seen, self.paid = secret, set(), []
148
149 def handle(self, event_json, timestamp, signature, now):
150 if not verify(self.secret, timestamp, event_json, signature, now):
151 return 400
152 event = json.loads(event_json)
153 if event["id"] in self.seen:
154 return 200 # duplicate: acknowledge, do nothing
155 self.seen.add(event["id"])
156 self.paid.append(event["order_id"])
157 return 200
158
159
160# ---------- Level 4: check-then-charge race ----------
161
162class BuggyKeyedPayments(Payments):
163 """Looks the key up, charges, and only then saves it."""
164
165 def start(self, key):
166 return key in self.keys # step 1: check
167
168 def finish(self, key, order_id, amount):
169 resp = self._do_charge(order_id, amount) # step 2: charge
170 self.keys[key] = {"status": "done", "response": resp} # step 3: save
171 return resp
172
173
174# ---------- checks ----------
175
176def run_checks():
177 # double charge without a key, single with one
178 api = Payments()
179 client_with_timeout(api, "881", 124_800, use_key=False)
180 assert len(api.charges) == 2 # charged twice
181 api = Payments()
182 _, first = api.charge_with_key("checkout-881", "881", 124_800)
183 code, again = api.charge_with_key("checkout-881", "881", 124_800)
184 assert len(api.charges) == 1 and again == first and code == 201
185 assert api.charge_with_key("checkout-881", "881", 99_900)[0] == 422 # different body
186 api.keys["k2"] = {"hash": hashlib.sha256(json.dumps(["882", 1]).encode()).hexdigest(),
187 "status": "in_progress", "at": 0}
188 assert api.charge_with_key("k2", "882", 1)[0] == 409 # concurrent
189 api.charge_with_key("checkout-881", "881", 124_800, now=90_000) # after 24 h: new
190 assert len(api.charges) == 2
191
192 # PUT is idempotent by design; POST isn't
193 shelf = {}
194 for _ in range(3):
195 shelf[42] = {"title": "Dune"} # PUT /books/42
196 assert len(shelf) == 1
197
198 # versioning: v1 clients break on v2's shape
199 assert v1_client_reads_price(render_v1(BOOK)) == 499.0
200 try:
201 v1_client_reads_price(render_v2(BOOK))
202 broke = False
203 except TypeError:
204 broke = True
205 assert broke
206
207 # pagination: a new order arrives between page 1 and page 2
208 rows = list(range(100, 0, -1)) # order ids, newest first
209 p1 = offset_page(rows, 0, 10)
210 rows_after = [101] + rows # new order 101 at the top
211 p2_offset = offset_page(rows_after, 10, 10)
212 assert p2_offset[0] == p1[-1] # order 91 shown twice
213 c1 = cursor_page(rows, None, 10)
214 c2 = cursor_page(rows_after, c1[-1], 10)
215 assert c2[0] == c1[-1] - 1 and not set(c1) & set(c2) # no repeats, no gaps
216
217 # errors
218 status, body = problem(409, "Order Already Paid", "Order 881 was paid at 10:02.")
219 assert status == 409 and body["status"] == 409
220 assert to_status("rate_limited") == 429 and to_status("validation") == 400
221
222 # API keys: stored hashed, compared in constant time
223 raw, stored = issue_key()
224 assert raw.startswith("sk_live_") and check_key(raw, stored) and not check_key(raw + "x", stored)
225
226 # safe retries
227 assert should_retry("GET", 503, False)
228 assert not should_retry("POST", 503, False) # might double-charge
229 assert should_retry("POST", 503, True) # keyed: safe
230 assert not should_retry("POST", 400, True) # your bug: retrying won't help
231 d = retry_delays(6)
232 assert all(0 <= x <= min(20, 0.5 * 2 ** n) for n, x in enumerate(d))
233
234 # webhooks: signed, fresh, de-duplicated
235 secret = b"whsec_test"
236 rx = WebhookReceiver(secret)
237 ev = json.dumps({"id": "evt_1", "type": "payment.succeeded", "order_id": "881"})
238 ts = 1_000_000
239 sig = sign(secret, ts, ev)
240 assert rx.handle(ev, ts, sig, now=ts + 5) == 200
241 assert rx.handle(ev, ts, sig, now=ts + 60) == 200 # retried delivery
242 assert rx.paid == ["881"] # applied once
243 forged = json.dumps({"id": "evt_2", "type": "payment.succeeded", "order_id": "999"})
244 assert rx.handle(forged, ts, sig, now=ts + 5) == 400 # wrong signature
245 assert rx.handle(ev, ts, sig, now=ts + 3_600) == 400 # too old: replay
246
247 # Level 4: two concurrent requests with the same key
248 bug = BuggyKeyedPayments()
249 a_seen = bug.start("checkout-883") # A checks: not there
250 b_seen = bug.start("checkout-883") # B checks: not there
251 assert not a_seen and not b_seen
252 bug.finish("checkout-883", "883", 50_000)
253 bug.finish("checkout-883", "883", 50_000)
254 assert len(bug.charges) == 2 # double charge despite the key
255 fixed = Payments()
256 fixed.keys["checkout-883"] = {"hash": hashlib.sha256(json.dumps(["883", 50_000]).encode()).hexdigest(),
257 "status": "in_progress", "at": 0} # A claimed it first
258 assert fixed.charge_with_key("checkout-883", "883", 50_000)[0] == 409 # B is told to wait
259 assert len(fixed.charges) == 0
260
261 return {"charges_without_key": 2, "offset_repeat": p1[-1], "retry_delays": [round(x, 2) for x in d]}
262
263
264if __name__ == "__main__":
265 print("All checks pass.", run_checks())