Building Relay

Part 3 · Chapter 3.8

Limits you can see coming

You will produce: Per-environment request counters, the headers on every response, and two limiters that fail in opposite directions · about 90 minutes including the exercise

Source: SRS — Software Requirements Specification

Chương 1.3 đã viết xuống một từ mà nền tảng này chưa bao giờ thốt ra.

packages/protocol/src/codes.ts (excerpt)
rate_limited: "too many requests",

Được export, được định kiểu, và suốt mười bảy chương không có gì phát ra nó. Close code 4008 — "quota exhausted" — cũng vậy, và 4009 — "server shutdown (drain)" — cũng vậy. Theo một cách khác, trường thứ tư của error envelope mà hiến pháp V yêu cầu từ 1.3 cũng thế: request_id, nằm ngay trên một dòng comment hứa rằng nó "sẽ nhập cuộc ở Phần 2, khi đã có một gateway để sinh ra nó". Gateway đã đến ở 1.4 và chẳng sinh ra gì cả.

Chương này thực thi hai trong bốn, giải thích vì sao cái thứ ba vẫn cố tình nằm im, và nói rõ cái thứ tư đang chờ điều gì.

Bản thân bộ giới hạn thì dễ. Hai cái, hỏng theo hai hướng ngược nhau một cách có chủ đích, mới là chương này.

Cái yêu cầu mà một bản vá chắp thêm sẽ trượt

Đây là FR-RTL-02, và nó đáng đọc hai lần:

Mọi response — 2xx cũng như 429 — đều mang X-RateLimit-Limit, X-RateLimit-RemainingX-RateLimit-Reset.

Nửa 429 là thứ ai cũng làm. Nửa 2xx mới là yêu cầu. Một client chỉ biết hạn mức của mình đúng vào lúc dùng hết là đã biết quá muộn: nó có thể lùi lại, nhưng nó đã không thể tự điều tiết nhịp độ. Journey map nói đúng điều đó từ phía lập trình viên, ở pha Test, dưới dạng một lời than về API của người khác:

rate limit trả về 429 mà không có header nào cho biết quota còn lại hay thời điểm reset

Đó là FR-RTL-02 viết bởi chính người phải chịu nó. Một bộ giới hạn bắt vít vào một service đã hoàn thiện sẽ đặt header ở chỗ viết ra lời từ chối, vì đó là nhánh duy nhất biết về giới hạn. Đặt chúng lên đường thành công nghĩa là nó chạy trên mọi request và ghi vào cả những response nó không từ chối — một thiết kế khác, được quyết trước khi viết code chứ không phải sau.

Vì sao cửa sổ là cố định

Lựa chọn hiển nhiên còn lại là token bucket, và trên gần như mọi trục nó là thuật toán tốt hơn: nó làm mượt burst, nó không có biên, và nó là thứ bạn sẽ với tay lấy nếu yêu cầu duy nhất là "giới hạn tốc độ".

flowchart TB
    subgraph w1["cửa sổ N · 12:00:00 – 12:00:59"]
      b1["600 request<br/>lúc 12:00:59"]
    end
    subgraph w2["cửa sổ N+1 · 12:01:00 – 12:01:59"]
      b2["600 request<br/>lúc 12:01:00"]
    end
    cost["1.200 request trong hai giây<br/>đối lại giới hạn 600 mỗi phút"]
    b1 --> cost
    b2 --> cost
    gain["Reset gọi tên MỘT khoảnh khắc.<br/>Câu trả lời trung thực của một<br/>bucket đang tự đầy lại là một<br/>đường cong, mà header chỉ đủ<br/>chỗ cho một con số."]
    cost -.->|"cái giá"| gain
    style cost fill:#78350f,color:#fff,stroke:#d97706
    style gain fill:#064e3b,color:#fff,stroke:#059669
Cái giá của fixed window, và thứ nó mua về.

Một fixed window có thể bị tiêu hai lần ở biên: 600 request trong giây cuối của phút này và 600 request trong giây đầu của phút sau là 1.200 request trong hai giây, đối lại một giới hạn 600 mỗi phút. Điều đó là thật, và không lời comment nào chữa được.

Đó là cái giá của X-RateLimit-Reset.

Vì cửa sổ là cố định, bộ đếm chỉ gồm hai lệnh Redis và không Lua:

services/api/src/limits/store.ts (excerpt)
const count = await redis.incr(key);
if (count === 1) await redis.pexpire(key, windowMs);

Chẳng có gì ở đây cần làm atomic. INCR vốn đã atomic và trả về giá trị mới, nên đọc và ghi là một thao tác. PEXPIRE được canh bằng count === 1 vì chỉ request tạo ra khóa mới cần đặt thời hạn cho nó. Một script Lua ở đây sẽ thỏa mãn một thói quen chứ không phải một race.

Phần số học, và chỗ nó sống

Ba hàm, không store, không đồng hồ:

services/api/src/limits/bucket.ts
// The fixed-window arithmetic (chapter 3.8, research R1).
//
// FIXED WINDOW, NOT A TOKEN BUCKET, and the SAD's own row is why the question
// arose: §6.3 lists `rl:{env}:{bucket}` as "Token buckets" with a TTL of
// "window", which are two different algorithms. The TTL column wins, for three
// reasons in order of weight.
//
// `X-RateLimit-Reset` decides it. The header names the moment an allowance
// returns, and a fixed window has exactly one. A continuously refilling bucket
// does not — the honest answer to "when do I have my full allowance back" is a
// curve, and the header is an integer. A limiter whose reset header is a lie
// fails FR-RTL-02 in the way that matters, because that requirement exists so a
// client can schedule against it.
//
// Then atomicity: `INCR` returns the new value on its own and `EXPIRE` on the
// first increment gives the window. Two commands, no Lua, no read-modify-write
// race between api instances.
//
// Then cleanup: the key expires when its window ends, so nothing accumulates.
// That matters more than it sounds — chapter 3.7 spent a baseline on four suites
// that broke because a shared store grew without bound, and this chapter's own
// baseline found a fifth.
//
// THE COST, stated rather than hidden: up to twice the limit across a boundary.
// 600 in the last instant of one window and 600 in the first instant of the next
// is 1,200 inside two minutes. The limit bounds sustained load; it does not
// smooth instantaneous rate. `bucket.test.ts` asserts it so the claim is checked
// rather than merely written down.
//
// Everything here is pure and takes the instant it should reason about. Nothing
// reads a clock, so a boundary is a test rather than a wait.
 
/** The window an instant belongs to, floored — and the key's own suffix.
 *
 * Two api instances compute this from the same wall clock and agree without
 * talking to each other, which is what closes the clock-skew case by
 * construction. A stored reset time would be a value they could disagree about,
 * and `Retry-After` is exactly where that disagreement would surface. */
export function windowStart(nowMs: number, windowMs: number): number {
  return Math.floor(nowMs / windowMs) * windowMs;
}
 
/** When the allowance returns: the end of the current window, in milliseconds.
 *
 * One moment, which is the whole argument for this algorithm over a bucket that
 * refills. Never in the past — the window an instant belongs to always ends
 * after it. */
export function resetAt(nowMs: number, windowMs: number): number {
  return windowStart(nowMs, windowMs) + windowMs;
}
 
/** How many operations are left, after counting the one in hand.
 *
 * Clamped at zero. A limit lowered while a window is open — an operator dropping
 * an environment from 600 to 2 with forty already counted — would otherwise
 * produce `-38`, and a client would parse that as a number and act on it. Zero
 * is both true and safe. */
export function remaining(count: number, limit: number): number {
  return Math.max(0, limit - count);
}

windowStart làm tròn xuống, và chính điều đó cho phép hai instance api cùng một gateway thống nhất được chúng đang tăng bucket nào mà không cần trao đổi một lời. Không ai điều phối ai; tất cả cùng chia một chiếc đồng hồ theo cùng một cách.

Vì thuần khiết, một biên cửa sổ là windowStart(59_999, 60_000) === 0 được khẳng định thay vì phải nằm chờ hết một phút.

Policy: ba cột, và null không phải số không

services/api/migrations/0008_limit_policy.sql
-- Chapter 3.8 — per-environment rate limit policy (FR-RTL-04, FR-RTL-04).
--
-- NULLABLE, AND NULL IS NOT ZERO. A null column means "no override, use the
-- documented default", resolved at read time. A zero means "refuse everything",
-- which has to stay expressible — an environment can be switched off
-- deliberately — so the absent state and the refuse-everything state cannot
-- share a representation.
--
-- ON `environments` RATHER THAN IN A TABLE OF ITS OWN. FR-RTL-04's independence
-- is per environment, there is exactly one row per environment with no history
-- and no versioning, and a separate table would be a join for a value read on
-- every request.
--
-- The shape has a slot for an environment and NONE FOR A ROUTE, which forecloses
-- SRS Appendix C question 5 — whether the dev-token endpoint should be limited
-- more aggressively than the rest of its environment. That question stays open
-- and this is why (research R30).
 
ALTER TABLE environments
  ADD COLUMN rest_limit_per_minute    integer,
  ADD COLUMN send_limit_per_minute    integer,
  ADD COLUMN connect_limit_per_minute integer;
 
ALTER TABLE environments
  ADD CONSTRAINT environments_rest_limit_non_negative
    CHECK (rest_limit_per_minute IS NULL OR rest_limit_per_minute >= 0),
  ADD CONSTRAINT environments_send_limit_non_negative
    CHECK (send_limit_per_minute IS NULL OR send_limit_per_minute >= 0),
  ADD CONSTRAINT environments_connect_limit_non_negative
    CHECK (connect_limit_per_minute IS NULL OR connect_limit_per_minute >= 0);

Cả ba đều nullable, và tính nullable ấy chính là quyết định.

environments vốn đã có sẵn một cột trông rất hợp cho việc này, và nó đã cố tình không được dùng:

services/api/src/db/schema.ts (excerpt)
quotaConfig: jsonb("quota_config").notNull().default({}),

Khai báo ở 2.1, được gọi tên trong SRS §6.1, rỗng suốt mười bảy chương — và mang tên quota. Rate limit và quota là hai lời hứa khác nhau: một cái phù du và có thể mất, cái kia là tiền và phải bền. Nhét cái này vào một trường mang tên cái kia sẽ làm sụp đổ ngay trong schema đúng thứ mà cả chương này bỏ công vẽ ra.

Các giá trị mặc định đến từ đâu

Bốn con số, và mỗi con số đều được suy ra chứ không phải chọn bừa:

Thao tácMặc địnhSuy ra từ
REST request600/phútngân sách P95 của NFR-PRF-01 ở một nhịp bền vững
Message send600/phútnhư trên, và cố tình bằng nhau
Connection3.000/phút10.000 kết nối mỗi gateway instance của NFR-SCL-01
Xác thực thất bại10/phút/IPđủ chậm để chặn một đợt quét mật khẩu, đủ nhanh để không khóa nhầm một cú gõ sai

Giới hạn connect ban đầu là 60/phút, và sai lệch tới năm mươi lần. NFR-SCL-01 là yêu cầu P1 cho mười nghìn kết nối trên mỗi gateway instance; với 60 mỗi phút, chạm tới con số đó mất 167 phút. Một giới hạn khiến một yêu cầu năng lực P1 không thể đạt tới trong vòng ba tiếng thì không phải giới hạn — nó là một cái bug có kèm cột policy.

Hai vị trí trong chuỗi, cả hai đều bị ép

flowchart LR
    req(["request"])
    rc["RequestContextMiddleware<br/>chương 2.2"]
    am["AuthenticateMiddleware<br/>chương 3.2"]
    rl["RateLimitMiddleware<br/>chương 3.8"]
    cg{"CredentialGuard"}
    h["handler"]
    req --> rc --> am --> rl --> cg --> h
    inside[["bộ đếm XÁC THỰC sống<br/>BÊN TRONG middleware này:<br/>nó phải chạy được cả khi<br/>không có principal"]]
    after[["bộ giới hạn TENANT đứng SAU nó:<br/>giới hạn thuộc về một environment<br/>và chỉ bước này mới biết<br/>đó là environment nào"]]
    am -.-> inside
    rl -.-> after
    style am fill:#1e3a8a,color:#fff,stroke:#3b82f6
    style rl fill:#064e3b,color:#fff,stroke:#059669
Chuỗi middleware, và vì sao mỗi bộ giới hạn ngồi đúng chỗ của nó.

Bộ giới hạn tenant chạy sau AuthenticateMiddleware và không có lựa chọn nào khác: giới hạn thuộc về một environment, và không gì biết đó là environment nào cho tới khi credential được phân giải. Bộ đếm xác thực-thất-bại chạy bên trong nó, và cũng không có lựa chọn nào khác: nó đếm đúng trường hợp không có principal, nên không thể chạy ở bất cứ đâu giả định là có.

services/api/src/auth/authenticate.middleware.ts (excerpt)
const address = clientAddress(req);
if (await this.authLimiter.isOverThreshold(address)) {
  req[OVER_AUTH_THRESHOLD] = true;
}
const principal = await resolvePrincipal(this.db, credential);
if (principal !== null) {
  req.principal = principal;
} else {
  await this.authLimiter.recordFailure(address);
}

Thất bại được quan sát ở đây và bị từ chối ở nơi khác: middleware này chưa bao giờ throw kể từ chương 3.2, nên nó dựng một cờ và CredentialGuard mới là chỗ ném ra 429.

Cái gì bị đếm, và cái gì không

services/api/src/limits/rate-limit.middleware.ts (excerpt)
export function operationsFor(method: string, path: string): LimitedOperation[] {
  if (!path.startsWith(PUBLIC_PREFIX)) return [];
  if (method === "POST" && SEND_PATH.test(path)) return ["rest", "send"];
  return ["rest"];
}

Một message send tốn hai ngân sách, một request và một message. Mọi thứ khác dưới /v1/ tốn một request, và bất cứ gì ngoài đó không tốn gì — /healthz, đường nối nội bộ của gateway, và báo cáo kết quả của dispatcher.

Bóp cổ dispatcher sẽ biến đống webhook tồn đọng của một khách hàng thành một cú đình trệ cho mọi khách hàng, điều mà FR-WHK-05 cấm bằng đúng chừng ấy chữ.

Hai hướng

Đây mới là chương này.

flowchart TB
    out(["Redis không truy cập được"])
    subgraph tenant["bộ giới hạn TENANT · rl:{env}:{op}:{window}"]
      t1["không biết số đếm"]
      t2["PHỤC VỤ request"]
      t3["chỉ còn X-RateLimit-Limit<br/>Remaining và Reset vắng mặt"]
      t1 --> t2 --> t3
    end
    subgraph auth["bộ giới hạn XÁC THỰC · rlauth:{address}:{window}"]
      a1["không biết số đếm"]
      a2["bộ đếm dự phòng in-process<br/>cùng ngưỡng"]
      a3["TỪ CHỐI khi quá 10/phút<br/>trên mỗi instance, không phải toàn fleet"]
      a1 --> a2 --> a3
    end
    out --> t1
    out --> a1
    why1["sự cố cache không được phép<br/>từ chối lưu lượng đã trả tiền<br/>SAD §6.3"]
    why2["một cửa sổ không giới hạn cho<br/>đăng nhập thất bại không phải<br/>suy giảm, nó là lỗ hổng"]
    t3 -.-> why1
    a3 -.-> why2
    style t2 fill:#064e3b,color:#fff,stroke:#059669
    style a3 fill:#7f1d1d,color:#fff,stroke:#dc2626
Một sự cố, hai bộ giới hạn, hai câu trả lời ngược nhau — và cả hai đều đúng.

Redis biến mất. Bộ giới hạn tenant không đếm được, nên nó phục vụ request:

$ POST /v1/channels/{id}/messages   # 1 of 4, limit is 2
HTTP 201
x-ratelimit-limit: 2
x-ratelimit-remaining: (absent)
x-ratelimit-reset: (absent)

$ POST /v1/channels/{id}/messages   # 4 of 4, limit is 2
HTTP 201
x-ratelimit-limit: 2
x-ratelimit-remaining: (absent)
x-ratelimit-reset: (absent)

Bốn request đối lại một giới hạn là hai, tất cả đều được phục vụ. SAD §6.3 nói Redis không phải nguồn sự thật, và từ chối mọi thứ chỉ vì bộ đếm không sẵn sàng sẽ biến một sự cố cache thành một sự cố nền tảng — một thất bại lớn hơn nhiều so với thứ nó ngăn được.

Hãy để ý header nào sống sót. Limit ở lại: nó là policy đọc từ Postgres và không hề suy giảm. RemainingReset biến mất, vì chúng chỉ tồn tại chừng nào còn có thứ gì đó đang đếm. Vắng mặt là câu trả lời trung thực; -1 là một giá trị quy ước mà một client không biết quy ước sẽ đọc thành con số và hiểu là "bạn đã vượt giới hạn".

