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 is 401-vs-403, the token-bucket rate limiter with a correct Retry-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 of if statements — 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/me shadows /users/{id}), or None (→ 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-delimited scp is missing a required scope — not allowed), or 200 (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 is min(capacity, tokens + elapsed × refill), consume one if available, and on empty retry_after = (1 − tokens) / refill. Deterministic — time is injected as now, never read from a clock.
  • RateLimiter — one bucket per client_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 a Response(status, body, headers). The order is the contract.

Key concepts

ConceptWhat to understand
Path-template matching{id} matches one segment and binds it; literal matches only itself; arity must match
Most-specific-winsamong matches, the template with the most literal segments wins (/users/me/users/{id})
Method dispatchGET /orders and POST /orders are different operations; method is part of the key
401 vs 403401 = authentication failed (no/invalid token) → re-auth; 403 = authorization failed (valid token, wrong scope) → re-login won't help
Fail closedabsent/malformed token, or a required scope you can't prove, denies — never "allow on doubt"
scp containmentrequired scopes must be a subset of the token's granted scopes; a superset of grants is fine
Token bucketcapacity = burst, refill_per_sec = sustained rate; tokens accrue continuously, one spent per request
Retry-Afteron 429, tell the client exactly how long until one token accrues so it backs off precisely
Pipeline order404 before 401 (no op to authorize); 401/403 before 429 (don't let probing cost rate budget)

Files

FilePurpose
lab.pyskeleton with # TODO markers
solution.pycomplete reference; python solution.py walks a 404, 401, 403, 200, a 429 after the bucket drains, and a 200 after refill
test_lab.pythe proof — run it red, make it green
requirements.txtpytest 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_auth returns 404 and not 401 even 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_429 returns 401 and not 429 — 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-403 rule from memory: 401 is "I don't know who you are" (re-authenticate); 403 is "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_token expects 0.25 — with 0.5 of a token accrued and a 2/s refill, one full token is (1 − 0.5) / 2 = 0.25 s away.
  • 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 labReal Azure API Management
Gatewayan APIM instance (the gateway) fronting one or more backends
Routean operation on an API (method + URL template), grouped into a product
match_routeAPIM'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_scopesthe required-claims block of validate-jwt, or a check-header/choose on the scp claim
TokenBucket / RateLimiterthe 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 429APIM emits Retry-After on throttling so clients back off correctly
the pipeline orderAPIM's policy pipeline: inbound (auth, rate-limit, transform) → backendoutboundon-error
Response headersAPIM 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 → issaudexp/nbf → claim), so authorize runs the full check and still returns 401/403. Now the gateway is end-to-end correct.
  • roles (app-only) authorization: add app-role authorization alongside scp — a client-credentials token carries roles, not scp; require either scp ⊇ delegated or roles ⊇ 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 that 429 can come from either and that they answer different questions (burst vs monthly fairness).
  • 429 headers: emit X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit- Reset alongside Retry-After, the GitHub/Stripe convention well-behaved clients read.
  • Pagination + content negotiation: add a GET /users that returns a page with a next cursor, and honour Accept: application/json; return 406 on an unsupported media type and 415 on an unsupported request Content-Type.
  • PATCH vs PUT idempotency: add both and test that two identical PUTs converge to one state (idempotent) while two POSTs 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 a validate-jwt + rate-limit-by-key policy, and watch a missing scope return 403 and a flood return 429 with Retry-After on real traffic.

Interview / resume

  • Talking points: "Walk me through what an API gateway does to a request, in order." / "401 vs 403 — when does each fire and why does the distinction matter operationally?" / "Implement a token bucket and tell me what Retry-After should 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/403 fail-closed distinction, and a continuous-refill token-bucket rate limiter emitting Retry-After) modeling the Azure API Management inbound policy pipeline.