R12 · Week 12 Recall & Drill
Week 12 revision: resource modelling and idempotency keys, GraphQL's N+1 and cost limits, Protobuf field tags and RPC shapes, delegated authorisation versus identity, and Linux processes, memory, and file descriptors.
🎯 Rebuild Week 12 from a blank page: resource modelling beats URL aesthetics, GraphQL moves cost rather than removing it, Protobuf compatibility lives in the field tag, delegated access is not identity, and threads differ from processes in exactly what they share.
Weekly revision · Week 12 · Covers 5 sessions from Mon–Fri.
Sessions covered
- S056 — REST API Design — Resources, Versioning, Idempotency
- S057 — GraphQL — Schema, Resolvers, N+1, When to Pick It
- S058 — gRPC & Protobuf — When RPC Wins
- S059 — AuthN & AuthZ — OAuth 2.0, OIDC, JWT
- S060 — OS Basics — Processes, Threads, Memory, FDs
- Model a domain as resources rather than actions, and design cursor pagination that survives inserts and deletes.
- Add idempotency keys to a creating endpoint so a client can retry a timed-out request safely.
- Reproduce the N+1 resolver problem and explain the batching fix, plus the production controls a public GraphQL endpoint requires.
- State the Protobuf compatibility rules and pick the right RPC shape among unary, server-stream, client-stream, and bidirectional.
- Explain why delegated authorisation does not establish identity, and decode a token's claims by hand.
- Distinguish resident from virtual memory, and diagnose a file-descriptor exhaustion end to end.
90-min structure
| Block | Minutes | What you do |
|---|---|---|
| Warm-up recall | 5 | Five sessions, one sentence each. |
| Blank-page reconstruction | 30 | The per-session prompts below. |
| Hands-on drill | 30 | Idempotency, N+1, token claims, and descriptor exhaustion. |
| Quiz + misconception | 15 | Answer before revealing. |
| Gap analysis + preview | 10 | Write the gaps. Skim next week. |
Blank-page reconstruction · 30 min
S056 · REST API Design
- Contrast offset and cursor pagination in one sentence each, and say when you would choose which.
- Explain what an idempotency key does and how long the server must remember it.
- Name the versioning strategies and give a defensible argument for one.
Gotcha you probably forgot: offset pagination is not merely slow at depth, it is incorrect under concurrent writes. If a row is inserted before your current offset between page requests, one row shifts across the boundary and you see it twice; if a row is deleted, you skip one entirely. Cursor pagination anchors on a stable sort key, so concurrent modification cannot silently duplicate or drop items.
S057 · GraphQL
- Explain what a resolver is and where the engine calls it.
- Describe the N+1 problem and the batching fix, in one sentence each.
- Name three cases where the simpler resource-oriented approach wins.
Gotcha you probably forgot: HTTP caching largely stops working, because a single endpoint receiving posted queries means every request has the same URL and method. The infrastructure-level caching you got for free must be rebuilt inside the application, or reintroduced via persisted queries that give each operation a stable identifier the cache layer can key on.
S058 · gRPC & Protobuf
- Say why the field tag number matters more than the field name.
- List the four RPC shapes with a real use case for each.
- Write the rules for adding a field to a deployed message without breaking existing clients.
Gotcha you probably forgot: reusing a tag number after deleting a field is a silent data-corruption bug. Old clients still on the wire send the old meaning under that number, and the new code parses it as the new field — same type, wrong semantics, no error anywhere. Reserve retired tag numbers explicitly so the compiler refuses to reuse them.
S059 · Authentication & Authorisation
- Define authentication and authorisation in one sentence each.
- Walk through the authorization-code flow with the proof-key extension in five steps.
- Contrast server-side sessions with self-contained tokens on revocation and on multi-node scaling.
Gotcha you probably forgot: the algorithm-confusion attack. If your verifier trusts the algorithm field inside the token itself, an attacker can change it — most notoriously to "none", or from an asymmetric algorithm to a symmetric one using the public key as the shared secret — and forge tokens. The fix is that the verifier must pin the expected algorithm from configuration and never read it from the token.
S060 · OS Basics
- State the one decisive difference between a process and a thread, and derive two consequences from it.
- Define resident and virtual memory, and say which one you alert on.
- Write the ordered commands you run to diagnose file-descriptor exhaustion.
Gotcha you probably forgot: virtual size is not memory consumption. It counts everything mapped into the address space, including memory-mapped files, shared libraries, and reserved-but-untouched regions, so a process can show an enormous virtual size while using very little physical memory. Alerting on it produces constant false alarms; resident size is the number that reflects actual pressure.
Hands-on drill · 30 min
Task: implement the four mechanisms rather than describe them — idempotent creation, batched loading, token verification, and descriptor limits.
mkdir -p ~/projects/w12-drill && cd ~/projects/w12-drillStep 1 — idempotency keys (8 min)
# idempotent.py
import hashlib
import json
import random
store: dict[str, dict] = {} # key -> {"body_hash":..., "response":...}
orders: list[dict] = []
def create_order(idem_key: str | None, body: dict, flaky: bool = False):
"""Returns (status, response). Mirrors the real contract, including the conflict case."""
if idem_key is None:
orders.append(body)
return 201, {"id": len(orders), **body}
bh = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
prior = store.get(idem_key)
if prior:
if prior["body_hash"] != bh:
return 409, {"error": "idempotency key reused with a different body"}
return 200, prior["response"] # replay: no second charge
orders.append(body)
resp = {"id": len(orders), **body}
store[idem_key] = {"body_hash": bh, "response": resp}
if flaky:
raise TimeoutError("response lost after the write committed")
return 201, resp
body = {"amount": 4200, "currency": "EUR"}
key = "req-" + str(random.Random(0).randint(10**9, 10**10))
try:
create_order(key, body, flaky=True) # network died AFTER the server committed
except TimeoutError as e:
print("client saw:", e)
print("client retries with the same key ->", create_order(key, body))
print("client retries with a different body ->", create_order(key, {"amount": 9999, "currency": "EUR"}))
print("orders created:", len(orders))
# Contrast: no key at all.
for _ in range(3):
create_order(None, body)
print("orders after 3 keyless retries:", len(orders))Expected outcome: exactly one order exists after the timeout plus retry, and the retry returns the original response rather than creating a second. The mismatched body returns a conflict, which is the case people omit and which is what protects against a client accidentally reusing a key. Without a key, three retries create three orders — that is the double-charge bug, reproduced in ten lines. Note the failure happened after the commit: that is the case that makes retries dangerous, and no amount of server-side care can fix it without a client-supplied key.
Step 2 — N+1 and batching (7 min)
# nplusone.py
users = {i: {"id": i, "name": f"user{i}"} for i in range(1, 6)}
posts = [{"id": p, "author_id": (p % 5) + 1, "title": f"post{p}"} for p in range(1, 21)]
calls = {"n": 0}
def fetch_user(uid): # naive resolver: one query per parent row
calls["n"] += 1
return users[uid]
def fetch_users(uids): # batched: one query for the whole level
calls["n"] += 1
return {u: users[u] for u in set(uids)}
calls["n"] = 0
naive = [{"title": p["title"], "author": fetch_user(p["author_id"])["name"]} for p in posts]
print(f"naive resolver: {calls['n']} backend calls for {len(posts)} posts")
calls["n"] = 0
loaded = fetch_users([p["author_id"] for p in posts])
batched = [{"title": p["title"], "author": loaded[p["author_id"]]["name"]} for p in posts]
print(f"batched loader: {calls['n']} backend calls for {len(posts)} posts")
print("same result:", naive == batched)Expected outcome: the naive version makes one call per post while the batched version makes a single call, and both produce identical output. The count scales with the number of parent rows, which is why this is invisible in a test with three records and fatal at production breadth. Note that the batching happens per level of the query tree, which is exactly the shape a loader utility automates.
Step 3 — decode and verify a token by hand (7 min)
# token.py
import base64
import hmac
import hashlib
import json
import time
def b64u(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
def b64u_dec(s: str) -> bytes:
return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
SECRET = b"do-not-use-this-in-production"
def sign(header: dict, payload: dict) -> str:
h, p = b64u(json.dumps(header).encode()), b64u(json.dumps(payload).encode())
sig = hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest()
return f"{h}.{p}.{b64u(sig)}"
now = int(time.time())
payload = {"iss": "https://auth.example", "sub": "user-42", "aud": "api.example",
"exp": now + 300, "iat": now, "scope": "orders:read"}
token = sign({"alg": "HS256", "typ": "JWT"}, payload)
# Anyone can read the payload. It is encoded, not encrypted.
print("payload without any secret:", json.loads(b64u_dec(token.split(".")[1])))
def verify(tok: str, expected_alg="HS256", audience="api.example"):
h, p, s = tok.split(".")
header = json.loads(b64u_dec(h))
if header.get("alg") != expected_alg: # PINNED, not read from the token
return False, f"algorithm mismatch: {header.get('alg')}"
expect = b64u(hmac.new(SECRET, f"{h}.{p}".encode(), hashlib.sha256).digest())
if not hmac.compare_digest(expect, s):
return False, "bad signature"
claims = json.loads(b64u_dec(p))
if claims.get("exp", 0) < time.time():
return False, "expired"
if claims.get("aud") != audience:
return False, "wrong audience"
return True, claims["sub"]
print("genuine token ->", verify(token))
# The classic attack: strip the signature and claim no algorithm was used.
h2 = b64u(json.dumps({"alg": "none", "typ": "JWT"}).encode())
p2 = b64u(json.dumps({**payload, "sub": "user-1", "scope": "orders:write"}).encode())
print("forged 'alg: none' ->", verify(f"{h2}.{p2}."))Expected outcome: the payload prints without any secret, which is the point to internalise — never put anything confidential in a token body. The genuine token verifies and returns its subject. The forged token is rejected specifically because the expected algorithm is pinned in the verifier; delete that check and the forgery is accepted with escalated scope. Try it both ways so you have watched the attack succeed once.
Step 4 — exhaust the descriptor table (8 min)
ulimit -n # soft limit for this shell# fds.py
import resource
import tempfile
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
print(f"soft limit={soft} hard limit={hard}")
resource.setrlimit(resource.RLIMIT_NOFILE, (64, hard)) # lower it so this is quick and safe
open_files = []
try:
while True:
open_files.append(tempfile.TemporaryFile())
except OSError as e:
print(f"failed after {len(open_files)} files: {e.errno} {e.strerror}")
finally:
for f in open_files:
f.close()
resource.setrlimit(resource.RLIMIT_NOFILE, (soft, hard))
print("limit restored")Expected outcome: the failure arrives a little below the limit you set, because the interpreter already holds a handful of descriptors including the standard streams. The error name is the one you will see in production logs. Note that the leak in a real service is almost never files — it is unclosed sockets from a client that does not reuse connections, which is why the diagnostic path is to list the open handles for the process and group them by type before assuming anything.
"OAuth is an authentication protocol — if I integrate 'log in with provider X' using OAuth, I have authenticated the user."
It is an authorisation-delegation protocol. It answers "may this application access that resource on the user's behalf", and an access token is proof of a granted permission, not proof of identity. Treating it as login leads to a specific, exploitable mistake: accepting an access token issued for a different application as evidence of who the bearer is, when that token may have been obtained by any site the user visited. The identity layer built on top exists exactly to close this — it adds a signed identity token with a defined audience, so you can verify that this token was issued for your application and about this user. If you need to know who the user is, verify an identity token with its audience checked; if you only need to call an API on their behalf, an access token is the right thing and the identity question does not arise.
Gap analysis + next week preview · 10 min
- In Step 1, did you include the conflicting-body case before reading it? That branch is what separates a real idempotency contract from a cache.
- Did the forged token verify when you removed the algorithm pin? Watch it succeed once; that memory is worth more than the rule.
- Could you write the descriptor diagnosis order from memory, without the drill in front of you?
Next week (S061–S065) goes deeper into systems: Linux networking with sockets, TCP, and DNS resolution; filesystems and input-output behaviour; observability through logs, metrics, and traces; performance analysis methodology; and reliability practices. The process, memory, and descriptor model from S060 is the foundation each of those debugging workflows assumes you already hold.
Part of the 6-month evergreen learning plan.