Giờ vẫn sự cố ấy, vẫn tiến trình ấy, vẫn khoảnh khắc ấy, nhưng với bộ giới hạn xác thực-thất-bại:

$ POST /auth/dev-token   # bad credential 1 of 5, threshold is 3
HTTP 401
$ POST /auth/dev-token   # bad credential 2 of 5, threshold is 3
HTTP 401
$ POST /auth/dev-token   # bad credential 3 of 5, threshold is 3
HTTP 401
$ POST /auth/dev-token   # bad credential 4 of 5, threshold is 3
HTTP 429
$ POST /auth/dev-token   # bad credential 5 of 5, threshold is 3
HTTP 429

Bị từ chối. Đem lối lập luận của bộ giới hạn tenant sang đây thì nó cho ra câu trả lời sai: một cửa sổ không giới hạn cho các lần đăng nhập thất bại không phải là suy giảm, nó là một lỗ hổng, và kẻ tấn công chỉ cần chờ một sự cố cache là có ngay một đợt quét mật khẩu không bị chặn.

Một dòng log duy nhất, giới hạn mười giây một lần, vì một sự cố Redis dưới tải sẽ phát một dòng cho mỗi lượt thử và biến một sự cố thành hai:

{"time":"2026-08-20T03:48:30.986Z","level":"error","service":"api",
 "msg":"limits.auth_degraded",
 "detail":"counter store unreachable; counting failed authentications in process",
 "tracked":0}

Không credential và không địa chỉ, theo NFR-SEC-06. tracked là số địa chỉ mà bộ đếm dự phòng đang giữ, và đó là thứ cho người vận hành biết trần đã gần hay chưa.

Đó là thất bại của ai?

Bộ đếm lấy khóa từ địa chỉ của client, không phải của bên gọi, và làm sai chỗ này là một lỗ hổng chứ không phải một bất tiện. Một handshake xác thực qua gateway sẽ đến api từ gateway; lấy khóa theo bên gọi thì thất bại của mọi khách hàng dồn vào một bucket, nên một kẻ tấn công là đủ để làm cạn một ngưỡng rồi từ chối tất cả mọi người — một cú từ chối dịch vụ với vũ khí là chính cái rate limiter.

Gateway: hai giới hạn, hai hình dạng từ chối

Socket có một giới hạn thiết lập kết nối và một giới hạn gửi, và hai lời từ chối của nó không giống nhau. Một handshake vượt giới hạn nhận HTTP 429, được ghi thẳng lên socket upgrade thô trước cả khi wss.handleUpgrade được gọi:

services/gateway/src/session.ts (excerpt)
if (decision.over) {
  refuseUpgrade(socket, decision);   // 429 · Retry-After · the three headers
  return;
}
wss.handleUpgrade(req, socket, head, (ws) => { … });

Một frame vượt giới hạn nhận về một frame error, và kết nối vẫn mở.

Một request, hai kiểu tin cậy ngược nhau

Gateway được miễn trừ khỏi bộ giới hạn tenant — các lời gọi nội bộ của nó không tốn gì của khách hàng — mà đồng thời không được tin khi nói ai đã gây ra một lần đăng nhập thất bại. Một request, hai phán quyết.

Nghe như mâu thuẫn nhưng không phải. "Lời gọi này có nên tính vào ngân sách của khách hàng không?" là câu hỏi về việc của ai, và việc của gateway chính là lưu lượng socket của khách hàng, vốn đã được đếm ở frame. "Đó là thất bại của ai?" là câu hỏi về credential đến từ đâu, và gateway chỉ chuyển tiếp credential ấy chứ không phải nơi khởi nguồn. Tin một service là hạ tầng không giống với tin nó là nguồn gốc.

Thứ còn lại gateway không làm được là đọc database. ADR-05 cấm điều đó, và chương 3.2 đã chấp nhận trả thêm một vòng gọi thay vì chuyển signing secret của mọi environment tới một service không giữ trạng thái tenant nào. Vậy nên các giới hạn đi nhờ chính cái response xác thực mà gateway vốn đã gọi:

packages/protocol/src/internal.ts (excerpt)
limits: z.strictObject({
  connect: z.number().int().nonnegative(),
  send: z.number().int().nonnegative(),
}),

Thêm một trường bắt buộc vào schema ấy làm gãy bảy fixture viết tay, tất cả đều bị trình biên dịch bắt trước khi có test nào chạy. Để nó optional kèm giá trị mặc định thì sẽ chẳng gãy gì — và cả bảy chỗ đó sẽ lặng lẽ đi qua nhánh mặc định, kể cả hai test mà chủ đề duy nhất của chúng là một giới hạn đã được cấu hình.

Hai service, một bộ đếm

Một cú gửi qua socket và một cú gửi qua REST tiêu cùng một ngân sách — một client có thể nhân đôi hạn mức bằng cách mở WebSocket thì coi như không có hạn mức nào — và đó là lý do bộ đếm sống trong Redis chứ không nằm trong tiến trình nào. Không bên nào nhìn thấy bộ nhớ của bên kia.

Vì thế gateway giữ Redis client riêng, và đây là điều bị ép chứ không phải được ưa thích. Fanout là một interface đóng, không phơi ra client nào trong hai client nó giữ; một trong hai là subscriber, và một kết nối ở chế độ subscribe không chạy được INCR; và fanout là tùy chọn trong session server, nên một bộ giới hạn đi nhờ vòng đời của nó sẽ biến mất trong mọi cấu hình không có fabric.

Một integration test gánh lời khẳng định đó, và nó là test duy nhất trong chương không thể làm rẻ hơn: một tiến trình con api thật, một gateway thật, một Redis thật. Năm cú gửi qua REST và năm frame message.send để lại bucket send dùng chung ở mức 10rest ở mức 5. Hai bộ đếm riêng biệt sẽ đọc ra 5 và 5, và vượt qua mọi test còn lại trong suite.

Vì sao 4008 vẫn nằm im

4008 đọc là "quota exhausted". Chưa có quota nào cả.

Với tay lấy nó chỉ vì nó đã được khai báo sẽ làm sụp đổ đúng cái ranh giới mà cả chương này dựng lên. Một rate limit nói chậm lại, quay lại sau bốn mươi giây, không có gì sai cả. Một quota nói bạn đã dùng hết phần đã mua. Chúng hỏng theo hai hướng khác nhau, và một cái thuộc về Redis còn cái kia thì không thể.

Nên có một test khẳng định không gì trong gateway phát ra nó:

