Solutions — Lesson 14
Reference answers for Lesson 14's three homework tasks · per-tenant daily quota, session timeout, per-IP rate limit at Caddy
Each solution below shows the worked answer for one homework task followed by a "common mistake" callout that captures the failure modes we saw in earlier drafts. Your exact log lines, timer ids, and Caddy version strings will obviously differ; what matters is that the shape of the change matches the reference and the self-check evidence from the homework reproduces.
Solution to Task 1 — Per-tenant daily quota
The daily counter is a second bucket layered on top of the token-bucket rate limit. The token bucket caps burst (10 calls now) and short-term sustained rate (60 / minute); the daily quota caps long-term volume (1000 / day) so a tenant that paces itself exactly at the rate-limit ceiling still cannot run for 24 hours uncapped. We anchor the reset on UTC midnight, not on the first-call timestamp, so all tenants reset at the same calendar boundary — this makes capacity planning and billing reconciliation straightforward.
// src/rateLimit.ts — append at the bottom:
interface DailyBucket {
count: number;
resetAt: number; // epoch ms of next UTC midnight
}
const DAILY = new Map<string, DailyBucket>();
const DAILY_QUOTA = Number(process.env.PKB_DAILY_QUOTA ?? 1000);
function nextMidnightUTC(now: number): number {
const d = new Date(now);
d.setUTCHours(24, 0, 0, 0); // rolls to next day at 00:00 UTC
return d.getTime();
}
export function dailyCheck(tenant: string): {
allowed: boolean;
remaining: number;
resetAt: string;
} {
const now = Date.now();
let b = DAILY.get(tenant);
if (!b || now >= b.resetAt) {
b = { count: 0, resetAt: nextMidnightUTC(now) };
DAILY.set(tenant, b);
}
if (b.count >= DAILY_QUOTA) {
return {
allowed: false,
remaining: 0,
resetAt: new Date(b.resetAt).toISOString(),
};
}
b.count += 1;
return {
allowed: true,
remaining: DAILY_QUOTA - b.count,
resetAt: new Date(b.resetAt).toISOString(),
};
}
Wired into CallTool in src/server.ts after the per-second rate-limit check:
import { check as rlCheck, dailyCheck } from "./rateLimit.js";
server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
const tenant = (extra as any)?.tenant ?? "anonymous";
// 1. Per-second token-bucket (existing).
const rl = rlCheck(tenant);
if (!rl.allowed) {
return {
isError: true,
content: [{ type: "text", text: `rate limit exceeded; retry after ${rl.retryAfterMs} ms` }],
};
}
// 2. Per-day quota (new).
const dq = dailyCheck(tenant);
if (!dq.allowed) {
return {
isError: true,
content: [{ type: "text", text: `daily quota exceeded; resets at ${dq.resetAt}` }],
};
}
// ... dispatch as before ...
});
Self-check evidence on a successful run:
$ PKB_TRANSPORT=http \
PKB_TOKENS="alice:tok_alice" \
PKB_DAILY_QUOTA=5 \
node dist/server.js &
# fire 6 calls from alice; the 6th should fail with quota exceeded:
$ for i in 1 2 3 4 5 6; do
curl -s -H "Authorization: Bearer tok_alice" \
-X POST localhost:8080/messages?sessionId=$SID \
-d '{"jsonrpc":"2.0","id":'$i',"method":"tools/call","params":{"name":"pkb.search","arguments":{"q":"x"}}}'
echo
done
... (calls 1-5: normal result content) ...
{"isError":true,"content":[{"type":"text","text":"daily quota exceeded; resets at 2026-05-27T00:00:00.000Z"}]}
Reading this solution:
- Reset is anchored on UTC midnight, not on first call.
nextMidnightUTCusessetUTCHours(24, 0, 0, 0)which rolls the JS Date forward to the next day at 00:00 UTC. A tenant whose first call lands at 23:55 UTC gets a quota window that closes 5 minutes later, then opens a full new window at 00:00 — not a 24-hour "from first call" window that drifts. - Order matters: per-second rate limit fires before the daily quota. If you flip the order, a burst attacker eats your daily quota in one second before the rate limiter can intervene. Cheap, fast-rejecting checks come first; slower-burning checks come second.
- The quota counter is in memory. A process restart
resets every tenant's counter to zero. For production, persist the
{tenant, day_utc, count}tuple to SQLite/Redis so an accidental redeploy doesn't gift every tenant a free 1000 calls.
Common mistake
Resetting on first call (e.g., resetAt: now + 24 * 60 * 60_000)
instead of UTC midnight. A tenant who first calls at 23:55 then again at
00:01 sees their quota window appear to "roll over" five minutes later, then
again at midnight, then again 24 hours after first call — three different
boundary semantics depending on which call you trace. Anchoring on calendar
midnight gives one global reset everyone can reason about. The second common
variant: forgetting now >= b.resetAt in the lazy-rotate
branch, so the counter never actually resets and every tenant ends up
permanently blocked one day later. Test the rotation by setting
resetAt to Date.now() + 5_000 in dev, exhausting
the quota, waiting 6 seconds, and confirming the next call succeeds.
Solution to Task 2 — Session timeout
Each SSE session gets a setTimeout that prunes the entry from
the sessions map after PKB_SESSION_TTL_MS (default
30 minutes). The session value type grows a timer field so we
can clearTimeout on the close path — without this, every
closed connection leaks a timer that fires later and calls
res.end() on an already-finished response.
// src/server.ts
interface Session {
transport: SSEServerTransport;
tenant: string;
timer: NodeJS.Timeout;
}
const sessions = new Map<string, Session>();
const SESSION_TTL_MS = Number(process.env.PKB_SESSION_TTL_MS ?? 30 * 60_000);
app.get("/sse", bearerAuth, async (req, res) => {
const tenant = (req as any).tenant as string;
const transport = new SSEServerTransport("/messages", res);
// Prune this session after TTL. Keep the timer ref so we can clear it on close.
const timer = setTimeout(() => {
sessions.delete(transport.sessionId);
try { res.end(); } catch { /* already closed */ }
logger.info({
phase: "session_expired",
tenant,
session_id: transport.sessionId,
});
}, SESSION_TTL_MS);
sessions.set(transport.sessionId, { transport, tenant, timer });
res.on("close", () => {
clearTimeout(timer); // <-- the line everyone forgets
sessions.delete(transport.sessionId);
});
await server.connect(transport);
});
The POST /messages handler doesn't change — it still looks
the session up by sessionId, and a pruned session naturally
returns 404 unknown session:
app.post("/messages", express.json(), async (req, res) => {
const sid = String(req.query.sessionId ?? "");
const sess = sessions.get(sid);
if (!sess) {
res.status(404).send("unknown session");
return;
}
(req as any).tenant = sess.tenant;
await sess.transport.handlePostMessage(req, res, req.body);
});
Self-check evidence on a successful run:
$ PKB_TRANSPORT=http \
PKB_TOKENS="alice:tok_alice" \
PKB_SESSION_TTL_MS=10000 \
node dist/server.js &
# Open SSE stream and capture the session id from the first event:
$ curl -N -H "Authorization: Bearer tok_alice" localhost:8080/sse &
event: endpoint
data: /messages?sessionId=01HXYZ... (this is the session id)
# Wait 11 seconds (TTL is 10s):
$ sleep 11
# Try to use the now-expired session:
$ curl -s -o /dev/null -w "%{http_code}\n" \
-X POST "localhost:8080/messages?sessionId=01HXYZ..." \
-H "Authorization: Bearer tok_alice" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"pkb.search","arguments":{"q":"x"}}}'
404
# Server log shows the prune fired:
{"level":"info","phase":"session_expired","tenant":"alice","session_id":"01HXYZ..."}
Reading this solution:
clearTimeout(timer)onres.on("close")is non-negotiable. Without it, every closed connection leaks a pending timer. After 10 000 short-lived connections you have 10 000 callbacks queued to fire later, each one attemptingres.end()on a long-dead response and spamming "write after end" errors. The Node process eventually OOMs not from data but from timer queue depth. Thetry/catcharoundres.end()in the timeout callback is a second-layer defence for the race where the timer fires the same tick as a natural close.- The session value type carries the timer ref. Adding
timer: NodeJS.Timeoutto theSessioninterface is what lets the close handler reach the right timer to cancel. If you keep the timer in a closure variable and forget to thread it through, a long-lived session map plus short sessions become a memory leak. - The middleware order is unchanged.
bearerAuthstill runs before the SSE upgrade so unauthorised callers never get a session id at all. The TTL is a defence against authorised sessions that go silent, not a substitute for auth.
Common mistake
Forgetting to clearTimeout on the close path. The session is
deleted from the map on close, so functionally nothing breaks for
a while — the pruner finds no entry and the sessions.delete
call is a no-op. But the timer callback still runs later and calls
res.end() on a closed stream, emitting ERR_STREAM_WRITE_AFTER_END
once per orphaned session. In production this is the kind of bug that
looks fine in dev (one connection, one timer) and reports thousands of
"write after end" lines per minute under real traffic. The second variant:
declaring the timer field on Session but
leaving the res.on("close") handler from L14 step 5
unchanged, so the timer is set but never cleared — same bug, different
line. Always do a grep -n "setTimeout" src/server.ts and
confirm every setTimeout has a matching clearTimeout
on every exit path (close, error, expire).
Solution to Task 3 — Per-IP rate limit at Caddy
The per-IP rate limit is a second layer above the per-tenant in-process rate limit. The in-process limit defends one tenant from itself (10 burst, 60 / minute, 1000 / day); the Caddy per-IP limit defends the process from an anonymous flood (no token, no tenant) that would otherwise saturate the auth middleware before it can issue 401s. Limits stack: each request must pass IP then token then tenant then daily.
Updated Caddyfile:
{
# Caddy needs to know where `rate_limit` slots into the directive order.
order rate_limit before reverse_proxy
}
pkb.example.com {
rate_limit {
zone per_ip {
key {remote_host}
events 30
window 1m
}
# Only rate-limit the auth-establishing route. The /messages POSTs
# are already gated by sessionId + bearer at the app layer.
match {
path /sse*
}
}
encode gzip
reverse_proxy pkb:8080
log {
output stdout
format json
}
}
The rate_limit directive ships as a separate Caddy module
(caddy-ratelimit) that isn't in the stock caddy:2-alpine
image. The simplest production fix is to bake a custom Caddy image:
# Caddyfile and image build, all-in-one.
FROM caddy:2-builder-alpine AS builder
RUN xcaddy build \
--with github.com/mholt/caddy-ratelimit
FROM caddy:2-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY Caddyfile /etc/caddy/Caddyfile
Point the compose service at the local build:
services:
caddy:
build:
context: .
dockerfile: Caddyfile.Dockerfile
depends_on:
pkb:
condition: service_healthy
ports:
- "443:443"
- "80:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
restart: unless-stopped
Self-check evidence on a successful run:
$ docker compose build caddy
$ docker compose up -d
$ for i in $(seq 1 50); do
curl -s -o /dev/null -w "%{http_code}\n" \
-H "Authorization: Bearer tok_alice" \
https://localhost/sse \
-k --max-time 1
done | sort | uniq -c
30 200
20 429
Reading this solution:
- 30 events / 1m / per IP is conservative. Inspector reconnects on tab refresh; a developer running the lab will easily hit 5-10 reconnects in a minute. 30 leaves headroom for normal use and still kills a flood (1000+ requests / minute from one source IP).
- The limit applies to
/sseonly. POSTs to/messagescarry asessionIdthat the attacker doesn't know — those return404 unknown sessionpractically for free. Rate-limiting/messagestoo would require careful tuning because legitimate sessions issue many POSTs (one per tool call); easier to leave it and let the per-tenant in-process limit cover that path. - Caddy returns
429 Too Many Requestsautomatically. No app-layer code change needed. The MCP client sees a transport-level 429 and is responsible for backing off — clients that don't will just keep getting 429s, which is the intended behaviour. {remote_host}is the post-proxy client IP. If Caddy sits behind another proxy (Cloudflare, AWS ALB), settrusted_proxiesin the global options so{remote_host}resolves to the real client and not the upstream proxy's IP — otherwise one upstream IP gets the whole 30/minute budget and every client looks like the same attacker.
Common mistake
Adding the rate_limit block to the stock caddy:2-alpine
image and watching caddy validate fail with
unknown directive "rate_limit". The directive is in a
third-party module that the stock image doesn't ship; you must build a
custom image with xcaddy as shown above, or apply the limit
at a different layer entirely (Cloudflare WAF rule, AWS WAF, fronting
nginx with limit_req). If you can't build a custom Caddy
image in your environment, document the gap explicitly in the
runbook so the next operator doesn't assume the layer is in place when
it isn't. The second common variant: forgetting order rate_limit
before reverse_proxy in the global options — without it Caddy
parses the directive but emits a warning and silently does nothing. Always
confirm a 429 actually appears in the self-check; don't trust
caddy validate alone.
What you learned
- Limits stack — one layer is never enough. Per-IP at Caddy (anonymous floods), per-token in middleware (credential stuffing), per-second per-tenant (one tenant's runaway loop), per-day per-tenant (slow long-running abuse). Each layer protects against a class of attack that the others can't see. A single-layer design is a single point of failure: if the attacker's traffic shape doesn't match that layer's granularity, abuse passes straight through.
- Calendar boundaries beat sliding windows for quotas. "1000 calls per UTC day" reconciles cleanly against billing, capacity planning, and tenant communication ("you have X calls left until reset at midnight UTC"). "1000 calls in any rolling 24-hour window" is harder to explain, harder to implement (you need a ring buffer of timestamps), and harder to support over the phone. Pick the simpler semantics unless you have a real reason not to.
- Every
setTimeoutneeds aclearTimeouton every exit path. The session-timeout bug is the canonical timer leak: dev looks clean because connections are short and few; prod leaks 100 timers per second under real traffic and looks healthy for hours before the queue depth catches up. The pattern generalises — file watchers, interval polls, AbortControllers, child processes — anything you create on a per-connection basis needs a matching cleanup on close, error, and explicit-cancel. Three exit paths, not one. - Tenant identity must never reach you over the wire it
authorises. The token in
Authorization: Bearerestablishes the tenant; the tenant never appears in tool args, never in URL query params, never in the JSON-RPC payload. The moment you accept atenantfield from the caller, isolation collapses — the caller decides who they are. Trust the session, not the wire. Audit your tool schemas withgrep -E "tenant" src/schemas.tsand confirm the count is zero. - The right HTTP status code carries information downstream. 401 = no/invalid credentials, retry-with-auth. 403 = valid credentials, wrong tenant or insufficient permission, do not retry. 429 = rate limit, retry with backoff. Conflating them — especially blanket-401-ing everything — breaks client retry logic and floods your logs with spurious "auth failed" entries that are really rate limits. Pick one status per failure mode and never reuse.
Lesson 15 closes the course: threat model, input fuzzer, prompt-injection-resistant
tool result formatter, audit log, and a written runbook. The auth / store /
rateLimit modules from L14 stay byte-identical — L15 layers
audit.ts and sanitize.ts on top, plus a
scripts/fuzz.ts harness that exercises every tool with
pathological inputs and a RUNBOOK.md that documents
on-call procedures.