aws2-8 Exercise 3: Models of Routing, Throttling, Authorizer Caching and Cost ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with SAM CLI 1.168.0 (AWS SAM Translator 1.114.0, cfn-lint 1.53.3, boto3 1.43.108); a second venv with cfn-lint 1.57.2 plus aws-sam-translator 1.114.0; Docker Desktop 29.8.2 with AWS's public Lambda Python 3.13 images (pulled by sam local). Scratch folder C:/lwaws/c8, nothing installed globally. VERIFICATION LEVEL: 1 (no AWS account, no credentials, nothing deployed); statements about the real service come from the AWS documentation read on 2026-10-13 and are marked as such in the chapter. Outputs below are saved from the real runs of 2026-10-13. --- route_model.py --- """Model of HTTP API route selection as documented (http-api-develop-routes, read 2026-10-13). Priority: 1 full match for route and method; 2 match with a greedy path variable ({proxy+}); 3 $default. ANY matches methods not defined for that resource. No match -> 404 {"message":"Not Found"}. My additions (NOT stated on the page, so they are assumptions to test against the real service): a static segment beats a {variable} segment, and among greedy routes the longer prefix wins. """ def split(p): return [s for s in p.strip("/").split("/") if s] def match_path(route_path, req_path): """Return (kind, specificity) or None. kind: 'full' or 'greedy'.""" r, q = split(route_path), split(req_path) if r and r[-1].endswith("+}"): pre = r[:-1] if len(q) >= len(pre) + 1 and all(a == b or (a.startswith("{") and not a.endswith("+}")) for a, b in zip(pre, q)): return ("greedy", sum(1 for a in pre if not a.startswith("{")), len(pre)) return None if len(r) == len(q) and all(a == b or a.startswith("{") for a, b in zip(r, q)): return ("full", sum(1 for a in r if not a.startswith("{")), len(r)) return None def select(routes, method, path): """routes: list of 'METHOD /path' keys or '$default'.""" best = None for key in routes: if key == "$default": continue m, p = key.split(" ", 1) if m not in (method, "ANY"): continue r = match_path(p, path) if not r: continue kind, statics, depth = r # order: full before greedy; exact method before ANY; more static segments; longer rank = (kind == "full", m == method, statics, depth) if best is None or rank > best[0]: best = (rank, key) if best: return best[1] return "$default" if "$default" in routes else None ROUTES = ["GET /pets/dog/1", "GET /pets/dog/{id}", "GET /pets/{proxy+}", "ANY /{proxy+}", "$default"] DOCS_TABLE = [ # the four rows of the page's table ("GET", "/pets/dog/1", "GET /pets/dog/1"), ("GET", "/pets/dog/2", "GET /pets/dog/{id}"), ("GET", "/pets/cat/1", "GET /pets/{proxy+}"), ("POST", "/test/5", "ANY /{proxy+}"), ] if __name__ == "__main__": bad = 0 for m, p, want in DOCS_TABLE: got = select(ROUTES, m, p) ok = got == want; bad += not ok print(f"{'ok ' if ok else 'FAIL'} {m:5s} {p:14s} -> {got} (docs: {want})") print("extra cases (assumptions, not in the docs table):") for r, m, p in [(["$default"], "GET", "/anything"), (["GET /a"], "GET", "/b"), (["GET /a"], "POST", "/a"), (["ANY /a", "GET /a"], "GET", "/a"), (["GET /{proxy+}", "GET /pets/{proxy+}"], "GET", "/pets/x/y")]: print(f" routes={r} {m} {p} -> {select(r, m, p)}") print("docs-table rows disagreeing with the model:", bad) --- py route_model.py (saved output of the real run) --- ok GET /pets/dog/1 -> GET /pets/dog/1 (docs: GET /pets/dog/1) ok GET /pets/dog/2 -> GET /pets/dog/{id} (docs: GET /pets/dog/{id}) ok GET /pets/cat/1 -> GET /pets/{proxy+} (docs: GET /pets/{proxy+}) ok POST /test/5 -> ANY /{proxy+} (docs: ANY /{proxy+}) extra cases (assumptions, not in the docs table): routes=['$default'] GET /anything -> $default routes=['GET /a'] GET /b -> None routes=['GET /a'] POST /a -> None routes=['ANY /a', 'GET /a'] GET /a -> GET /a routes=['GET /{proxy+}', 'GET /pets/{proxy+}'] GET /pets/x/y -> GET /pets/{proxy+} docs-table rows disagreeing with the model: 0 --- bucket_model.py --- """Token-bucket model of API Gateway throttling as documented (api-gateway-request-throttling, read 2026-10-13). 'rate' = tokens added per second, 'burst' = bucket capacity, one token per request, 429 when empty. The page says limits are best-effort 'targets rather than guaranteed request ceilings', so this model shows the documented ALGORITHM, not what the service does at the margins.""" class Bucket: def __init__(self, rate, burst): self.rate, self.burst, self.tokens, self.t = rate, burst, float(burst), 0.0 def allow(self, now): self.tokens = min(self.burst, self.tokens + (now - self.t) * self.rate); self.t = now if self.tokens >= 1: self.tokens -= 1; return 200 return 429 def run(rate, burst, n, spacing): b = Bucket(rate, burst) return [b.allow(i * spacing) for i in range(n)] if __name__ == "__main__": print("rate 2/s, burst 5, 30 requests all at once (spacing 0.0 s):") r = run(2, 5, 30, 0.0); print(" 200s:", r.count(200), " 429s:", r.count(429)) print("rate 2/s, burst 5, 30 requests at 10 per second (spacing 0.1 s):") r = run(2, 5, 30, 0.1); print(" 200s:", r.count(200), " 429s:", r.count(429), " first 429 at request #", r.index(429) + 1) print("rate 2/s, burst 5, 30 requests at 2 per second (spacing 0.5 s):") r = run(2, 5, 30, 0.5); print(" 200s:", r.count(200), " 429s:", r.count(429)) print("steady state: sustained ceiling is the rate; burst is how far a short spike may overrun it") print("account default (docs): 10,000 rps, bucket 5,000; at spacing 0 the first 5,000 of 12,000 simultaneous requests pass:") r = run(10000, 5000, 12000, 0.0); print(" 200s:", r.count(200), " 429s:", r.count(429)) --- py bucket_model.py (saved output of the real run) --- rate 2/s, burst 5, 30 requests all at once (spacing 0.0 s): 200s: 5 429s: 25 rate 2/s, burst 5, 30 requests at 10 per second (spacing 0.1 s): 200s: 10 429s: 20 first 429 at request # 7 rate 2/s, burst 5, 30 requests at 2 per second (spacing 0.5 s): 200s: 30 429s: 0 steady state: sustained ceiling is the rate; burst is how far a short spike may overrun it account default (docs): 10,000 rps, bucket 5,000; at spacing 0 the first 5,000 of 12,000 simultaneous requests pass: 200s: 5000 429s: 7000 --- authz_cache.py --- """Why a cached Lambda-authorizer policy must cover every route. Docs (apigateway-use-lambda-authorizer, read 2026-10-13): with caching on, API Gateway caches the policy 'so that the Lambda authorizer function isn't invoked again. Ensure that your policy is applicable to all resources and methods across your API.' With caching, a TOKEN authorizer's cache key is the token header. This model applies the cached policy to a later request for a different method ARN (IAM-style * and ? wildcards).""" import fnmatch API = "arn:aws:execute-api:eu-west-1:111122223333:abc123def4" def arn(method, path, stage="dev"): return f"{API}/{stage}/{method}{path}" def policy(resource): return [{"Effect": "Allow", "Action": "execute-api:Invoke", "Resource": resource}] def allowed(stmts, method_arn): ok = False for s in stmts: if fnmatch.fnmatchcase(method_arn, s["Resource"]): if s["Effect"] == "Deny": return False ok = True return ok def authorizer(token, method_arn, wide): """My stand-in authorizer: token 'good' is allowed. 'wide' policies cover the stage; narrow ones only this call.""" if token != "good": return [] return policy(f"{API}/dev/*" if wide else method_arn) def serve(requests, ttl, wide): cache, calls, out = {}, 0, [] for token, method, path in requests: m = arn(method, path) if ttl and token in cache: pol = cache[token] else: pol = authorizer(token, m, wide); calls += 1 if ttl: cache[token] = pol out.append((method, path, 200 if allowed(pol, m) else 403)) return out, calls REQ = [("good", "GET", "/hello"), ("good", "GET", "/items/42"), ("good", "POST", "/items")] if __name__ == "__main__": for ttl, wide, label in [(0, False, "no cache, policy names only the requested ARN"), (300, False, "cache 300 s, policy names only the requested ARN"), (300, True, "cache 300 s, policy covers the whole stage (./dev/*)")]: out, calls = serve(REQ, ttl, wide) print(f"{label}\n authorizer invoked {calls}x; results: " + ", ".join(f"{m} {p} -> {c}" for m, p, c in out)) --- py authz_cache.py (saved output of the real run) --- no cache, policy names only the requested ARN authorizer invoked 3x; results: GET /hello -> 200, GET /items/42 -> 200, POST /items -> 200 cache 300 s, policy names only the requested ARN authorizer invoked 1x; results: GET /hello -> 200, GET /items/42 -> 403, POST /items -> 403 cache 300 s, policy covers the whole stage (./dev/*) authorizer invoked 1x; results: GET /hello -> 200, GET /items/42 -> 200, POST /items -> 200 --- cost_model.py --- """Request-charge model from aws.amazon.com/api-gateway/pricing (read 2026-10-13 via the fetch tool). The page has no standalone price table: rates come from its worked examples and the tier boundaries were inferred by the fetch tool. REST: Regions named were N. Virginia, Ohio, Oregon, Mumbai. HTTP: Region not stated. Request charges only: data transfer, caching, Lambda, logs, WAF and the free tier are NOT included.""" def tiered(n, tiers): cost, left = 0.0, n for size, rate in tiers: take = min(left, size); cost += take / 1e6 * rate; left -= take if left <= 0: break return cost REST = [(333e6, 3.50), (667e6, 2.80), (float("inf"), 2.38)] HTTP = [(300e6, 1.00), (float("inf"), 0.90)] if __name__ == "__main__": print(f"{'requests/month':>16} {'REST $':>12} {'HTTP $':>12} {'REST/HTTP':>10}") for n in (1e6, 10e6, 100e6, 400e6, 1.2e9): r, h = tiered(n, REST), tiered(n, HTTP) print(f"{n:16,.0f} {r:12,.2f} {h:12,.2f} {r/h:10.2f}") --- py cost_model.py (saved output of the real run) --- requests/month REST $ HTTP $ REST/HTTP 1,000,000 3.50 1.00 3.50 10,000,000 35.00 10.00 3.50 100,000,000 350.00 100.00 3.50 400,000,000 1,353.10 390.00 3.47 1,200,000,000 3,509.10 1,110.00 3.16 --- choosing REST or HTTP for three invented scenarios (a judgement, using the docs' feature table of 2026-10-13) --- 1. A mobile app's public read-only catalogue API, 20 million requests a month, authenticated with tokens from a Cognito user pool, CORS for one web origin. -> HTTP API. Needs: JWT authorizer (HTTP: yes), CORS (both), routing (both). Nothing from the REST-only rows (API keys, validation, WAF, caching). Request charge at the modelled rates: $20 for HTTP against $70 for REST. 2. A partner API where each partner gets an API key and a monthly quota, request bodies are checked against a schema before your code runs, and AWS WAF sits in front. -> REST API. Each of those three needs is a REST-only row (API keys with usage plans, request validation, WAF). 3. An internal API reachable only from inside a VPC. -> REST API (the docs' table lists the Private endpoint type as REST only; HTTP APIs are Regional only). -------------------------- Mistake made and fixed: my first bucket_model.py kept a function (chain) that was never called and had a half-written probe attribute; I deleted it and re-ran to confirm the output was unchanged (it was). How to avoid it: delete scaffolding you do not use before you publish code as the solution. All four models encode documented rules or published prices; none was compared with the real service. The route-selection model matched all four rows of the docs' table; the extra cases are my assumptions and are labelled as such in the output.