services/gateway/src/session.test.ts (excerpt)
for (const text of source) {
  expect(text).not.toMatch(/close\(\s*400[89]/);
}
expect(source.join("")).toMatch(/close\(\s*400[12]/);

Dòng thứ hai mới là dòng quan trọng. Một khẳng định về sự vắng mặt không thể chứng minh bằng bất kỳ đầu vào nào — không có frame nào bạn gửi được để quan sát một mã không bao giờ được gửi — nên phép kiểm tra phải đọc mã nguồn. Nhưng một regex nhắm vào một mã chẳng ai gửi thì sẽ pass dù regex đúng hay sai. Chạy đúng mẫu ấy lên những mã mà gateway gửi mới là thứ khiến khẳng định phủ định đáng giá.

Trường thứ tư

request_id giờ nằm trên mọi lỗi mà nền tảng phát ra — envelope REST, frame error của socket, error filter của framework. Không chỉ riêng 429.

Nó làm gãy bốn test cô lập tenant, và chúng gãy là đúng. Mỗi test so sánh hai error body bằng phép bằng nhau:

expect(await foreign.json()).toEqual(await missing.json());

Thuộc tính ấy là thật: một credential cho environment sai và một credential cho tài nguyên không tồn tại phải không phân biệt được, nếu không lỗi trở thành một chiếc máy tra cứu liệt kê ra những gì đang tồn tại. Một id theo từng request khiến hai body khác nhau ở một trường chẳng nói gì về cả hai, nên các test giờ bóc nó ra rồi mới so phần còn lại — một phát biểu chính xác hơn về điều chúng vẫn luôn muốn nói.

Một giới hạn mà lập trình viên được kỳ vọng sẽ đâm vào

Pha Test của journey map có cảnh lập trình viên cố tình đẩy tới một rate limit, để xem thư viện client của mình xử lý 429 ra sao. Cô ấy là người dùng duy nhất trong cả bản đồ sẽ chạm tới 600 mỗi phút một cách chủ ý.

Đó là lý do FR-RTL-04 nói giới hạn của một environment phát triển vốn được thiết kế để nâng lên. Các cột là theo từng environment, nên nâng một cái để load test không làm dịch trần của production — mà một policy dùng chung thì sẽ làm đúng như vậy.

Thứ mà bản ghi lại tìm ra còn các test thì không

Chương này chụp lại các transcript thay vì mô tả chúng. Lần chụp 429 đầu tiên in ra thế này:

HTTP 429
x-ratelimit-limit: 3
retry-after: 22
{"code":"rate_limited","message":"too many messages for this environment; …"}

Limit: 3 nằm trên "too many messages", ở một environment có giới hạn send là 2.

Cả hai ngân sách đều chạm mức còn lại bằng không trong request ấy nhưng chỉ một cái thực sự vượt: với rest là 3 và send là 2, cú gửi thứ ba để lại rest ở 3 trên 3 — đã tiêu hết, chưa vượt — và send ở 3 trên 2, tức là đã vượt. Header mô tả cái nào còn lại ít nhất, hòa thì lấy cái đầu tiên, và cái đầu tiên là rest. Thế là phần body gọi tên ngân sách đã từ chối còn header gọi tên cái kia. Client đọc Limit: 3, tự điều tiết ở ba mỗi phút, rồi bị từ chối ở hai.

Bản vá chỉ một dòng: một lời từ chối mô tả ngân sách đã từ chối, còn "còn lại ít nhất" chỉ chi phối những response đang được phục vụ. Mười tám integration test phủ lên middleware ấy và không cái nào bắt được, vì mỗi cái chỉ khẳng định một trường và chưa ai nhìn vào trọn vẹn một response.

Sau khi sửa:

$ POST /v1/channels/{id}/messages   # the first send
HTTP 201
x-ratelimit-limit: 2
x-ratelimit-remaining: 1
x-ratelimit-reset: 1787197740

$ POST /v1/channels/{id}/messages   # the second
HTTP 201
x-ratelimit-limit: 2
x-ratelimit-remaining: 0
x-ratelimit-reset: 1787197740

$ POST /v1/channels/{id}/messages   # over the send limit
HTTP 429
x-ratelimit-limit: 2
x-ratelimit-remaining: 0
x-ratelimit-reset: 1787197740
retry-after: 51
{
  "code": "rate_limited",
  "message": "too many messages for this environment; retry after 51 seconds",
  "docs_url": "https://relay.dev/docs/error-reference#rate_limited",
  "request_id": "67219aad-436e-434c-8da2-a6a8c9a16754"
}

x-ratelimit-reset giống nhau ở cả ba, và đó là fixed window đang làm đúng bổn phận của một fixed window. Còn body có bốn trường, điều chưa từng có trước chương này.

Chương này đã thay đổi những gì bên ngoài code

Ba thứ, và mỗi thứ đều là điều một chương có nghĩa vụ nói thành lời.

Nó sửa SRS

EIR-API-04 mô tả một error body lồng dưới khóa error:

{ "error": { "code": "…", "message": "…", "docs_url": "…" } }

Nền tảng chưa bao giờ phát ra thứ đó. Mọi lỗi kể từ chương 1.3 đều phẳng, envelope của hiến pháp V là phẳng, và chuỗi fence đã và đang phát lại những error body phẳng vào các chương đã xuất bản suốt từ đó.

Hai tài liệu bất đồng và một trong hai sai. Bọc mọi error response lại cho khớp SRS là một thay đổi phá vỡ hợp đồng công khai — điều mà CON-05 biến thành một sự kiện đánh phiên bản — vì một hình dạng chưa ai từng nhận được. Nên tài liệu được kéo về với code: docs/04-srs.md bản 1.3 ghi năm trường ở cấp cao nhất và gỡ lớp bọc khỏi ví dụ. Một chương thay đổi một yêu cầu gốc thì phải nói ra, và đó chính là kỷ luật mà chuỗi fence áp cho code, nay áp lên chính những tài liệu mà code được dựng từ đó.

Nó hoàn tất tập yêu cầu của Phase 2 trong SRS

§7.3 liệt kê Phase 2 gồm FR-TEN, FR-AUT, FR-WHK và FR-RTL ở mức P2, và FR-RTL-01…04 là cái cuối cùng trong bốn. Tiêu chí thoát của pha lại là chuyện khác và thuộc về chương cửa ải cô lập: "một lập trình viên bên ngoài tích hợp được chỉ bằng tài liệu công khai, không cần ai trợ giúp." Yêu cầu hoàn tất ở đây; pha chỉ thoát khi có người ngoài repository này dùng được thứ mà các yêu cầu ấy mô tả.

docs_url vẫn là chỗ giữ chỗ, và giờ điều đó bắt đầu có giá

Mọi lỗi đều mang một docs_url trỏ tới https://relay.example/docs/errors/{code}, một địa chỉ không tồn tại.

ĐÃ SỬA LẠI bởi chương 3.14. Mục này gọi tên món nợ rồi từ chối trả, và nó nằm đó thêm sáu chương nữa — chương 3.10 thêm quota_exceeded vào danh sách những code chẳng có chỗ nào để trỏ tới, và chương 3.11 từ chối thêm cái thứ ba. Giờ nó đã được trả: tài liệu tham chiếu là docs/08-error-reference.md, URL là https://relay.dev/docs/error-reference#{code}, và một phép kiểm so registry với các heading của tài liệu theo cả hai chiều. Đoạn dưới đây là lý do cuối cùng nó được trả, không phải mô tả trạng thái hiện tại.

Vô hại từ chương 1.3, vì những mã nó gọi tên đều là mã lập trình viên chỉ gặp khi đang làm sai điều gì đó. rate_limited thì khác: nó là lỗi đầu tiên mà một tích hợp đang chạy tốt nhận được thường xuyên, đúng vào lúc tác giả của nó muốn tra cứu.

Thời điểm này rất đắt. Chương này khép lại tập yêu cầu của một pha mà tiêu chí thoát là tích hợp chỉ bằng tài liệu công khai, trong khi ship ra đúng mã lỗi dễ khiến người ta đi tìm tài liệu không có ở đó nhất. Hiến pháp V đòi mọi mã lỗi phải có một trang truy cập được; không mã nào có. Dựng một trang tài liệu không phải việc của chương này, nhưng một URL ngụ ý ngược lại còn tệ hơn là không có URL nào.

Trọn vẹn chương này

Tất cả những gì ở trên, đúng như repository đang giữ.

Bản thân các bộ đếm

Bốn file không có framework nào bên trong, cộng một migration. bucket.ts và migration đã nằm ở trên; đây là phần còn lại.

services/api/src/limits/policy.ts
// The limit policy: what each environment is allowed, and what each number rests
// on (chapter 3.8, research R26).
//
// R4 chose all four of these by judgement and checked none of them against a
// document stating this platform's scale. The fourteenth analysis pass read the
// SRS's NFR tables and found that one of them made a P1 requirement unreachable,
// so all four were re-derived rather than the broken one patched.
//
// WHAT WENT WRONG IS WORTH KEEPING. The connect limit was 60/min, on the
// reasoning that "sixty establishments a minute per environment is a client
// reconnecting hard, not a client working". True of a client. The limit is per
// ENVIRONMENT, and an environment is a tenant — NFR-SCL-01 puts ten thousand
// concurrent connections on one gateway instance and FR-RTM-09 allows five per
// user, so filling one instance from cold would have taken 167 minutes.
//
// Each number below names what it rests on, including the one that rests on
// nothing. That is the actual fix; the new value is a consequence of it.
 
/** Per environment, per minute. Overridable per environment (FR-RTL-04, FR-RTL-04);
 * these apply when a column is null, which means "no override" and never zero —
 * refuse-everything has to stay expressible. */
export const DEFAULT_LIMITS = {
  /** NO ANCHOR, and recorded as such. No SRS requirement caps a tenant's request
   * rate; NFR-PRF-02's p95 under 150 ms is a latency target, not a throughput
   * bound. Matched to the send limit because a REST send consumes both budgets
   * (FR-RTL-01), and two different ceilings on one operation would mean a client
   * hitting one while the other says it has room. */
  rest: 600,
 
  /** 1% of NFR-SCL-03's stated 1,000 messages per second aggregate — 60,000 a
   * minute across the platform. So a hundred environments at their ceiling
   * saturate it, and the hundred-and-first is what this protects. */
  send: 600,
 
  /** NFR-SCL-01's ten thousand connections per gateway instance, divided by
   * FR-RTM-09's five per user, re-established inside one window so a deploy stays
   * one reconnection cycle (NFR-REL-03).
   *
   * IT IS STILL A LIMIT. Its job is to stop a client reconnecting in a tight
   * loop, which does thousands a minute and is refused well before a legitimate
   * fleet is. It is not there to shape a tenant's capacity, and the old number
   * had those two jobs confused. */
  connect: 3_000,
} as const;
 
export type LimitedOperation = keyof typeof DEFAULT_LIMITS;
 
/** Failed authentications per source address per minute.
 *
 * NOT a per-environment column: the caller has not proved which environment they
 * are, which is the point of the limiter. Configuration, and configuration the
 * lane has to be able to raise — the api's own integration suites assert `401`
 * twenty-six times from one loopback address inside about 110 seconds, so a
 * threshold nothing could lift would refuse this project's own tests
 * (research R15).
 *
 * THE DEFAULT ENFORCES. Chapter 3.6's `RELAY_DISABLE_SWEEP` states the rule: a
 * flag whose default disabled a requirement would be a requirement nobody had
 * built.
 *
 * THE COST IT CARRIES, which R4 did not name: shared egress. An office behind one
 * NAT is one source address, so ten failed logins a minute is a whole building's
 * budget — and the refusal is deliberately indistinguishable from a wrong
 * credential (EIR-API-04), so they will experience it as a broken login. Kept anyway;
 * the alternative is a threshold high enough to be worthless against the attack
 * it exists for. */
export const DEFAULT_AUTH_FAILURES_PER_MINUTE = 10;
 
export function authFailureThreshold(): number {
  const raw = process.env["RELAY_AUTH_FAILURES_PER_MINUTE"];
  if (raw === undefined) return DEFAULT_AUTH_FAILURES_PER_MINUTE;
  const parsed = Number.parseInt(raw, 10);
  return Number.isFinite(parsed) && parsed > 0
    ? parsed
    : DEFAULT_AUTH_FAILURES_PER_MINUTE;
}
 
/** The window every counter uses. One minute, because every limit above is
 * expressed per minute and a second unit would be a second thing to reason
 * about. */
export const WINDOW_MS = 60_000;
services/api/src/limits/fallback.ts
// The in-process counter the AUTH limiter falls back to when Redis is gone
// (chapter 3.8, research R3).
//
// THE TENANT LIMITER FAILS OPEN AND THIS ONE MUST NOT, and that asymmetry is the
// chapter's whole argument. Both are the same mechanism; what differs is what is
// on the other side of the limit. The tenant limiter protects Relay's capacity
// from a customer's traffic, and over-serving a paying customer for the length of
// a cache outage costs some capacity. This one protects a customer's credentials
// from an attacker, and over-serving an attacker costs the customer their
// account.
//
// So neither of the two obvious answers is right. Failing open is unbounded — a
// hole rather than a degradation. Failing closed converts a Redis restart into an
// authentication outage, which is worse than the attack it prevents for every
// customer who is not being attacked. The third answer is this: count in memory,
// same threshold, and let the guarantee weaken from "N per window across the
// fleet" to "N per window per instance". Three api instances give an attacker
// three times the attempts for the duration of the outage — a small multiple
// rather than infinity.
//
// THE CAP IS PART OF THE DECISION, NOT A DETAIL. A map keyed by
// attacker-controlled source address is a memory-exhaustion vector if it is
// unbounded, and a fallback that closed a brute-force hole by opening a worse one
// would not be worth having.
//
// AND IT STOPS ADMITTING RATHER THAN EVICTING. An eviction policy on this map is
// a policy the attacker drives: fill it, evict the entry that was counting them,
// start again. Refusing new keys degrades to "addresses already being tracked
// stay tracked", which is the safe direction.
 
interface Entry {
  count: number;
  windowStart: number;
}
 
export interface FallbackCounter {
  /** Count one failure against a key, returning the new count — or `null` when
   * the key could not be admitted because the map is full. A caller that gets
   * `null` has learned nothing about that address and must not treat it as
   * "under the threshold". */
  increment(key: string, nowMs: number): number | null;
  /** The current count for a key WITHOUT adding to it, or `null` when the key is
   * not tracked and the map is full.
   *
   * `null` and `0` are different answers and the caller must tell them apart:
   * zero means "tracked, nothing counted", null means "we have no idea". While
   * degraded, refusing an address we cannot track is the safe direction, and the
   * cap makes that a bounded population rather than everybody. */
  peek(key: string, nowMs: number): number | null;
  /** Live keys. Exposed for the test that proves the bound holds. */
  size(): number;
}
 
export function createFallbackCounter({
  windowMs,
  maxKeys,
}: {
  windowMs: number;
  maxKeys: number;
}): FallbackCounter {
  const entries = new Map<string, Entry>();
 
  return {
    increment(key, nowMs) {
      const start = Math.floor(nowMs / windowMs) * windowMs;
      const existing = entries.get(key);
 
      if (existing !== undefined) {
        if (existing.windowStart === start) {
          existing.count += 1;
          return existing.count;
        }
        // Same key, new window: reuse the slot rather than counting against the
        // cap twice.
        existing.count = 1;
        existing.windowStart = start;
        return 1;
      }
 
      if (entries.size >= maxKeys) {
        // Sweep what the current window has already outlived before refusing.
        // The cap is on LIVE keys, not on keys ever seen — an outage lasting
        // hours must not permanently refuse to count anybody new.
        for (const [k, v] of entries) {
          if (v.windowStart !== start) entries.delete(k);
        }
      }
      if (entries.size >= maxKeys) return null;
 
      entries.set(key, { count: 1, windowStart: start });
      return 1;
    },
 
    peek(key, nowMs) {
      const start = Math.floor(nowMs / windowMs) * windowMs;
      const existing = entries.get(key);
      if (existing === undefined) {
        return entries.size >= maxKeys ? null : 0;
      }
      return existing.windowStart === start ? existing.count : 0;
    },
 
    size() {
      return entries.size;
    },
  };
}
services/api/src/limits/store.ts
import { Redis } from "ioredis";
 
import { WINDOW_MS } from "./policy";
 
// The counter store (chapter 3.8, research R1).
//
// THE ONLY MODULE IN THE API PERMITTED TO HOLD A REDIS CLIENT, enforced by
// `no-restricted-imports` in `eslint.config.mjs` — the same confinement the
// database driver has, for the same stated reason. The keys are per environment,
// so an unrestricted client would let any handler read or write another tenant's
// counter, and constitution I makes that a correctness property rather than a
// convention.
//
// TWO COMMANDS, NO LUA. `INCR` returns the new value atomically on its own, and
// `EXPIRE` is set only when the increment returns 1 — the first write of a
// window. A token bucket would need read-timestamp-compute-write, which across
// instances needs a script, which is a second language in the request path
// (constitution VII).
//
// The TTL does the cleanup: a key dies when its window ends and nothing
// accumulates. Chapter 3.7's baseline and this chapter's own both found suites
// broken by shared stores that grew without bound, so a counter that tidies
// itself is worth the sentence.
 
export interface CounterStore {
  /** Count one operation against a key, returning the new count — or `null` when
   * the store could not be reached.
   *
   * NULL IS NOT ZERO AND NOT AN ERROR. It means "we are not counting", and each
   * caller decides what that is worth: the tenant limiter serves the request
   * (SAD §6.3, Redis is not a source of truth), and the auth limiter falls back to
   * counting in memory rather than letting an attacker through (FR-AUT-12). Same
   * signal, opposite conclusions, which is the chapter's argument in one return
   * type. */
  increment(key: string, nowMs: number): Promise<number | null>;
  /** The current count without adding to it, or `null` when the store could not
   * be reached. Asking "is this address over the threshold" must not itself push
   * it over — a limiter whose check is also a write refuses on its own
   * questions. */
  get(key: string): Promise<number | null>;
  close(): Promise<void>;
}
 
export const DEFAULT_REDIS_URL = "redis://localhost:6379";
 
/** The key. `rl:` is the prefix the SAD's cache-keys table names; the operation and the
 * window's start are appended so one `INCR` reaches the right counter and the key
 * expires itself.
 *
 * That EXTENDS the SAD's three-segment `rl:{env}:{bucket}` rather than matching
 * it, and the extension is what makes the TTL do the cleanup. */
export function counterKey(
  scope: string,
  operation: string,
  windowStartMs: number,
): string {
  return `rl:${scope}:${operation}:${windowStartMs}`;
}
 
/** The auth counter's key, keyed by source address rather than environment.
 *
 * A SEPARATE PREFIX, not an `operation` value on the tenant key, because it is
 * keyed by something else entirely and because the two have opposite failure
 * behaviour. Sharing a prefix would invite sharing a code path, and the whole
 * point is that they must not.
 *
 * THE PREFIX IS OVERRIDABLE, and that is test isolation rather than
 * configuration. The integration lane runs files in PARALLEL — only the coverage
 * config sets `fileParallelism: false` — so every suite asserting a `401` from
 * loopback lands in one bucket. Raising a threshold survives that; a suite that
 * needs a LOW threshold needs its own key, or it compares a count filled by other
 * workers against a deliberately small number and refuses requests that had
 * nothing to do with it (research R21).
 *
 * The same pattern `attempts.itest.ts` uses for its durable name, and for the
 * same reason. */
export function authKey(
  address: string,
  windowStartMs: number,
  prefix: string = process.env["RELAY_AUTH_KEY_PREFIX"] ?? "rlauth",
): string {
  return `${prefix}:${address}:${windowStartMs}`;
}
 
export function createCounterStore(
  url: string = process.env["RELAY_REDIS_URL"] ?? DEFAULT_REDIS_URL,
): CounterStore {
  // `lazyConnect` so constructing the store never blocks start-up.
  //
  // THE OFFLINE QUEUE STAYS ON, and the first draft had it off. With it off, the
  // very first command is rejected because the lazy connection has not been
  // established yet — so the first request an api instance ever serves reports no
  // count, degrades, and looks like a Redis outage. The integration suite caught
  // it as `expected null to be '599'` on the first test and three passes after
  // it.
  //
  // Failing fast on a store that is genuinely down is then `maxRetriesPerRequest:
  // 0` and a short `connectTimeout`: a queued command rejects as soon as the
  // connection attempt fails rather than waiting out a retry schedule. A limiter
  // that waits is worse than one that does not count, because the request it is
  // holding is a customer's.
  const redis = new Redis(url, {
    lazyConnect: true,
    maxRetriesPerRequest: 0,
    connectTimeout: 1_000,
  });
 
  // FAILING OPEN IS NOT FREE IF IT FAILS SLOWLY, and the first version of this
  // file was slow. With the store gone, every command waits out its connect
  // timeout before giving up — so each request paid a second or more, twice,
  // and the integration test for the degraded path timed out rather than
  // asserting anything.
  //
  // That is worse than it looks. The tenant limiter fails open so a cache outage
  // does not refuse paid traffic; an outage that instead adds seconds to every
  // request has refused it in a slower way, and NFR-PRF-02 asks for a p95 under
  // 150 ms.
  //
  // So a known-down store is not retried on the request path. The first failure
  // opens a window; while it is open every call answers `null` immediately, which
  // is the same signal the caller already handles. One probe per window is what
  // notices the store coming back.
  const DOWN_WINDOW_MS = 5_000;
  let downUntil = 0;
 
  const guard = async <T>(op: () => Promise<T>): Promise<T | null> => {
    if (Date.now() < downUntil) return null;
    try {
      const result = await op();
      downUntil = 0;
      return result;
    } catch {
      downUntil = Date.now() + DOWN_WINDOW_MS;
      return null;
    }
  };
  // A dead store is an expected state here, not an exception. Without a listener
  // ioredis emits `error` on an EventEmitter with none attached, which Node turns
  // into an unhandled exception and the api dies for the thing it was designed to
  // survive.
  redis.on("error", () => {});
 
  return {
    async increment(key, nowMs) {
      void nowMs;
      return guard(async () => {
        const count = await redis.incr(key);
        if (count === 1) {
          await redis.pexpire(key, WINDOW_MS);
        }
        return count;
      });
    },
 
    async get(key) {
      return guard(async () => {
        const raw = await redis.get(key);
        return raw === null ? 0 : Number.parseInt(raw, 10);
      });
    },
 
    async close() {
      redis.disconnect();
    },
  };
}
services/api/src/limits/client-address.ts
import type { RequestWithPrincipal } from "../auth/principal";
 
// Whose failure was that? (chapter 3.8, FR-AUT-12, research R14.)
//
// THE API SEES THE GATEWAY, not the client. A WebSocket handshake is
// authenticated by the gateway forwarding the end user's token to
// `/internal/session`, so the TCP peer is the gateway for every customer at
// once. Counting the peer would put every customer's failed handshakes in one
// bucket, and one attacker would exhaust a threshold that then refused
// everybody.
//
// A FIELD ON THE INTERNAL CONTRACT, NOT A HEADER. A header the caller asserts is
// a header the caller can forge — the exact pattern chapter 3.2 removed when it
// retired the two identity headers the gateway used to send. This one is
// accepted only from a caller already trusted enough to reach the internal
// routes, and it is trusted for exactly one thing: naming who was on the other
// end. The same request is trusted enough not to be throttled and not trusted to
// be the origin.
 
/** The field the gateway sets on its internal calls. Read from the parsed body
 * rather than a header, so an ordinary customer cannot set it. */
export const CLIENT_ADDRESS_FIELD = "client_address";
 
export function clientAddress(
  req: RequestWithPrincipal & {
    socket?: { remoteAddress?: string | undefined };
    body?: unknown;
  },
): string {
  const body = req.body;
  if (typeof body === "object" && body !== null) {
    const forwarded = (body as Record<string, unknown>)[CLIENT_ADDRESS_FIELD];
    if (typeof forwarded === "string" && forwarded.length > 0) {
      return forwarded;
    }
  }
  return req.socket?.remoteAddress ?? "unknown";
}
services/api/src/limits/auth-limiter.ts
import { Inject, Injectable } from "@nestjs/common";
import type { Logger } from "@relay/service-kit";
 
import { LOGGER } from "../logger";
import { windowStart } from "./bucket";
import { createFallbackCounter } from "./fallback";
import { COUNTER_STORE } from "./limits.module";
import { authFailureThreshold, WINDOW_MS } from "./policy";
import { authKey, type CounterStore } from "./store";
 
// The limiter that counts FAILED AUTHENTICATIONS by source address
// (chapter 3.8, FR-AUT-12, research R3).
//
// THE ONE THAT MUST NOT FAIL OPEN, and that is the chapter's whole argument. The
// tenant limiter serves the request when Redis is gone, because Redis is not a
// source of truth and a cache outage is not a reason to refuse paid traffic. Run
// the same reasoning here and it gives the opposite answer: an unlimited window
// on failed logins is not a degradation, it is a hole.
//
// FAILING CLOSED IS NOT THE ANSWER EITHER — it turns a Redis restart into an
// authentication outage, which is worse than the attack it prevents for every
// customer who is not being attacked. So: an in-process fallback at the same
// threshold, weakening the guarantee from N per window across the fleet to N per
// window per instance. A small multiple rather than infinity.
//
// WHOSE ADDRESS. The client's, not the caller's. A handshake authenticated
// through the gateway reaches the api FROM the gateway, so counting the caller
// would put every customer's failures in one bucket and let one attacker exhaust
// a threshold that then refuses everybody (research R14).
 
/** Bounded, and the bound is the decision rather than a detail: this map is keyed
 * by attacker-controlled input, so unbounded it would be a memory-exhaustion
 * vector — a fallback that closed a brute-force hole by opening a worse one. */
const FALLBACK_MAX_KEYS = 10_000;
 
@Injectable()
export class AuthLimiter {
  private readonly fallback = createFallbackCounter({
    windowMs: WINDOW_MS,
    maxKeys: FALLBACK_MAX_KEYS,
  });
 
  private lastDegradationLog = 0;
 
  constructor(
    @Inject(COUNTER_STORE) private readonly store: CounterStore,
    @Inject(LOGGER) private readonly logger: Logger,
  ) {}
 
  /** Count one failed authentication. */
  async recordFailure(address: string): Promise<void> {
    const now = Date.now();
    const key = authKey(address, windowStart(now, WINDOW_MS));
    if ((await this.store.increment(key, now)) === null) {
      this.degradation();
      this.fallback.increment(address, now);
    }
  }
 
  /** Has this address already spent its allowance?
   *
   * READS WITHOUT COUNTING. A check that also writes would refuse on its own
   * questions, and this one runs on every request that presents a credential —
   * including the valid ones.
   *
   * When the shared store is unreachable it answers from the in-process count,
   * which is the whole point: the guarantee gets weaker, not absent. A key the
   * fallback could not admit answers `true` — refusing an address we cannot track
   * is the safe direction while degraded, and the cap makes that a bounded
   * population rather than everybody. */
  async isOverThreshold(address: string): Promise<boolean> {
    const now = Date.now();
    const threshold = authFailureThreshold();
    const shared = await this.store.get(
      authKey(address, windowStart(now, WINDOW_MS)),
    );
    if (shared !== null) return shared >= threshold;
 
    this.degradation();
    const local = this.fallback.peek(address, now);
    return local === null ? true : local >= threshold;
  }
 
  /** One line, rate limited at the logger. A Redis outage under load would
   * otherwise emit one per attempt, which is how one outage becomes two. No
   * credential and no address (NFR-SEC-06); the count of tracked addresses is
   * what an operator actually needs. */
  private degradation(): void {
    const now = Date.now();
    if (now - this.lastDegradationLog < 10_000) return;
    this.lastDegradationLog = now;
    this.logger.log("error", "limits.auth_degraded", {
      detail:
        "counter store unreachable; counting failed authentications in process",
      tracked: this.fallback.size(),
    });
  }
}
services/api/src/limits/rate-limit.middleware.ts
import type { IncomingMessage, ServerResponse } from "node:http";
 
import { Inject, Injectable, type NestMiddleware } from "@nestjs/common";
import type { Logger } from "@relay/service-kit";
 
import type { Db } from "../db/client";
import { environmentLimits } from "../db/repository";
import type { RequestWithPrincipal } from "../auth/principal";
import { LOGGER } from "../logger";
import { remaining, resetAt, windowStart } from "./bucket";
import { clientAddress } from "./client-address";
import { COUNTER_STORE, LIMITS_DB } from "./limits.module";
import { authFailureThreshold, WINDOW_MS, type LimitedOperation } from "./policy";
import { authKey, counterKey, type CounterStore } from "./store";
 
/** Read once per call site so a test that freezes time sees one instant. */
const now0 = (): number => Date.now();
 
// The tenant limiter (chapter 3.8, FR-RTL-01…04).
//
// MIDDLEWARE, NOT A GUARD, for two reasons. Chapter 3.2's: Nest constructs
// request-scoped providers before the enhancer chain, so a guard cannot be the
// thing that resolves tenant scope. And one of its own: FR-RTL-02 wants the three
// headers on SUCCESSFUL responses, and a guard that returns `true` has no natural
// place to set a header on a response the handler has not produced yet.
//
// AFTER `AuthenticateMiddleware`, and the order is forced: the counters are keyed
// by environment and the environment comes from the credential.
//
// COUNT EACH OPERATION ONCE, AT THE DOOR IT ENTERED (research R17). The exemption
// cannot key off the principal, because the gateway forwards the END USER's token
// on all three of its api calls — `/internal/session`, `/internal/backfill`,
// `/internal/messages` are all `@Accepts("user")` and resolve exactly like
// customer traffic. Only the dispatcher carries the platform credential. So the
// route decides, not the caller:
//
//   /v1/…            counted. A message send decrements both budgets (FR-RTL-01).
//   /internal/…      not counted. The gateway already counted the handshake
//                    against `connect` and the frame against `send`; counting
//                    again here would charge the socket twice and make a
//                    reconnect storm eat a customer's request budget.
//   /healthz         never limited. Docker polls it every five seconds and
//                    `up -d --wait` depends on the answer; a limiter that can
//                    refuse it can stop a deployment.
 
const PUBLIC_PREFIX = "/v1/";
const SEND_PATH = /^\/v1\/channels\/[^/]+\/messages\/?$/;
/** Account creation (FR-AUT-12). Limited per SOURCE ADDRESS, because it has no
 * tenant to key on — that is the point of it — and an unlimited
 * account-creation route is not acceptable in a platform that limits everything
 * else. It also has no guard, so T027a's refusal cannot reach it. */
const SIGNUP_PATH = /^\/auth\/[^/]+\/(start|callback)\/?$/;
 
interface Decision {
  operation: LimitedOperation;
  limit: number;
  remaining: number;
  resetSeconds: number;
  refused: boolean;
  counted: boolean;
}
 
/** Which budgets a path spends. Empty means the route is not counted at all. */
export function operationsFor(
  method: string,
  path: string,
): LimitedOperation[] {
  if (!path.startsWith(PUBLIC_PREFIX)) return [];
  if (method === "POST" && SEND_PATH.test(path)) return ["rest", "send"];
  return ["rest"];
}
 
@Injectable()
export class RateLimitMiddleware implements NestMiddleware {
  constructor(
    @Inject(LIMITS_DB) private readonly db: Db,
    @Inject(COUNTER_STORE) private readonly store: CounterStore,
    @Inject(LOGGER) private readonly logger: Logger,
  ) {}
 
  async use(
    req: RequestWithPrincipal & IncomingMessage,
    res: ServerResponse,
    next: () => void,
  ): Promise<void> {
    // `originalUrl`, NOT `url`. Express rewrites `req.url` relative to the mount
    // point, and a middleware applied through `forRoutes("{*path}")` is mounted
    // at the match — so `req.url` is `/` for every request and the route rules
    // below would never match anything. Found by probe at implementation, and
    // the same read is why the request log recorded `/` for every request from
    // chapter 2.2 until this chapter fixed it.
    const raw =
      (req as unknown as { originalUrl?: string }).originalUrl ?? req.url ?? "/";
    const path = raw.split("?")[0] ?? "/";
    const operations = operationsFor(req.method ?? "GET", path);
    const principal = req.principal;
 
    // Account creation first: no tenant, no guard, so it is neither counted like
    // customer traffic nor refusable by `CredentialGuard`. Same counter family
    // and same threshold as failed authentication (FR-AUT-12, research R17).
    if (SIGNUP_PATH.test(path)) {
      const address = clientAddress(req);
      const count = await this.store.increment(
        authKey(address, windowStart(now0(), WINDOW_MS)) + ":signup",
        now0(),
      );
      if (count !== null && count > authFailureThreshold()) {
        res.statusCode = 429;
        res.setHeader("Retry-After", "60");
        res.setHeader("content-type", "application/json");
        res.end(
          JSON.stringify({
            code: "rate_limited",
            message: "too many sign-up attempts from this address; retry shortly",
            docs_url: "https://relay.example/docs/errors/rate_limited",
            request_id: String(res.getHeader("X-Request-Id") ?? ""),
          }),
        );
        return;
      }
      next();
      return;
    }
 
    // An environment to key on, or nothing to do. A platform principal has none
    // by construction — the dispatcher's credential belongs to a deployment, not
    // a tenant — and an absent principal means the guard is about to refuse this
    // or the route is pre-credential.
    const environmentId =
      principal !== undefined && "environmentId" in principal
        ? principal.environmentId
        : undefined;
    if (operations.length === 0 || environmentId === undefined) {
      next();
      return;
    }
 
    const limits = await environmentLimits(this.db, environmentId);
    if (limits === null) {
      next();
      return;
    }
 
    const now = Date.now();
    const start = windowStart(now, WINDOW_MS);
    const resetSeconds = Math.ceil(resetAt(now, WINDOW_MS) / 1000);
    const decisions: Decision[] = [];
 
    for (const operation of operations) {
      const limit = limits[operation];
      const count = await this.store.increment(
        counterKey(environmentId, operation, start),
        now,
      );
      decisions.push({
        operation,
        limit,
        remaining: count === null ? limit : remaining(count, limit),
        resetSeconds,
        refused: count !== null && count > limit,
        counted: count !== null,
      });
    }
 
    const refusal = decisions.find((d) => d.refused);
 
    // THE HEADERS DESCRIBE WHICHEVER HAS FEWER REMAINING, because that is the one
    // that will refuse first and the only value a client can schedule against. A
    // client with 400 request-slots and 12 send-slots needs to hear 12; reporting
    // 400 would be a header that lies by omission. A tie reports the first, which
    // is `rest` (research R11).
    //
    // EXCEPT WHEN ONE OF THEM ACTUALLY REFUSED, and that exception was found by
    // capturing the transcript rather than by a test. Both budgets can reach
    // zero remaining in the same request while only one of them is over: with
    // `rest` at 3 and `send` at 2, the third send leaves both at zero remaining
    // and only `send` refused. "Fewest remaining" then picks `rest` on the tie,
    // and the response says `X-RateLimit-Limit: 3` above a body reading "too many
    // messages" — two numbers describing different budgets in one refusal
    // (research R41). A refusal describes the budget that refused.
    const nearest =
      refusal ?? decisions.reduce((a, b) => (b.remaining < a.remaining ? b : a));
    const degraded = decisions.some((d) => !d.counted);
 
    res.setHeader("X-RateLimit-Limit", String(nearest.limit));
    if (degraded) {
      // `Limit` only. It is policy read from Postgres and is not degraded; the
      // other two exist only because something was counting, and inventing them
      // is the failure FR-RTL-02 forbids. NOT a sentinel — a client that does not
      // know `-1` would parse it as a number and conclude it was over its limit
      // (research R6).
      this.degradation(environmentId, req);
    } else {
      res.setHeader("X-RateLimit-Remaining", String(nearest.remaining));
      res.setHeader("X-RateLimit-Reset", String(nearest.resetSeconds));
    }
 
    if (refusal !== undefined) {
      const retryAfter = Math.max(1, refusal.resetSeconds - Math.floor(now / 1000));
      res.setHeader("Retry-After", String(retryAfter));
      res.setHeader("X-RateLimit-Remaining", "0");
      // The message names WHICH limit was reached: "too many requests" and "too
      // many messages" are different problems, one saying batch and the other
      // saying slow down. Neither names a credential (NFR-SEC-06).
      const what =
        refusal.operation === "send" ? "messages" : "requests";
      res.statusCode = 429;
      res.setHeader("content-type", "application/json");
      res.end(
        JSON.stringify({
          code: "rate_limited",
          message: `too many ${what} for this environment; retry after ${retryAfter} seconds`,
          docs_url: "https://relay.example/docs/errors/rate_limited",
          request_id: String(res.getHeader("X-Request-Id") ?? ""),
        }),
      );
      return;
    }
 
    next();
  }
 
  private lastDegradationLog = 0;
 
  /** One line, rate limited at the logger. A Redis outage under load would
   * otherwise emit one per request, which is how one outage becomes two. Carries
   * the request id and the environment — NFR-OBS-01 asks for request id, tenant
   * id and correlation id, and the platform mints no correlation id yet. Carries
   * no credential (NFR-SEC-06). */
  private degradation(environmentId: string, req: IncomingMessage): void {
    const now = Date.now();
    if (now - this.lastDegradationLog < 10_000) return;
    this.lastDegradationLog = now;
    void req;
    // `error`, not `info`: the limiter is doing the right thing by serving, and
    // an unreachable store is still an operational fault somebody should see.
    // The logger's two levels are the service-kit's, unchanged since 1.4.
    this.logger.log("error", "limits.degraded", {
      environment_id: environmentId,
      detail: "counter store unreachable; serving without counting",
    });
  }
}
services/gateway/src/limits.ts
import { Redis } from "ioredis";
 
// The gateway's counter (chapter 3.8, research R12, R20).
//
// ITS OWN CLIENT, not fanout's, and that is forced rather than preferred.
// `Fanout` is a closed interface — `onDelivery`, `publish`, `subscribe`,
// `unsubscribe`, `close` — and exposes neither of the two clients it holds. One
// of them is a SUBSCRIBER, and a Redis connection in subscribe mode cannot run
// `INCR`. And `fanout` is optional in the session server, so a limiter riding its
// lifecycle would vanish in every configuration that has no fabric — which is
// every chapter-2.5 test.
//
// So: one more client, and a `close()` the session server calls. `fanout.ts`
// already sets that precedent for this service.
//
// THE SAME KEYS THE API USES. Two services increment one bucket, which is why
// the counter lives in Redis rather than in either process: neither can see the
// other's memory, and a socket send has to count against the same `send` budget a
// REST send does or a client could double its allowance by opening a socket
// (research R11).
 
export const DEFAULT_REDIS_URL = "redis://localhost:6379";
 
/** The window an instant belongs to. Floored, so two instances agree without
 * coordinating — the same arithmetic as the api's, deliberately duplicated
 * rather than shared: a package for two small functions would be an abstraction
 * constitution VII asks to be justified, and this one could not be. */
export function windowStartFor(nowMs: number, windowMs: number): number {
  return Math.floor(nowMs / windowMs) * windowMs;
}
 
/** Is this count past the allowance?
 *
 * `null` — the store could not be reached — is NOT over. Both of the gateway's
 * limits are tenant limits, so they fail open like the api's: Redis is not a
 * source of truth, and a cache outage is not a reason to refuse a paying
 * customer's traffic. */
export function overLimit(count: number | null, limit: number): boolean {
  if (count === null) return false;
  return count > limit;
}
 
/** What one counted operation decided, and everything a refusal has to say.
 *
 * The api reports the same four numbers in three headers plus `Retry-After`;
 * the gateway needs them for the handshake refusal, which IS an HTTP response
 * and can carry headers. The frame refusal cannot — there is nowhere on an
 * `error` frame to put them — which is why the socket's two refusals do not
 * look alike (research R7). */
export interface Decision {
  over: boolean;
  limit: number;
  remaining: number;
  /** Unix seconds, matching `X-RateLimit-Reset`. */
  resetSeconds: number;
  /** Whole seconds until the window turns over, for `Retry-After`. At least 1:
   * `Retry-After: 0` invites an immediate retry that is certain to fail. */
  retryAfterSeconds: number;
}
 
/** The arithmetic, with no store in it — so a window boundary is a test rather
 * than a wait. A `null` count means the store could not be reached. */
export function decide(
  count: number | null,
  limit: number,
  nowMs: number,
  windowMs: number,
): Decision {
  const reset = windowStartFor(nowMs, windowMs) + windowMs;
  return {
    over: overLimit(count, limit),
    limit,
    remaining: Math.max(0, limit - (count ?? 0)),
    resetSeconds: Math.ceil(reset / 1_000),
    retryAfterSeconds: Math.max(1, Math.ceil((reset - nowMs) / 1_000)),
  };
}
 
export interface GatewayLimits {
  /** Count one operation and report what that decided. */
  spend(
    environmentId: string,
    operation: "connect" | "send",
    limit: number,
  ): Promise<Decision>;
  close(): Promise<void>;
}
 
const WINDOW_MS = 60_000;
const DOWN_WINDOW_MS = 5_000;
 
export function createGatewayLimits(
  url: string = process.env["RELAY_REDIS_URL"] ?? DEFAULT_REDIS_URL,
): GatewayLimits {
  const redis = new Redis(url, {
    lazyConnect: true,
    maxRetriesPerRequest: 0,
    connectTimeout: 1_000,
  });
  // A dead store is an expected state, not an exception. Without a listener
  // ioredis emits `error` on an EventEmitter with none attached and Node turns
  // that into an unhandled exception — the gateway would die for the thing it is
  // designed to survive.
  redis.on("error", () => {});
 
  // A known-down store is not retried on the connect path. Waiting out a connect
  // timeout per handshake would turn a cache outage into a slow one, and
  // NFR-PRF-04 asks for a handshake under a second (research R34).
  let downUntil = 0;
 
  return {
    async spend(environmentId, operation, limit) {
      const now = Date.now();
      if (now < downUntil) return decide(null, limit, now, WINDOW_MS);
      const key = `rl:${environmentId}:${operation}:${windowStartFor(now, WINDOW_MS)}`;
      try {
        const count = await redis.incr(key);
        if (count === 1) await redis.pexpire(key, WINDOW_MS);
        downUntil = 0;
        return decide(count, limit, now, WINDOW_MS);
      } catch {
        downUntil = now + DOWN_WINDOW_MS;
        return decide(null, limit, now, WINDOW_MS);
      }
    },
 
    async close() {
      redis.disconnect();
    },
  };
}

Bộ từ vựng, cuối cùng cũng được nói ra

rate_limited và trường thứ tư. Ba trong bốn file này được viết từ Phần 1 và đã chờ từ bấy đến giờ.

packages/protocol/src/frames.ts
@@ -102,14 +102,26 @@ export const typingSchema = z.strictObject({
 });
 
 /** Protocol-level error — EIR-API-04's error shape, reused on the socket
- * (this chapter's recorded decision). `request_id` joins in Part 2, when a
- * gateway exists to mint one. */
+ * (chapter 1.3's recorded decision).
+ *
+ * `request_id` ARRIVED IN CHAPTER 3.8, not in Part 2. The comment here promised
+ * it "joins in Part 2, when a gateway exists to mint one"; Part 2 came and went,
+ * the gateway existed, and the field did not. Constitution V asks for four fields
+ * and the platform sent three for twenty-two chapters.
+ *
+ * REQUIRED, not optional, and that was a decision rather than an oversight. A
+ * server-initiated frame is arguably not a response to a request, so optional
+ * would have been defensible — and it would have been the fourth instance of the
+ * habit this chapter is about: `rate_limited`, close code 4008 and this field
+ * were all declared here and left unenforced. The gateway mints one per answered
+ * frame instead (research R13). */
 export const errorFrameSchema = z.strictObject({
   type: z.literal("error"),
   payload: z.strictObject({
     code: z.string().min(1),
     message: z.string().min(1),
     docs_url: z.string().min(1),
+    request_id: z.string().min(1),
     field: z.string().min(1).optional(),
   }),
 });
packages/protocol/src/frames.test.ts
@@ -43,6 +43,11 @@ const valid: Record<string, unknown> = {
       code: "invalid_frame",
       message: "no",
       docs_url: "https://docs.example/errors/invalid_frame",
+      // Chapter 3.8: the fourth field, required rather than optional. The
+      // comment above this schema promised it "joins in Part 2, when a gateway
+      // exists to mint one" — Part 2 came and went, and constitution V has asked
+      // for four fields since 1.3.
+      request_id: "01JABCDEFGHJKMNPQRSTVWXYZ",
     },
   },
 };
packages/service-kit/src/index.ts
@@ -83,6 +83,10 @@ export function serve(options: ServeOptions): Server {
         code: "not_found",
         message: `no route for ${req.method ?? "?"} ${path}`,
         docs_url: "https://relay.example/docs/errors/not_found",
+        // Chapter 3.8: the fourth field constitution V has asked for since 1.3.
+        // Everywhere, not only on the rate-limit error — four fields on one
+        // status and three on the others is worse than either consistent answer.
+        request_id: requestId,
       };
     }
     res.statusCode = status;
