Lab 01 — API Gateway: Routing, Scope Authz & Rate Limiting
Phase: 09 — RESTful APIs, JSON & Secure AuthN/AuthZ | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Every request to a managed API hits a gateway before it ever reaches your code, and that gateway makes the same four decisions in the same order: does this route exist (
404), who is this caller and may they do this (401/403), are they over their rate budget (429), and only then call the backend (200). This lab builds that machine — the route matcher with path-template params and most-specific-wins, the scope authorizer whose whole subtlety is401-vs-403, the token-bucket rate limiter with a correctRetry-After, and the pipeline that wires them in the order that is the security policy. Strip away TLS, the JWKS fetch, and the YAML policy syntax, and what's left is route matching, set containment, a little arithmetic on a bucket of tokens, and a careful sequence ofifstatements — exactly the part interviewers probe and incidents turn on.
What you build
Route(method, path_template, required_scopes)— an API operation. The template uses{name}segments (/users/{id}) that match any single non-empty segment and bind the param; literal segments match only themselves. Validates the method and that the path starts with/, and normalises the method to upper-case.match_route(routes, method, path)— method + path dispatch with param extraction (/users/42→{"id": "42"}), returning the most-specific match when several templates fit (/users/meshadows/users/{id}), orNone(→404) when nothing does.authorize(token_claims, required_scopes)— returns an HTTP status:401(no / malformed / unverified token — who are you?),403(valid token, but its space-delimitedscpis missing a required scope — not allowed), or200(authenticated and every required scope present; a superset is fine). Fails closed.TokenBucket(capacity, refill_per_sec)— the continuous-refill rate limiter:allow(now)returns(allowed, retry_after_seconds); refill ismin(capacity, tokens + elapsed × refill), consume one if available, and on emptyretry_after = (1 − tokens) / refill. Deterministic — time is injected asnow, never read from a clock.RateLimiter— one bucket perclient_id, created lazily, so one noisy tenant drains its bucket and nobody else's (the bulkhead).handle(gateway, request, now)— the pipeline: match (404) → authorize (401/403) → rate-limit (429+Retry-After) → backend (200), returning aResponse(status, body, headers). The order is the contract.
Key concepts
| Concept | What to understand |
|---|---|
| Path-template matching | {id} matches one segment and binds it; literal matches only itself; arity must match |
| Most-specific-wins | among matches, the template with the most literal segments wins (/users/me ≻ /users/{id}) |
| Method dispatch | GET /orders and POST /orders are different operations; method is part of the key |
401 vs 403 | 401 = authentication failed (no/invalid token) → re-auth; 403 = authorization failed (valid token, wrong scope) → re-login won't help |
| Fail closed | absent/malformed token, or a required scope you can't prove, denies — never "allow on doubt" |
scp containment | required scopes must be a subset of the token's granted scopes; a superset of grants is fine |
| Token bucket | capacity = burst, refill_per_sec = sustained rate; tokens accrue continuously, one spent per request |
Retry-After | on 429, tell the client exactly how long until one token accrues so it backs off precisely |
| Pipeline order | 404 before 401 (no op to authorize); 401/403 before 429 (don't let probing cost rate budget) |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py walks a 404, 401, 403, 200, a 429 after the bucket drains, and a 200 after refill |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 29 tests pass against your implementation and against
solution.py. - You can explain why
test_pipeline_404_before_authreturns404and not401even though no token is present — routing runs before authorization, and there is no operation to authorize against. - You can explain why
test_pipeline_401_before_429returns401and not429— auth runs before rate limiting, so an unauthenticated probe never touches the bucket and an attacker can't drain another caller's budget. - You can state the
401-vs-403rule from memory:401is "I don't know who you are" (re-authenticate);403is "I know exactly who you are and you're still not allowed" (ask for more scopes — re-login won't fix it). - You can explain why
test_bucket_retry_after_partial_tokenexpects0.25— with0.5of a token accrued and a2/srefill, one full token is(1 − 0.5) / 2 = 0.25 saway. - You can explain why the bucket starts full and why it caps at
capacity(burst allowance; you can't bank unused rate forever).
How this maps to real Azure (API Management)
| The lab | Real Azure API Management |
|---|---|
Gateway | an APIM instance (the gateway) fronting one or more backends |
Route | an operation on an API (method + URL template), grouped into a product |
match_route | APIM's operation matching by method + URL template; {id} is a template parameter |
authorize (401/403) | the validate-jwt inbound policy (signature/iss/aud/exp) then a scp/roles claim check; 401 on no/invalid token, 403 on insufficient scope |
required_scopes | the required-claims block of validate-jwt, or a check-header/choose on the scp claim |
TokenBucket / RateLimiter | the rate-limit-by-key policy (calls per period, by subscription key / IP / a claim); quota-by-key is the longer-window cousin |
Retry-After header on 429 | APIM emits Retry-After on throttling so clients back off correctly |
| the pipeline order | APIM's policy pipeline: inbound (auth, rate-limit, transform) → backend → outbound → on-error |
Response headers | APIM set-header/set-status policies and the context.Response it returns |
What the miniature leaves out (and why it's fine): real APIM validates RS256 JWTs
against the issuer's JWKS (fetched and cached by kid) over TLS, runs the policy
pipeline in XML with <inbound>/<backend>/<outbound>/<on-error> sections, caches
responses, transforms payloads (JSON↔XML), does request/response mTLS to the backend,
and rate-limits across a distributed gateway fleet (so the bucket state is replicated /
approximate, not exact). None of that changes the decisions — route match, the
401-vs-403 split, the token-bucket arithmetic, the pipeline order — which is the part
interviewers probe and on-call debugs. We inject now and stub JWT validation to a valid
flag purely to stay offline and deterministic; swap the flag for the Phase 03 validate_jwt
and the rest is the real thing.
Extensions (build these in your own subscription)
- Real
validate-jwt: replace the"valid"flag with the Phase 03 validator (signature →iss→aud→exp/nbf→ claim), soauthorizeruns the full check and still returns401/403. Now the gateway is end-to-end correct. roles(app-only) authorization: add app-role authorization alongsidescp— a client-credentials token carriesroles, notscp; require eitherscp ⊇ delegatedorroles ⊇ app, so the same operation works for a user and a daemon.- Quota vs rate-limit: add a
Quota(limit, window_seconds)(a hard count per long window, e.g. 10k calls/day) on top of the per-second token bucket; show that429can come from either and that they answer different questions (burst vs monthly fairness). 429headers: emitX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit- ResetalongsideRetry-After, the GitHub/Stripe convention well-behaved clients read.- Pagination + content negotiation: add a
GET /usersthat returns a page with anextcursor, and honourAccept: application/json; return406on an unsupported media type and415on an unsupported requestContent-Type. PATCHvsPUTidempotency: add both and test that two identicalPUTs converge to one state (idempotent) while twoPOSTs create two resources (not) — the property that decides whether a client may safely retry on a timeout.- Wire it to a real instance:
az apim create, import an API (az apim api import), attach avalidate-jwt+rate-limit-by-keypolicy, and watch a missing scope return403and a flood return429withRetry-Afteron real traffic.
Interview / resume
- Talking points: "Walk me through what an API gateway does to a request, in order." /
"
401vs403— when does each fire and why does the distinction matter operationally?" / "Implement a token bucket and tell me whatRetry-Aftershould say." / "Why does routing run before auth, and auth before rate limiting?" / "Rate-limit vs quota — what's each for?" - Resume bullet: Built an API-gateway engine (HTTP method + path-template routing with
most-specific match and param extraction, OAuth2 scope authorization with the
401/403fail-closed distinction, and a continuous-refill token-bucket rate limiter emittingRetry-After) modeling the Azure API Management inbound policy pipeline.