services/api/src/protocol-error.filter.ts
@@ -54,11 +54,24 @@ export class ProtocolErrorFilter implements ExceptionFilter {
         : "unexpected internal error";
     res.statusCode = status;
     res.setHeader("content-type", "application/json");
+    // FOUR FIELDS AS OF CHAPTER 3.8, and constitution V has asked for four since
+    // chapter 1.3. `request_id` was promised "in Part 2, when a gateway exists to
+    // mint one"; the gateway arrived and the field did not. It is read back off
+    // the response rather than threaded through, because `RequestContextMiddleware`
+    // has already set `X-Request-Id` by the time anything can throw — one id, in
+    // the header and the body, from one place.
+    //
+    // TOP-LEVEL, NOT NESTED. EIR-API-04's worked example wrapped these in an
+    // `error` key until this chapter checked what the platform actually sends;
+    // it never sent that shape. Wrapping every error response would be a breaking
+    // change and CON-05 makes breaking changes a URL-versioning event, so the
+    // document was brought to the code — SRS 1.3 (research R27).
     res.end(
       JSON.stringify({
         code,
         message,
         docs_url: `https://relay.example/docs/errors/${code}`,
+        request_id: String(res.getHeader("X-Request-Id") ?? ""),
       }),
     );
   }

Đường đi của request

Chuỗi xử lý, principal mà nó mang theo, và policy mà nó đọc.

schema.ts cũng mất đi ba con số. Chương 3.7 viết một dòng comment giải thích rằng một số hiệu chương nằm trong comment mã nguồn là một tham chiếu sẽ già đi — và chứng minh điều đó bằng cách liệt kê những thứ tự mà trường đấu cách ly tenant đã đi qua. Kế hoạch lại dịch chuyển trong lúc chương này đang được viết, và lời giải thích ấy hoá cũ ngay trên chính chủ đề của nó. Giờ nó không gọi tên con số nào.

services/api/src/request-context.middleware.ts
@@ -17,11 +17,22 @@ export class RequestContextMiddleware implements NestMiddleware {
   use(req: IncomingMessage, res: ServerResponse, next: () => void): void {
     const requestId = newRequestId();
     res.setHeader("X-Request-Id", requestId);
+    // `originalUrl` first, and this line was WRONG from chapter 2.2 until 3.8.
+    // Express rewrites `req.url` relative to the mount point, and this middleware
+    // is applied through `forRoutes("{*path}")`, so `req.url` is `/` — every
+    // request this api has logged recorded `/` as its path. NFR-OBS-06 asks for
+    // one structured line per request that an operator can grep; a line whose
+    // path is always `/` is one they cannot.
+    //
+    // Found by probe while wiring the rate limiter, which reads the same value
+    // to decide which routes it counts and would have counted nothing.
+    const path =
+      (req as { originalUrl?: string }).originalUrl ?? req.url ?? "/";
     res.on("finish", () => {
       this.logger.log("info", "request", {
         request_id: requestId,
         method: req.method,
-        path: req.url,
+        path,
         status: res.statusCode,
       });
     });
services/api/src/auth/principal.ts
@@ -61,9 +61,23 @@ export type PrincipalKind = Principal["kind"];
  * principal is optional at the type level for one honest reason: a request that
  * presented nothing has none, and pre-credential routes (signup) are reached
  * exactly that way. */
+/** Chapter 3.8. Set by `AuthenticateMiddleware` when this address has already
+ * spent its failed-authentication allowance, and read by `CredentialGuard`,
+ * which throws the 429.
+ *
+ * THE MIDDLEWARE NEVER THROWS, by documented design — pre-credential routes
+ * reach their handlers by having no principal — so the refusal has to be raised
+ * somewhere that already refuses. The guard owns the 401 that EIR-API-04 wants this
+ * indistinguishable from, and it already throws the object form that carries a
+ * `code` (research R18). */
+export const OVER_AUTH_THRESHOLD = Symbol.for("relay:over-auth-threshold");
+
 export interface RequestWithPrincipal {
   headers: Record<string, string | string[] | undefined>;
   principal?: Principal;
+  /** Chapter 3.8: set when this source address has spent its
+   * failed-authentication allowance. See `OVER_AUTH_THRESHOLD` above. */
+  [OVER_AUTH_THRESHOLD]?: boolean;
 }
 
 /** How a credential class is named to a human. Used by the wrong-credential
services/api/src/auth/authenticate.middleware.ts
@@ -6,7 +6,14 @@ import {
   environmentSigningSecret,
 } from "../db/repository";
 import { looksLikeApiKey } from "./api-key";
-import { bearerCredential, type Principal, type RequestWithPrincipal } from "./principal";
+import { AuthLimiter } from "../limits/auth-limiter";
+import { clientAddress } from "../limits/client-address";
+import {
+  bearerCredential,
+  OVER_AUTH_THRESHOLD,
+  type Principal,
+  type RequestWithPrincipal,
+} from "./principal";
 import { environmentClaim, verifyUserToken } from "./user-token";
 
 export const AUTH_DB = "AUTH_DB";
@@ -106,17 +113,32 @@ export async function resolvePrincipal(
  */
 @Injectable()
 export class AuthenticateMiddleware implements NestMiddleware {
-  constructor(@Inject(AUTH_DB) private readonly db: Db) {}
+  constructor(
+    @Inject(AUTH_DB) private readonly db: Db,
+    private readonly authLimiter: AuthLimiter,
+  ) {}
 
   async use(
-    req: RequestWithPrincipal,
+    req: RequestWithPrincipal & { socket?: { remoteAddress?: string } },
     _res: unknown,
     next: () => void,
   ): Promise<void> {
     const credential = bearerCredential(req.headers);
     if (credential !== null) {
+      // Chapter 3.8 (FR-AUT-12). The failure is observable HERE — credential
+      // present, principal null — so this is where it is counted. It is not where
+      // it is refused: this middleware never throws, and `CredentialGuard` raises
+      // the 429 from the flag below (research R18).
+      const address = clientAddress(req);
+      if (await this.authLimiter.isOverThreshold(address)) {
+        req[OVER_AUTH_THRESHOLD] = true;
+      }
       const principal = await resolvePrincipal(this.db, credential);
-      if (principal !== null) req.principal = principal;
+      if (principal !== null) {
+        req.principal = principal;
+      } else {
+        await this.authLimiter.recordFailure(address);
+      }
     }
     next();
   }
services/api/src/auth/credential.guard.ts
@@ -1,5 +1,6 @@
 import {
   ForbiddenException,
+  HttpException,
   Injectable,
   SetMetadata,
   UnauthorizedException,
@@ -10,6 +11,7 @@ import { Reflector } from "@nestjs/core";
 
 import {
   describePrincipalKind,
+  OVER_AUTH_THRESHOLD,
   type PrincipalKind,
   type RequestWithPrincipal,
 } from "./principal";
@@ -58,6 +60,30 @@ export class CredentialGuard implements CanActivate {
     const req = context.switchToHttp().getRequest<RequestWithPrincipal>();
     const principal = req.principal;
 
+    // Chapter 3.8 (FR-AUT-12, FR-RTL-02, research R18). The refusal for an
+    // over-threshold address is thrown HERE and not in the middleware that
+    // counted it, because `AuthenticateMiddleware` never throws by documented
+    // design — pre-credential routes reach their handlers by having no principal.
+    //
+    // Three things fall out of putting it here. The invariant survives verbatim.
+    // Both refusals come from one place, which is what EIR-API-04 needs: a caller
+    // must not be able to tell a rate-limited refusal from a wrong-credential
+    // one, or the limiter becomes an oracle. And the guard already throws the
+    // object form that carries a `code`, which is what the envelope needs.
+    //
+    // BEFORE the principal check, so an address over its allowance is refused
+    // whether or not the credential it just presented would have worked.
+    if (req[OVER_AUTH_THRESHOLD] === true) {
+      throw new HttpException(
+        {
+          code: "rate_limited",
+          message:
+            "too many failed authentication attempts from this address; retry shortly",
+        },
+        429,
+      );
+    }
+
     if (!principal) {
       throw new UnauthorizedException(
         `this route requires a credential: ${expectation(accepted)}, presented as "Authorization: Bearer …"`,
services/api/src/auth/auth.module.ts
@@ -1,6 +1,8 @@
 import { Module } from "@nestjs/common";
 
 import { createDb, createPool, type Db } from "../db/client";
+import { LimitsModule } from "../limits/limits.module";
+import { AuthLimiter } from "../limits/auth-limiter";
 import { AUTH_DB, AuthenticateMiddleware } from "./authenticate.middleware";
 import { CredentialGuard } from "./credential.guard";
 import { DevTokenController } from "./dev-token.controller";
@@ -15,12 +17,17 @@ import { DevTokenController } from "./dev-token.controller";
 // runs BEFORE any tenant scope exists, and borrowing the request-scoped
 // machinery 2.2 built would invert the order it needs.
 @Module({
+  // Chapter 3.8: the failed-authentication counter. Imported rather than built
+  // here, because the counter store is one client with one lifecycle and two
+  // consumers — this module and the tenant limiter's middleware.
+  imports: [LimitsModule],
   controllers: [DevTokenController],
   providers: [
     { provide: AUTH_DB, useFactory: (): Db => createDb(createPool()) },
+    AuthLimiter,
     AuthenticateMiddleware,
     CredentialGuard,
   ],
-  exports: [AUTH_DB, AuthenticateMiddleware, CredentialGuard],
+  exports: [AUTH_DB, AuthenticateMiddleware, CredentialGuard, AuthLimiter],
 })
 export class AuthModule {}
services/api/src/app.module.ts
@@ -11,11 +11,14 @@ import { HealthController } from "./health.controller";
 import { InternalModule } from "./internal/internal.module";
 import { MessagesModule } from "./messages/messages.module";
 import { ConsumerModule } from "./consumer/consumer.module";
+import { NotificationsModule } from "./notifications/notifications.module";
 import { OutboxModule } from "./outbox/outbox.module";
 import { WebhooksModule } from "./webhooks/webhooks.module";
 import { TenancyModule } from "./tenancy/tenancy.module";
 import { LOGGER, apiLogger } from "./logger";
 import { ProtocolErrorFilter } from "./protocol-error.filter";
+import { LimitsModule } from "./limits/limits.module";
+import { RateLimitMiddleware } from "./limits/rate-limit.middleware";
 import { RequestContextMiddleware } from "./request-context.middleware";
 
 // The application described as a module graph — ADR-15's convention for the
@@ -29,24 +32,30 @@ import { RequestContextMiddleware } from "./request-context.middleware";
     InternalModule,
     TenancyModule,
     OutboxModule,
+    NotificationsModule,
     ConsumerModule,
     WebhooksModule,
+    LimitsModule,
   ],
   controllers: [HealthController],
   providers: [
     { provide: LOGGER, useFactory: apiLogger },
     { provide: APP_FILTER, useClass: ProtocolErrorFilter },
     RequestContextMiddleware,
+    RateLimitMiddleware,
   ],
 })
 export class AppModule implements NestModule {
   configure(consumer: MiddlewareConsumer): void {
-    // Order is the chain: the request gets its id first, then its principal.
+    // Order is the chain: the request gets its id first, then its principal,
+    // then its allowance. The limiter is LAST and that is forced (chapter 3.8):
+    // it counts per environment and the environment comes from the credential,
+    // so nothing earlier in the chain knows which tenant is asking.
     // Chapter 3.2 put authentication HERE rather than in a guard because Nest
     // constructs request-scoped providers before the enhancer chain runs — the
     // finding 2.6 paid for, measured again on this path in T004.
     consumer
-      .apply(RequestContextMiddleware, AuthenticateMiddleware)
+      .apply(RequestContextMiddleware, AuthenticateMiddleware, RateLimitMiddleware)
       .forRoutes("{*path}");
   }
 }
services/api/src/db/schema.ts
@@ -120,7 +120,33 @@ export const environments = pgTable(
     // envelope-encrypted (NFR-SEC-02)
     signingSecret: text("signing_secret").notNull(),
     retentionDays: integer("retention_days"),
+    // DECLARED IN 2.1 AND STILL EMPTY. Named in SRS §6.1's Environment entity
+    // and SAD §338, read by nothing in seventeen chapters. Chapter 3.8
+    // deliberately did NOT put rate-limit policy here: the column is named for
+    // quotas, quotas are a later chapter, and the distinction between a limit
+    // that may be lost and a quota that is money is the thing 3.8 is about.
+    // (Deliberately not a chapter NUMBER: 3.7 renumbered quotas once already,
+    // and a comment in a file fenced byte-exact into a published page goes stale
+    // silently. Chapter 3.7's rule — cite what a thing is, never where it will
+    // be. A grep for forward references is the gate, so this comment must not
+    // trip it either.) Putting
+    // one in a field named for the other would collapse in the schema what the
+    // prose spends a chapter drawing (research R31).
     quotaConfig: jsonb("quota_config").notNull().default({}),
+    // Chapter 3.8: per-environment rate limits (FR-RTL-04, FR-RTL-04).
+    //
+    // NULLABLE, AND NULL IS NOT ZERO. Null means "no override, use the
+    // documented default", resolved at read time. Zero means "refuse
+    // everything", which must stay expressible — an environment can be switched
+    // off deliberately — so the two states cannot share a representation.
+    //
+    // Three integers rather than a document, and a slot for an environment with
+    // NONE FOR A ROUTE. That forecloses SRS Appendix C question 5 — whether the
+    // dev-token endpoint should be limited more aggressively than the rest of
+    // its environment — and the question stays open because of it (R30).
+    restLimitPerMinute: integer("rest_limit_per_minute"),
+    sendLimitPerMinute: integer("send_limit_per_minute"),
+    connectLimitPerMinute: integer("connect_limit_per_minute"),
   },
   (t) => [
     check(
@@ -131,6 +157,18 @@ export const environments = pgTable(
     // above, this unique index IS that rule: two legal kinds, one row each.
     // No trigger, no counting query, nothing to lose a race to.
     unique("environments_application_kind_unique").on(t.applicationId, t.kind),
+    check(
+      "environments_rest_limit_non_negative",
+      sql`${t.restLimitPerMinute} IS NULL OR ${t.restLimitPerMinute} >= 0`,
+    ),
+    check(
+      "environments_send_limit_non_negative",
+      sql`${t.sendLimitPerMinute} IS NULL OR ${t.sendLimitPerMinute} >= 0`,
+    ),
+    check(
+      "environments_connect_limit_non_negative",
+      sql`${t.connectLimitPerMinute} IS NULL OR ${t.connectLimitPerMinute} >= 0`,
+    ),
   ],
 );
 
@@ -375,12 +413,15 @@ export const consumedEvents = pgTable(
 // the cross-tenant gauntlet as targets.
 //
 // NAMED, NOT NUMBERED. This line used to say "chapter 3.7's cross-tenant
-// gauntlet". The gauntlet was 3.7 when that was written, became 3.8 when a chapter
-// was inserted ahead of it, and is now 3.9 after a second insertion — and the
-// comment was carried neither time. A chapter number in a source comment is a
-// reference that ages every time the plan changes, and this file is fenced
-// byte-exact into a published chapter, so correcting it costs a fence amendment.
-// The subject does not move; the ordinal does.
+// gauntlet", and the gauntlet has moved three times since — carried by the
+// comment none of them. A chapter number in a source comment is a reference that
+// ages every time the plan changes, and this file is fenced byte-exact into a
+// published chapter, so correcting it costs a fence amendment.
+//
+// The sentence you are reading replaced one that stated the ordinals and went
+// stale in the very next chapter, which is the rule proving itself on its own
+// explanation. It now names no numbers at all. The subject does not move; the
+// ordinal does.
 // ---------------------------------------------------------------------------
 
 // DECISION (chapter 3.5): no source document defines this table. FR-WHK-01 and
services/api/src/db/repository.ts
@@ -1,7 +1,19 @@
 import { randomUUID } from "node:crypto";
 
-import { and, asc, desc, eq, gt, isNull, lt, sql, type SQL } from "drizzle-orm";
-
+import {
+  and,
+  asc,
+  desc,
+  eq,
+  gt,
+  inArray,
+  isNull,
+  lt,
+  sql,
+  type SQL,
+} from "drizzle-orm";
+
+import { DEFAULT_LIMITS, type LimitedOperation } from "../limits/policy";
 import type { Db } from "./client";
 import {
   apiKeys,
@@ -234,6 +246,37 @@ export async function environmentSigningSecret(
   return row ?? null;
 }
 
+/** An environment's rate limits, with nulls resolved to the documented defaults
+ * (chapter 3.8, FR-RTL-04, research R26).
+ *
+ * RESOLVED HERE RATHER THAN AT THE CALL SITE, because "null means use the
+ * default" is a property of the column and a caller that had to remember it
+ * would eventually forget. Null is NOT zero: zero means refuse everything, and an
+ * environment can be switched off deliberately.
+ *
+ * Returns null for an environment that does not exist, which the caller must tell
+ * apart from an environment with default limits — a request whose credential
+ * named a missing environment is not a request to serve generously. */
+export async function environmentLimits(
+  db: Db,
+  environmentId: string,
+): Promise<Record<LimitedOperation, number> | null> {
+  const [row] = await db
+    .select({
+      rest: environments.restLimitPerMinute,
+      send: environments.sendLimitPerMinute,
+      connect: environments.connectLimitPerMinute,
+    })
+    .from(environments)
+    .where(eq(environments.id, environmentId));
+  if (!row) return null;
+  return {
+    rest: row.rest ?? DEFAULT_LIMITS.rest,
+    send: row.send ?? DEFAULT_LIMITS.send,
+    connect: row.connect ?? DEFAULT_LIMITS.connect,
+  };
+}
+
 // ---------------------------------------------------------------------------
 // The outbox drain (chapter 3.3, ADR-06). Part of the ADMIN surface for the
 // same reason the credential lookup is: it runs on behalf of the platform

Socket

Các giới hạn đi nhờ response xác thực, và gateway cache chúng trên connection thay vì đọc lại lần nữa.

packages/protocol/src/internal.ts
@@ -137,6 +137,23 @@ export const internalSessionResponseSchema = z.strictObject({
   environment_id: z.string().min(1),
   user: z.string().min(1),
   channel_ids: z.array(z.string().min(1)),
+  /** Chapter 3.8. The two limits the gateway enforces, resolved from the
+   * environment's policy with nulls already turned into defaults.
+   *
+   * THEY RIDE THIS RESPONSE BECAUSE THE GATEWAY HAS NO DATABASE, and must not
+   * gain one — `registry.ts` states that as a design property: "no pg, no
+   * drizzle-orm, no repository import". The policy is three columns in Postgres
+   * and the api is the only service that reads Postgres, so the limits travel on
+   * the one call the gateway was already making at connect.
+   *
+   * The same move chapter 3.2 made on this call, whose comment records it: the
+   * api "answers with the identity AND the memberships … it just asks a better
+   * question than 'what may this user hear'". This asks it for one thing more
+   * (research R12). */
+  limits: z.strictObject({
+    connect: z.number().int().nonnegative(),
+    send: z.number().int().nonnegative(),
+  }),
 });
 
 /** The deliveries stream (chapter 3.5), and its subject grammar.
services/api/src/internal/session.controller.ts
@@ -14,7 +14,8 @@ import { AUTH_DB } from "../auth/authenticate.middleware";
 import { Accepts, CredentialGuard } from "../auth/credential.guard";
 import type { RequestWithPrincipal } from "../auth/principal";
 import type { Db } from "../db/client";
-import { Repository } from "../db/repository";
+import { environmentLimits, Repository } from "../db/repository";
+import { DEFAULT_LIMITS } from "../limits/policy";
 
 // `POST /internal/session` (chapter 3.2) — the route that replaced
 // `GET /internal/memberships`.
@@ -59,10 +60,19 @@ export class SessionController {
     // error: it is a user with no channels. The gateway's job is delivery, not
     // identity forensics — 2.5's rule, and the reason a first connect from a
     // brand-new user works before anything is seeded.
+    // Chapter 3.8: the gateway's limits, resolved here because the gateway has no
+    // database and must not gain one (research R12). Null columns are already
+    // defaults by the time they leave the repository, so the gateway never has to
+    // know that "no override" is a state.
+    const limits = await environmentLimits(this.db, principal.environmentId);
     return {
       environment_id: principal.environmentId,
       user: principal.userExternalId,
       channel_ids: user ? await this.repo.channelsForUser(user.id) : [],
+      limits: {
+        connect: limits?.connect ?? DEFAULT_LIMITS.connect,
+        send: limits?.send ?? DEFAULT_LIMITS.send,
+      },
     };
   }
 }
services/gateway/src/auth.ts
@@ -26,7 +26,16 @@ export type { Identity } from "./api-client.js";
  * 1011 tells it we are broken (retrying will). 2.5 drew that line for the
  * memberships lookup; moving verification here must not erase it. */
 export type Authentication =
-  | { outcome: "ok"; identity: Identity; channelIds: string[] }
+  | {
+      outcome: "ok";
+      identity: Identity;
+      channelIds: string[];
+      /** Chapter 3.8. The environment's two socket allowances, read from
+       * Postgres by the api and carried on the same response — the gateway has
+       * no database client and R12 spent its whole argument on keeping it that
+       * way. */
+      limits: { connect: number; send: number };
+    }
   | { outcome: "refused" }
   | { outcome: "unavailable"; error: string };
 
@@ -51,6 +60,7 @@ export async function authenticate(
         token,
       },
       channelIds: session.channel_ids,
+      limits: session.limits,
     };
   } catch (error) {
     return { outcome: "unavailable", error: String(error) };
services/gateway/src/registry.ts
@@ -47,6 +47,16 @@ export interface Connection {
    * 42. Bounded instead by `MAX_RESUME_CHANNELS`, which already caps the cursors
    * these are scoped to. */
   marks: Record<string, number> | null;
+  /** Chapter 3.8. The environment's send allowance, as it stood when this socket
+   * connected — carried on the session response because the gateway has no
+   * database and must not gain one (research R12).
+   *
+   * FIXED FOR THE LIFE OF THE CONNECTION, and that is a stated property rather
+   * than an accident: a limit changed while a socket is open does not reach it
+   * until the client reconnects. The alternative is a Postgres read per frame, on
+   * the hot path of the thing the limit protects. Beside `marks` for the same
+   * reason — it describes one socket and dies with it. */
+  sendLimit: number;
 }
 
 export class Registry {
services/gateway/src/session.ts
@@ -1,5 +1,6 @@
 import { randomUUID } from "node:crypto";
 import type { IncomingMessage, Server } from "node:http";
+import type { Duplex } from "node:stream";
 
 import {
   CLOSE_CODES,
@@ -7,12 +8,13 @@ import {
   type Frame,
   type Message,
 } from "@relay/protocol";
-import type { Logger } from "@relay/service-kit";
+import { newRequestId, type Logger } from "@relay/service-kit";
 import { WebSocketServer, type WebSocket } from "ws";
 
 import { ApiError, type ApiClient } from "./api-client.js";
 import { authenticate, type Identity } from "./auth.js";
 import type { Fanout } from "./fanout.js";
+import type { Decision, GatewayLimits } from "./limits.js";
 import { Registry, type Connection } from "./registry.js";
 import {
   MAX_BUFFERED_FRAMES,
@@ -39,14 +41,66 @@ function send(socket: WebSocket, frame: Frame): void {
   socket.send(JSON.stringify(frame));
 }
 
-/** EIR-API-04's envelope, wearing its WebSocket clothes. */
-function sendError(socket: WebSocket, code: string, message: string): void {
+/** EIR-API-04's envelope, wearing its WebSocket clothes.
+ *
+ * `request_id` ARRIVED IN CHAPTER 3.8, and the gateway had none to give — it
+ * minted no ids at all. The field is required on the frame rather than optional,
+ * because an optional fourth field would have been the fourth instance of the
+ * habit that chapter is about: `rate_limited`, close code 4008 and this field
+ * were all declared in 1.3 and left unenforced (research R13).
+ *
+ * WHAT THE ID IS FOR decides its shape. A developer quoting one in a support
+ * ticket needs it to find a single server-side log line, and on a socket the
+ * useful unit is the frame that failed — a client whose tenth `message.send` was
+ * refused needs to point at that refusal, not at the connection. So callers pass
+ * the id of the frame they are answering, and `sendError` mints one only for a
+ * frame nobody asked for. */
+/** The handshake refusal (chapter 3.8, FR-RTL-03). Written onto the raw upgrade
+ * socket by hand, because there is no `res` here — `server.on("upgrade")` hands
+ * over the socket and the unparsed head, and anything sent on it has to be a
+ * complete HTTP response including the blank line before the body.
+ *
+ * The same three headers the api sends on a 429, from the same numbers, plus
+ * `Retry-After` — a client should not have to learn a second dialect for the
+ * socket door. `Connection: close` because this socket is not becoming a
+ * WebSocket and is not being kept alive for a second request either. */
+function refuseUpgrade(socket: Duplex, decision: Decision): void {
+  const body = JSON.stringify({
+    code: "rate_limited",
+    message: "too many connections; retry after the window resets",
+    docs_url: "https://relay.example/docs/errors/rate_limited",
+    request_id: newRequestId(),
+  });
+  socket.write(
+    [
+      "HTTP/1.1 429 Too Many Requests",
+      "Content-Type: application/json",
+      `Content-Length: ${Buffer.byteLength(body)}`,
+      `Retry-After: ${decision.retryAfterSeconds}`,
+      `X-RateLimit-Limit: ${decision.limit}`,
+      `X-RateLimit-Remaining: ${decision.remaining}`,
+      `X-RateLimit-Reset: ${decision.resetSeconds}`,
+      "Connection: close",
+      "",
+      body,
+    ].join("\r\n"),
+  );
+  socket.destroy();
+}
+
+function sendError(
+  socket: WebSocket,
+  code: string,
+  message: string,
+  requestId: string = newRequestId(),
+): void {
   send(socket, {
     type: "error",
     payload: {
       code,
       message,
       docs_url: `https://relay.example/docs/errors/${code}`,
+      request_id: requestId,
     },
   });
 }
@@ -68,6 +122,12 @@ export interface SessionServerOptions {
    * (chapter 2.7): the degrade branch is a contract, and a test should not
    * have to sit through half a second to see it. */
   resumeDeadlineMs?: number;
+  /** The shared counter (chapter 3.8). Optional for the same reason `fanout`
+   * is: 2.5's tests and a single-process dev run have no Redis, and a socket
+   * server that refused to start without one would be a worse default than an
+   * uncounted one. `main.ts` always supplies it, so the optionality is a test
+   * affordance rather than a deployment mode. */
+  limits?: GatewayLimits;
 }
 
 export function attachSessions({
@@ -77,6 +137,7 @@ export function attachSessions({
   fanout,
   pingIntervalMs = PING_INTERVAL_MS,
   resumeDeadlineMs = SUBSCRIBE_DEADLINE_MS,
+  limits,
 }: SessionServerOptions): { registry: Registry; close: () => void } {
   const registry = new Registry();
 
@@ -125,6 +186,43 @@ export function attachSessions({
       // memberships. This is the same one call the connect path already made —
       // it just asks a better question than "what may this user hear".
       const result = await authenticate(api, token);
+      // Chapter 3.8. THE ESTABLISHMENT LIMIT IS SPENT HERE, before
+      // `handleUpgrade`, and that placement is the whole difference between
+      // this refusal and the one below it.
+      //
+      // A refusal needs to say WHEN to come back. `Retry-After` is an HTTP
+      // header and a close frame has nowhere to put one — a close code and a
+      // short reason string is all the protocol offers, and "4008, try later"
+      // is not an instruction a client can schedule against. So an over-limit
+      // handshake is refused with an HTTP 429 on the upgrade request, which
+      // still has a response to write headers onto (research R7).
+      //
+      // That makes it deliberately unlike the 4001 path immediately below,
+      // which COMPLETES the handshake in order to close it — because EIR-WS-05
+      // asks for a close code on a bad token, and a close code needs a socket
+      // to arrive on. Two refusals, two shapes, each because of what it has to
+      // carry.
+      //
+      // AFTER authentication, not before: the limit belongs to an environment
+      // and nothing knows which environment this is until the api has said so.
+      // The cost is that an unauthenticated flood still reaches the api — which
+      // is what the auth limiter there is for, and why that one counts by
+      // source address instead.
+      if (result.outcome === "ok" && limits !== undefined) {
+        const decision = await limits.spend(
+          result.identity.environmentId,
+          "connect",
+          result.limits.connect,
+        );
+        if (decision.over) {
+          refuseUpgrade(socket, decision);
+          logger.log("info", "connection.rejected", {
+            reason: "rate_limited",
+            environment_id: result.identity.environmentId,
+          });
+          return;
+        }
+      }
       wss.handleUpgrade(req, socket, head, (ws) => {
         if (result.outcome === "refused") {
           // 4001: "invalid or expired token" (EIR-WS-05). The close code is
@@ -143,7 +241,13 @@ export function attachSessions({
           });
           return;
         }
-        void open(ws, result.identity, result.channelIds, req.url ?? "/");
+        void open(
+          ws,
+          result.identity,
+          result.channelIds,
+          req.url ?? "/",
+          result.limits.send,
+        );
       });
     })();
   });
@@ -153,6 +257,7 @@ export function attachSessions({
     identity: Identity,
     channelIds: string[],
     url: string,
+    sendLimit: number,
   ): Promise<void> {
     // Cursors are read BEFORE anything else, because their presence decides
     // whether this connection is born buffering or born live.
@@ -173,6 +278,7 @@ export function attachSessions({
       // A fresh connect suppresses nothing; a resume fills this in when it
       // succeeds, and leaves it null when it degrades.
       marks: null,
+      sendLimit,
     };
 
     registry.add(connection);
@@ -399,6 +505,46 @@ export function attachSessions({
       return;
     }
 
+    // Chapter 3.8. THE SEND LIMIT IS SPENT ON THE FRAME, not on the api call
+    // it becomes — a socket send and a REST send count against one budget
+    // (FR-RTL-01), or a client could double its allowance by opening a socket.
+    //
+    // AND THE CONNECTION STAYS OPEN. Closing it would be the obvious move and
+    // the wrong one: a closed socket makes the client reconnect, a reconnect
+    // costs a handshake, and a handshake spends the ESTABLISHMENT allowance —
+    // a limiter that punishes the limited into hitting a second limit. The
+    // error frame says no to this frame and nothing more; the next one, after
+    // the window turns over, goes through on the connection that is still there.
+    //
+    // The limit is the one this socket was born with (`connection.sendLimit`),
+    // not one re-read per frame: the gateway has no database, and a Postgres
+    // read on the hot path of the thing the limit protects would be a strange
+    // way to protect it. A policy changed mid-connection reaches the client
+    // when it reconnects (research R12).
+    if (limits !== undefined) {
+      const decision = await limits.spend(
+        connection.identity.environmentId,
+        "send",
+        connection.sendLimit,
+      );
+      if (decision.over) {
+        // `rate_limited` — declared in chapter 1.3, emitted here for the first
+        // time. The numbers a 429 would carry in headers have nowhere to live
+        // on a frame, so the retry window goes in the message text; the code is
+        // what a client branches on.
+        sendError(
+          connection.socket,
+          "rate_limited",
+          `send rate limit exceeded; retry in ${decision.retryAfterSeconds}s`,
+        );
+        logger.log("info", "send.rate_limited", {
+          connection_id: connection.id,
+          environment_id: connection.identity.environmentId,
+        });
+        return;
+      }
+    }
+
     const { channel, text, idem_key } = frame.data.payload;
     try {
       const committed = await api.sendMessage(connection.identity, {
services/gateway/src/main.ts
@@ -3,6 +3,7 @@ import { createLogger, serve, type Logger } from "@relay/service-kit";
 
 import { createApiClient } from "./api-client.js";
 import { createFanout } from "./fanout.js";
+import { createGatewayLimits } from "./limits.js";
 import { attachSessions } from "./session.js";
 
 // The gateway — SAD §4.1: terminates WebSockets and never writes to the
@@ -33,15 +34,22 @@ export function createServer(logger?: Logger) {
   // here, and no instance knows how many others exist (ADR-07). Scaling
   // out is adding a process.
   const fanout = createFanout({ logger: log });
+  // Chapter 3.8. A SECOND Redis client, not fanout's — one of fanout's two is a
+  // subscriber, and a connection in subscribe mode cannot run `INCR`. It is
+  // created here rather than inside `attachSessions` so the tests that call
+  // that function directly stay Redis-free, and so its close has an owner.
+  const limits = createGatewayLimits();
   const sessions = attachSessions({
     server,
     api: createApiClient(process.env.RELAY_API_URL ?? DEFAULT_API_URL),
     logger: log,
     fanout,
+    limits,
   });
   server.on("close", () => {
     sessions.close();
     void fanout.close();
+    void limits.close();
   });
   return server;
 }

Những gì các suite đang giữ

Ba trong số này là suite của các chương trước, thay đổi chỉ vì request_id khiến hai error body vốn phải không phân biệt được nay khác nhau ở một trường chẳng nói gì về cả hai. test-event.itest.ts mất thêm một dòng nữa: chương 3.6 dán nhãn nó bằng một dải số làm việc của riêng feature ấy, những số không dẫn tới đâu mà người đọc lần theo được. FR-WHK-09 mới là thứ chúng muốn nói — và một phép quét truy vết mà chính lời giải thích của nó cũng làm vấp là phép quét rồi sẽ có người bỏ qua, nên những con số ấy không được nhắc lại ở đây.

services/gateway/src/session.test.ts
@@ -1,16 +1,18 @@
 import { WebSocket } from "ws";
 import { afterEach, describe, expect, it } from "vitest";
+import { readFile } from "node:fs/promises";
 import type { Server } from "node:http";
 import type { AddressInfo } from "node:net";
 
 import { createLogger, type Logger } from "@relay/service-kit";
 import { serve } from "@relay/service-kit";
-import type { Frame } from "@relay/protocol";
+import { CLOSE_CODES, type Frame } from "@relay/protocol";
 
 import type { InternalSendResponse, Message } from "@relay/protocol";
 
 import type { ApiClient } from "./api-client.js";
 import type { Fanout } from "./fanout.js";
+import { decide, type GatewayLimits } from "./limits.js";
 import { attachSessions } from "./session.js";
 
 // The door, the frames, and the liveness clock — all provable without a
@@ -42,7 +44,16 @@ function stubApi(overrides: Partial<ApiClient> = {}): ApiClient {
     // fake except the ANSWER.
     session: async (token) =>
       token === VALID_TOKEN
-        ? { environment_id: "env-1", user: "tuan", channel_ids: [CHANNEL] }
+        ? {
+            environment_id: "env-1",
+            user: "tuan",
+            channel_ids: [CHANNEL],
+            // Chapter 3.8. The limits ride the session response because the
+            // gateway has no database to read them from — so the stub supplies
+            // them, exactly as the api would. Generous by default: every test
+            // above this line is about something else.
+            limits: { connect: 3_000, send: 600 },
+          }
         : null,
     backfill: async () => ({}),
     sendMessage: async () => committed(42),
@@ -125,11 +136,34 @@ function stubFanout(): Fanout & {
   };
 }
 
+/** A counter with no Redis in it (chapter 3.8). The arithmetic is unit-tested
+ * in `limits.test.ts`; what these tests need is control over the ANSWER, so a
+ * refusal is a line of code instead of three thousand sockets. */
+function stubLimits(
+  allowances: { connect?: number; send?: number } = {},
+): GatewayLimits & { spent: { connect: number; send: number } } {
+  const spent = { connect: 0, send: 0 };
+  return {
+    spent,
+    spend: async (_environmentId, operation, limit) => {
+      spent[operation] += 1;
+      // The stub honours whichever allowance the test set, falling back to the
+      // limit the session response carried — which is what makes T034a's
+      // distinction visible: an allowance the test names here is the store's
+      // view, `limit` is the socket's cached one.
+      const allowed = allowances[operation] ?? limit;
+      return decide(spent[operation], allowed, 0, 60_000);
+    },
+    close: async () => {},
+  };
+}
+
 async function boot(
   api: ApiClient = stubApi(),
   pingIntervalMs?: number,
   fanout?: Fanout,
   resumeDeadlineMs?: number,
+  limits?: GatewayLimits,
 ): Promise<Harness> {
   const server: Server = serve({
     service: "gateway",
@@ -143,6 +177,7 @@ async function boot(
     ...(fanout !== undefined && { fanout }),
     ...(pingIntervalMs !== undefined && { pingIntervalMs }),
     ...(resumeDeadlineMs !== undefined && { resumeDeadlineMs }),
+    ...(limits !== undefined && { limits }),
   });
   await new Promise<void>((resolve) => server.listen(0, resolve));
   const { port } = server.address() as AddressInfo;
@@ -676,3 +711,246 @@ describe("the socket (chapter 2.5)", () => {
     socket.close();
   });
 });
+
+// Chapter 3.8. The socket's two limits — one at the door, one on every frame —
+// and the two shapes a refusal takes, which are different because a handshake
+// has an HTTP response to write headers onto and a frame does not.
+describe("the socket's limits (chapter 3.8)", () => {
+  let harness: Harness | undefined;
+  afterEach(async () => {
+    await harness?.close();
+    harness = undefined;
+  });
+
+  /** The upgrade's HTTP answer, for the case where there is no WebSocket to
+   * ask. `ws` surfaces a non-101 as `unexpected-response`, which hands back the
+   * request and the raw `IncomingMessage` — status and headers included. */
+  function unexpectedResponse(
+    socket: WebSocket,
+  ): Promise<{ status: number; headers: Record<string, string | undefined> }> {
+    return new Promise((resolve, reject) => {
+      const timer = setTimeout(() => reject(new Error("no response")), 2000);
+      socket.on("unexpected-response", (_req, res) => {
+        clearTimeout(timer);
+        res.resume();
+        resolve({
+          status: res.statusCode ?? 0,
+          headers: res.headers as Record<string, string | undefined>,
+        });
+      });
+      socket.on("open", () => {
+        clearTimeout(timer);
+        reject(new Error("the handshake completed"));
+      });
+      socket.on("error", () => {});
+    });
+  }
+
+  it("refuses an over-limit handshake with an HTTP 429, before the handshake (FR-RTL-03)", async () => {
+    // An allowance of one, so the second connect is the refused one.
+    harness = await boot(
+      stubApi(),
+      undefined,
+      undefined,
+      undefined,
+      stubLimits({ connect: 1 }),
+    );
+    const first = new WebSocket(`${harness.url}?token=${await token()}`);
+    await nextFrame(first, "connection.ack");
+
+    const second = new WebSocket(`${harness.url}?token=${await token()}`);
+    const { status, headers } = await unexpectedResponse(second);
+    expect(status).toBe(429);
+    // The instruction, not just the refusal. This is the reason the limiter is
+    // a fixed window: `Retry-After` and `X-RateLimit-Reset` both name one
+    // moment, and a refilling bucket's honest answer would be a curve.
+    expect(Number(headers["retry-after"])).toBeGreaterThan(0);
+    expect(headers["x-ratelimit-limit"]).toBe("1");
+    expect(headers["x-ratelimit-remaining"]).toBe("0");
+    expect(headers["x-ratelimit-reset"]).toBeDefined();
+
+    first.close();
+  });
+
+  it("leaves already-open sockets alone when the door is shut (FR-RTL-03)", async () => {
+    // The refusal is about establishing connections, not about the ones that
+    // exist. A limiter that killed live sockets to enforce an establishment
+    // limit would be enforcing a concurrency limit, which is a different
+    // promise and one Relay has not made.
+    harness = await boot(
+      stubApi(),
+      undefined,
+      undefined,
+      undefined,
+      stubLimits({ connect: 1 }),
+    );
+    const open = new WebSocket(`${harness.url}?token=${await token()}`);
+    await nextFrame(open, "connection.ack");
+
+    const refused = new WebSocket(`${harness.url}?token=${await token()}`);
+    expect((await unexpectedResponse(refused)).status).toBe(429);
+
+    // Still there, and still working — a round trip rather than a readyState
+    // check, because "the socket object says OPEN" is not the same claim.
+    open.send(
+      JSON.stringify({
+        type: "message.send",
+        payload: { channel: CHANNEL, text: "hello", idem_key: "k1" },
+      }),
+    );
+    expect(await nextFrame(open, "message.ack")).toMatchObject({
+      payload: { seq: 42 },
+    });
+    open.close();
+  });
+
+  it("answers an over-limit frame with rate_limited and KEEPS THE CONNECTION OPEN", async () => {
+    harness = await boot(
+      stubApi(),
+      undefined,
+      undefined,
+      undefined,
+      stubLimits({ send: 1 }),
+    );
+    const socket = new WebSocket(`${harness.url}?token=${await token()}`);
+    await nextFrame(socket, "connection.ack");
+    const send = () =>
+      socket.send(
+        JSON.stringify({
+          type: "message.send",
+          payload: { channel: CHANNEL, text: "hello", idem_key: "k1" },
+        }),
+      );
+
+    send();
+    await nextFrame(socket, "message.ack");
+    send();
+    const error = await nextFrame(socket, "error");
+    // `rate_limited` was declared in chapter 1.3 and emitted by nothing until
+    // now. This is the first line of the codebase that sends it.
+    expect(error).toMatchObject({ payload: { code: "rate_limited" } });
+    // And every error frame carries an id now, which is the other contract
+    // chapter 1.3 wrote down and never wired.
+    expect((error as { payload: { request_id: string } }).payload.request_id)
+      .toBeTruthy();
+
+    // THE POINT: the socket is still up. Closing it would make the client
+    // reconnect, and a reconnect spends the ESTABLISHMENT allowance — a
+    // limiter that pushes the limited into a second limit.
+    expect(socket.readyState).toBe(WebSocket.OPEN);
+    socket.close();
+  });
+
+  it("enforces a CONFIGURED connect limit, not just the default (ADR-05, FR-RTL-04)", async () => {
+    // The limit arrives on the authentication response, because the gateway has
+    // no database to read it from. A test that only exercised the default would
+    // pass with the plumbing missing entirely.
+    harness = await boot(
+      stubApi({
+        session: async () => ({
+          environment_id: "env-1",
+          user: "tuan",
+          channel_ids: [CHANNEL],
+          limits: { connect: 2, send: 600 },
+        }),
+      }),
+      undefined,
+      undefined,
+      undefined,
+      // No allowance override: the stub honours the limit the session response
+      // carried, so the number under test is the CONFIGURED one.
+      stubLimits(),
+    );
+    const first = new WebSocket(`${harness.url}?token=${await token()}`);
+    await nextFrame(first, "connection.ack");
+    const second = new WebSocket(`${harness.url}?token=${await token()}`);
+    await nextFrame(second, "connection.ack");
+
+    const third = new WebSocket(`${harness.url}?token=${await token()}`);
+    expect((await unexpectedResponse(third)).status).toBe(429);
+    first.close();
+    second.close();
+  });
+
+  it("does not apply a limit changed mid-connection until the client reconnects (research R12)", async () => {
+    // The consequence R12 accepted, asserted so it is a property rather than a
+    // surprise. The alternative is a Postgres read per frame, from a service
+    // that holds no database client, on the hot path of the thing the limit
+    // protects.
+    let configured = 600;
+    harness = await boot(
+      stubApi({
+        session: async () => ({
+          environment_id: "env-1",
+          user: "tuan",
+          channel_ids: [CHANNEL],
+          limits: { connect: 3_000, send: configured },
+        }),
+      }),
+      undefined,
+      undefined,
+      undefined,
+      stubLimits(),
+    );
+    const socket = new WebSocket(`${harness.url}?token=${await token()}`);
+    await nextFrame(socket, "connection.ack");
+
+    // The policy changes to "refuse everything" while the socket is open.
+    configured = 0;
+
+    socket.send(
+      JSON.stringify({
+        type: "message.send",
+        payload: { channel: CHANNEL, text: "hello", idem_key: "k1" },
+      }),
+    );
+    // Still allowed: this connection is spending the allowance it was born
+    // with. A new one would not be.
+    expect(await nextFrame(socket, "message.ack")).toMatchObject({
+      payload: { seq: 42 },
+    });
+
+    const reconnected = new WebSocket(`${harness.url}?token=${await token()}`);
+    await nextFrame(reconnected, "connection.ack");
+    reconnected.send(
+      JSON.stringify({
+        type: "message.send",
+        payload: { channel: CHANNEL, text: "hello", idem_key: "k1" },
+      }),
+    );
+    expect(await nextFrame(reconnected, "error")).toMatchObject({
+      payload: { code: "rate_limited" },
+    });
+
+    socket.close();
+    reconnected.close();
+  });
+
+  it("STILL emits close code 4008 from nowhere (quickstart V7)", async () => {
+    // 4008 reads "quota exhausted". There is no quota yet — quotas are a later
+    // chapter — and reaching for the code because it was declared would collapse
+    // the distinction this chapter is built on: a rate limit is a smoothing
+    // instruction, a quota is a commercial one, and they do not deserve the same
+    // signal. So does 4009, "server shutdown (drain)", for the same kind of
+    // reason (NFR-REL-03).
+    //
+    // Grep rather than behaviour, because the claim is about absence: no input
+    // makes the gateway send it, and the only way to check "no input" is to read
+    // what the source can send.
+    const source = await Promise.all(
+      ["session.ts", "limits.ts", "resume.ts", "main.ts"].map((file) =>
+        readFile(new URL(file, import.meta.url), "utf8"),
+      ),
+    );
+    for (const text of source) {
+      expect(text).not.toMatch(/close\(\s*400[89]/);
+    }
+    // A grep that can only pass is not a check. The SAME pattern, aimed at the
+    // codes this file does emit, has to match — otherwise "nothing sends 4008"
+    // would also be true of a typo in the regex.
+    expect(source.join("")).toMatch(/close\(\s*400[12]/);
+    // And the vocabulary still declares them, so this is "unused", not "gone".
+    expect(CLOSE_CODES[4008]).toBeDefined();
+    expect(CLOSE_CODES[4009]).toBeDefined();
+  });
+});
services/gateway/src/resume.itest.ts
@@ -111,16 +111,19 @@ describe("resume across a real fabric", () => {
     // The backfill leg is deliberately slow, and a DIFFERENT process — a
     // different fanout client on the same subject — publishes into the
     // window. Neither side coordinates; only the buffer saves this.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         channel_ids: [CHANNEL],
+        // Chapter 3.8: the limits ride the session response now. Generous, and
+        // beside the point of every test in this file.
+        limits: { connect: 3_000, send: 600 },
       }),
       backfill: async () => {
         await publishFromElsewhere(frame(43));
         await settle(150); // give Redis time to actually deliver it
         return {
           [CHANNEL]: { messages: [frame(42), frame(43)], truncated: false },
         };
       },
@@ -142,16 +145,19 @@ describe("resume across a real fabric", () => {
   it("delivers a mid-backfill frame that the backfill did not contain", async () => {
     // Committed after the backfill's snapshot: it exists ONLY in the buffer,
     // and the flush is the only reason the client ever sees it.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         channel_ids: [CHANNEL],
+        // Chapter 3.8: the limits ride the session response now. Generous, and
+        // beside the point of every test in this file.
+        limits: { connect: 3_000, send: 600 },
       }),
       backfill: async () => {
         await publishFromElsewhere(frame(43));
         await settle(150);
         return { [CHANNEL]: { messages: [frame(42)], truncated: false } };
       },
       sendMessage: async () => {
         throw new Error("not used");
@@ -167,16 +173,19 @@ describe("resume across a real fabric", () => {
   });
 
   it("goes live after the flush, with no buffering left behind", async () => {
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         channel_ids: [CHANNEL],
+        // Chapter 3.8: the limits ride the session response now. Generous, and
+        // beside the point of every test in this file.
+        limits: { connect: 3_000, send: 600 },
       }),
       backfill: async () => ({
         [CHANNEL]: { messages: [frame(42)], truncated: false },
       }),
       sendMessage: async () => {
         throw new Error("not used");
       },
     });
@@ -210,16 +219,19 @@ describe("resume across a real fabric", () => {
     // closed, because `marks` was a local variable that `resume()` discarded.
     //
     // One number different from the test above it. That is the whole bug.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         channel_ids: [CHANNEL],
+        // Chapter 3.8: the limits ride the session response now. Generous, and
+        // beside the point of every test in this file.
+        limits: { connect: 3_000, send: 600 },
       }),
       backfill: async () => ({
         [CHANNEL]: { messages: [frame(42)], truncated: false },
       }),
       sendMessage: async () => {
         throw new Error("not used");
       },
     });
@@ -246,16 +258,19 @@ describe("resume across a real fabric", () => {
     // This is the case that made the spec's first design unsafe. It proposed
     // retiring the mark once a higher sequence arrived — which would see the 43,
     // drop the mark, and then deliver the 42 (research R3).
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         channel_ids: [CHANNEL],
+        // Chapter 3.8: the limits ride the session response now. Generous, and
+        // beside the point of every test in this file.
+        limits: { connect: 3_000, send: 600 },
       }),
       backfill: async () => ({
         [CHANNEL]: { messages: [frame(42)], truncated: false },
       }),
       sendMessage: async () => {
         throw new Error("not used");
       },
     });
@@ -282,16 +297,19 @@ describe("resume across a real fabric", () => {
     // backfill it received is a fragment or nothing at all. A mark taken from it
     // would suppress messages the client never got — turning this chapter's
     // duplicate into a gap, which constitution II ranks worse.
     harness = await boot({
       session: async () => ({
         environment_id: "env-1",
         user: "tuan",
         channel_ids: [CHANNEL],
+        // Chapter 3.8: the limits ride the session response now. Generous, and
+        // beside the point of every test in this file.
+        limits: { connect: 3_000, send: 600 },
       }),
       backfill: async () => {
         throw new Error("backfill unavailable");
       },
       sendMessage: async () => {
         throw new Error("not used");
       },
     });
services/api/src/messages/messages.itest.ts
@@ -8,6 +8,23 @@ import { AppModule } from "../app.module";
 import { createDb, createPool } from "../db/client";
 import { createApiKey, createEnvironment, Repository } from "../db/repository";
 
+// Chapter 3.8 added `request_id` to every error body (constitution V's fourth
+// field, promised since 1.3). It is unique per request BY DESIGN, so two error
+// bodies can no longer be compared whole — and comparing them whole is how this
+// suite proves a foreign resource is indistinguishable from an absent one, which
+// is a tenant-isolation property (constitution I).
+//
+// The id is the one field that reveals nothing about the resource, so it is the
+// one field the comparison must drop. Everything discriminating still has to
+// match exactly.
+function withoutRequestId(body: unknown): unknown {
+  if (typeof body !== "object" || body === null) return body;
+  const rest: Record<string, unknown> = { ...(body as Record<string, unknown>) };
+  delete rest["request_id"];
+  return rest;
+}
+
+
 // The endpoint path (chapter 2.2): guard → pipe → service → repository →
 // filter, over real HTTP against the compose Postgres. Its own environment,
 // minted here — no truncate, because tenant isolation means this suite and
@@ -88,7 +105,9 @@ describe("POST /v1/channels/:channelId/messages", () => {
     );
     expect(foreign.status).toBe(404);
     expect(missing.status).toBe(404);
-    expect(await foreign.json()).toEqual(await missing.json());
+    expect(withoutRequestId(await foreign.json())).toEqual(
+      withoutRequestId(await missing.json()),
+    );
   });
 
   it("answers a FOREIGN channel id with the same 404 as a missing one", async () => {
@@ -97,6 +116,8 @@ describe("POST /v1/channels/:channelId/messages", () => {
     expect(foreign.status).toBe(404);
     expect(missing.status).toBe(404);
     // Indistinguishable — no data, and no reveal that the id exists.
-    expect(await foreign.json()).toEqual(await missing.json());
+    expect(withoutRequestId(await foreign.json())).toEqual(
+      withoutRequestId(await missing.json()),
+    );
   });
 });
services/api/src/webhooks/attempts.itest.ts
@@ -22,6 +22,23 @@ import {
 import { encryptSecret, mintSigningSecret } from "./secret";
 import { MAX_ATTEMPTS } from "./schedule";
 
+// Chapter 3.8 added `request_id` to every error body (constitution V's fourth
+// field, promised since 1.3). It is unique per request BY DESIGN, so two error
+// bodies can no longer be compared whole — and comparing them whole is how this
+// suite proves a foreign resource is indistinguishable from an absent one, which
+// is a tenant-isolation property (constitution I).
+//
+// The id is the one field that reveals nothing about the resource, so it is the
+// one field the comparison must drop. Everything discriminating still has to
+// match exactly.
+function withoutRequestId(body: unknown): unknown {
+  if (typeof body !== "object" || body === null) return body;
+  const rest: Record<string, unknown> = { ...(body as Record<string, unknown>) };
+  delete rest["request_id"];
+  return rest;
+}
+
+
 // The attempt record, against a real broker and a real api (chapter 3.6).
 //
 // Invariants 1, 2, 3 and 5 of contracts/attempts.md live here. Invariant 4 is the
@@ -388,7 +405,9 @@ describe("the attempt record", () => {
     const second = await report(body);
     // The dispatcher is told the same thing both times — that is what idempotent
     // means here — so the repeat is invisible to it.
-    expect(await first.json()).toEqual(await second.json());
+    expect(withoutRequestId(await first.json())).toEqual(
+      withoutRequestId(await second.json()),
+    );
 
     // Spend a real budget looking for a second event rather than checking once.
     const events = await collected(scoped.id, 2, 5_000);
services/api/src/webhooks/test-event.itest.ts
@@ -17,7 +17,24 @@ import {
 } from "../db/repository";
 import { encryptSecret, mintSigningSecret } from "./secret";
 
-// Proving an endpoint works again (chapter 3.6, FR-013…FR-017, research R8).
+// Chapter 3.8 added `request_id` to every error body (constitution V's fourth
+// field, promised since 1.3). It is unique per request BY DESIGN, so two error
+// bodies can no longer be compared whole — and comparing them whole is how this
+// suite proves a foreign resource is indistinguishable from an absent one, which
+// is a tenant-isolation property (constitution I).
+//
+// The id is the one field that reveals nothing about the resource, so it is the
+// one field the comparison must drop. Everything discriminating still has to
+// match exactly.
+function withoutRequestId(body: unknown): unknown {
+  if (typeof body !== "object" || body === null) return body;
+  const rest: Record<string, unknown> = { ...(body as Record<string, unknown>) };
+  delete rest["request_id"];
+  return rest;
+}
+
+
+// Proving an endpoint works again (chapter 3.6, FR-WHK-09, research R8).
 //
 // THIS SUITE PLAYS THE DISPATCHER. `POST /test` creates a real delivery and then
 // watches the row, because the attempt happens in another process — so something
@@ -478,7 +495,9 @@ describe("the test event", () => {
     // other — and nothing was delivered.
     const missing = await sendTest(randomUUID(), myKey.credential);
     expect(missing.status).toBe(404);
-    expect(await response.json()).toEqual(await missing.json());
+    expect(withoutRequestId(await response.json())).toEqual(
+      withoutRequestId(await missing.json()),
+    );
     expect(received).toHaveLength(0);
   }, 60_000);
 
vitest.coverage.config.mts
@@ -53,7 +53,10 @@ export default defineConfig({
         // Constitution VI, first clause: 70% of business logic. Set to what the
         // constitution says, not to what the code achieves — a threshold tuned
         // down to pass measures nothing. Currently met with room to spare
-        // (86.55% statements, 78.07% branches at the time of writing).
+        // (89.50% statements, 82.73% branches after chapter 3.8, up from 86.55%
+        // and 78.07%). Ten new files, eight of them small and heavily branched,
+        // moved both figures up — which is not the usual direction for a chapter
+        // that adds code, and worth naming for that reason.
         lines: 70,
         functions: 70,
         statements: 70,
@@ -167,6 +170,76 @@ export default defineConfig({
           lines: 100,
           statements: 100,
         },
+
+        // CHAPTER 3.8's limiter. Pinned at what the work achieves, which for the
+        // three pure files is everything — they hold no clock, no store and no
+        // framework, so a branch they miss is a case nobody thought of rather
+        // than a case nobody could reach.
+        //
+        // `bucket.ts`, `policy.ts` and `fallback.ts` are here at 100 on every
+        // metric. `fallback.ts` earns the strictest reading of constitution VI
+        // available: it is the mechanism the AUTH limiter degrades to, and R3's
+        // whole argument is that this one counter must not fail open. An
+        // unmeasured branch in it is a hole in the thing the chapter is about.
+        "services/api/src/limits/bucket.ts": {
+          branches: 100,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+        "services/api/src/limits/policy.ts": {
+          branches: 100,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+        "services/api/src/limits/fallback.ts": {
+          branches: 100,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+
+        // The four that touch a store, a clock or Nest's request pipeline, pinned
+        // at measurement rather than at 100. Each shortfall is one branch that
+        // needs a real outage at a real instant to reach, and chasing it would
+        // mean mocking the thing under test.
+        //
+        // `store.ts` misses its `downUntil` reset; `auth-limiter.ts` misses the
+        // arm where the store answers AND the fallback has an entry;
+        // `client-address.ts` misses one shape of malformed body. The gateway's
+        // `limits.ts` misses the arm where a recovered store clears `downUntil`
+        // mid-window.
+        "services/api/src/limits/store.ts": {
+          branches: 91,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+        "services/api/src/limits/auth-limiter.ts": {
+          branches: 87,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+        "services/api/src/limits/client-address.ts": {
+          branches: 90,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
+        "services/api/src/limits/rate-limit.middleware.ts": {
+          branches: 85,
+          functions: 100,
+          lines: 96,
+          statements: 97,
+        },
+        "services/gateway/src/limits.ts": {
+          branches: 90,
+          functions: 100,
+          lines: 100,
+          statements: 100,
+        },
       },
     },
   },

Cấu hình

Ràng buộc với ioredis mang đúng hình dạng mà chương 2.4 áp cho pg: một kho lưu trữ lấy khóa theo tenant thì chỉ có một chỗ ở, và một client không bị ràng buộc ở bất cứ đâu khác là một cú đọc xuyên tenant đang chờ được viết ra.

eslint.config.mjs
@@ -16,8 +16,28 @@ export default tseslint.config(
   {
     // Isolation lives in data access, not in handlers (constitution I):
     // only the repository layer may touch the driver.
+    //
+    // Chapter 3.8 added the SECOND per-tenant store and the same argument
+    // applies to it. The rate-limit counters are keyed `rl:{environment_id}:…`,
+    // so an unrestricted client would let any handler read or write another
+    // tenant's counter — which is the access this rule exists to prevent, and
+    // constitution I calls that a correctness property rather than a convention.
+    // `services/api/src/limits/**` is the Redis analogue of the repository
+    // layer; the gateway holds its own client in `services/gateway/src/limits.ts`
+    // and for fan-out in `fanout.ts`.
+    //
+    // `limits.itest.ts` is the one TEST allowed a raw client, and for a reason
+    // the rule cannot express: its whole subject is that the api and the gateway
+    // increment the SAME key, and the only way to check that is to read the key
+    // with neither of their code.
     files: ["**/*.ts"],
-    ignores: ["services/api/src/db/**"],
+    ignores: [
+      "services/api/src/db/**",
+      "services/api/src/limits/**",
+      "services/gateway/src/limits.ts",
+      "services/gateway/src/limits.itest.ts",
+      "services/gateway/src/fanout.ts",
+    ],
     rules: {
       "no-restricted-imports": [
         "error",
@@ -33,6 +53,11 @@ export default tseslint.config(
               message:
                 "The query engine lives inside the repository layer only (constitution I, ADR-16).",
             },
+            {
+              name: "ioredis",
+              message:
+                "The counter store lives in services/api/src/limits and services/gateway/src/limits.ts only (constitution I, chapter 3.8). Its keys are per environment; an unrestricted client is a cross-tenant read.",
+            },
           ],
           patterns: [
             {
turbo.json
@@ -30,10 +30,15 @@
         "RELAY_OUTBOX_RELAY",
         "RELAY_DELIVERY_RELAY",
         "RELAY_INTERNAL_CREDENTIAL",
+        "RELAY_AUTH_FAILURES_PER_MINUTE",
+        "RELAY_AUTH_KEY_PREFIX",
         "RELAY_WEBHOOK_SECRET_KEY",
         "RELAY_EVENT_CONSUMER",
         "RELAY_NATS_REPLICAS",
-        "RELAY_E2E_API_PORT"
+        "RELAY_E2E_API_PORT",
+        "RELAY_SMTP_URL",
+        "RELAY_MAILPIT_URL",
+        "RELAY_NOTIFICATION_RELAY"
       ]
     },
     "//#lint:root": {
packages/e2e/src/harness.ts
@@ -340,6 +340,18 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
       // would be a second source of truth for a credential.
       "RELAY_WEBHOOK_SECRET_KEY",
       "RELAY_INTERNAL_CREDENTIAL",
+      // Chapter 3.8: the failed-authentication threshold and the counter's key
+      // prefix. Forwarded for the reason this list exists at all — turbo runs
+      // tasks in STRICT env mode, so an undeclared variable reaches a child as
+      // `undefined` and the `??` behind it silently wins. A suite that raised the
+      // threshold would raise it in the parent and not in the api the child runs.
+      "RELAY_AUTH_FAILURES_PER_MINUTE",
+      "RELAY_AUTH_KEY_PREFIX",
+      // Chapter 3.8's other half: where the notification relay posts its SMTP.
+      // The lane runs Mailpit on 11025 and the default is 1025, so an
+      // unforwarded variable is not a missing feature — it is a mailer talking
+      // confidently to a port nothing is listening on.
+      "RELAY_SMTP_URL",
     ),
     // Chapter 3.3: the api children run WITHOUT the outbox relay. This journey
     // asserts message delivery, and a background loop draining the outbox while
@@ -347,6 +359,10 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
     // files, not a property of the system. The relay has its own suite, which
     // drives it explicitly.
     RELAY_OUTBOX_RELAY: "off",
+    // Chapter 3.8: and no notification relay either, for the same reason. This
+    // journey asserts message delivery; a loop marking rows delivered while
+    // 3.8's own suite asserts on that column is a race between test files.
+    RELAY_NOTIFICATION_RELAY: "off",
     // Chapter 3.4: no event consumer in these children either, for the reason
     // the line above exists — this journey asserts message delivery, and a
     // background consumer writing to a table 3.4's suite asserts on is a race
services/api/package.json
@@ -19,8 +19,10 @@
     "@relay/protocol": "workspace:*",
     "@relay/service-kit": "workspace:*",
     "drizzle-orm": "^0.45.2",
+    "ioredis": "^6.0.0",
     "jose": "^6.2.7",
     "nats": "^2.29.3",
+    "nodemailer": "^9.0.5",
     "pg": "^8.22.0",
     "reflect-metadata": "^0.2.2",
     "rxjs": "^7.8.2",
@@ -30,6 +32,7 @@
     "@nestjs/cli": "^11.0.24",
     "@nestjs/testing": "^11.1.28",
     "@swc/core": "^1.15.47",
+    "@types/nodemailer": "^8.0.1",
     "@types/pg": "^8.20.3",
     "drizzle-kit": "^0.31.10",
     "unplugin-swc": "^1.5.9"

Chương này không giao những gì

Quota. FR-RTL-05…08 — hạn mức theo tháng, trần chi tiêu cứng và mềm, email 50/80/100% — là một chương riêng, và lý do là sự phụ thuộc chứ không phải độ dài. Một quota là mức tiêu thụ được đo đếm, và việc đo đếm chỉ đến cùng phần analytics ở Phần 4. Dựng quota trên một bộ đếm Redis vốn fail open sẽ là đặt tiền vào đúng cái kho mà cả chương này bỏ công lập luận rằng nó được phép làm mất đồ.

Trần kết nối đồng thời của FR-RTM-09, thứ cần tới registry conn:{env}:{user} mà SAD đặc tả còn gateway thì không có. Presence thì không cần — chương 3.19 hỏi "còn ai đang kết nối không?" bằng sự tồn tại của một key duy nhất chứ không đếm thành viên, nên không cần registry nào cả. Có một điều đáng biết trước khi dựng nó: SAD đặc tả registry đó là một set Redis với một TTL, mà TTL thì theo key chứ không theo từng thành viên, nên một instance còn sống refresh key sẽ giữ mãi entry của một instance đã chết. Một sorted set chấm điểm theo thời gian heartbeat mới là dáng chạy được.

Giới hạn theo từng API key. SRS nói theo tenant, và environment là ranh giới mà hiến pháp I thực thi. Một key là một credential, không phải một tenant.

Một dashboard hiển thị hạn mức còn lại. Không có dashboard nào. Các header là nửa phần mà hiến pháp V đòi hỏi trong lời hứa đó.

Một khoảng ân hạn khi drain, thiếu nó thì giới hạn connect và NFR-REL-03 chỉ đồng thuận một nửa. Close code 4009 đã khai báo và đang chờ.

Một trang tài liệu, thứ mà chính chương này làm cho việc thiếu nó bắt đầu có giá.

Phương tiện gửi email. Những thông báo tắt endpoint mà chương 3.6 viết ra và không gì gửi đi nay đã được dựng và đang ship dưới tag của chương này — code nằm ở đây, delivered_at đang được set, và đống tồn đọng của 3.6 đã thoát hết. Chương giải thích nó là chương kế tiếp, vì chương này đo được 4.700 từ trước khi phần văn xuôi ấy kịp viết ra.

Gọi tên một sự phụ thuộc chính là ranh giới giữa một quyết định về phạm vi và một lỗ hổng im lặng.