Part 3 · Chapter 3.3
You will produce: Thirteen error codes with one registry and one URL rule, and a docs_url that resolves against the published site · about 49 minutes including the exercise
Source: SRS — Software Requirements Specification · Error reference
Every error this platform sends carries four fields, and one of them has been a lie since chapter 1.3.
{
"code": "rate_limited",
"message": "too many requests; retry shortly",
"docs_url": "https://relay.example/docs/errors/rate_limited",
"request_id": "9c2f8a1e-4b7d-4f3a-9e51-6d8c2b0a7f14"
}relay.example does not resolve. It never has. Chapter 1.4 wrote that the host "is
a placeholder until a docs site exists to make constitution V's reachable-page
promise true"; chapter 3.8 gave the placeholder its own section — "docs_url is
still a placeholder, and that now costs something" — because rate_limited is the
first error a working integration receives routinely. Chapter 3.10 added
quota_exceeded to the list of codes pointing nowhere. Chapter 3.11 declined to add
a third.
This chapter is the one that cannot ship with it, because its other half is the SRS Phase 2 exit criterion: an external developer integrates using only public documentation, with no assistance. A developer who follows a link from an error response to a 404 has been given no documentation at all.
flowchart TB
reg["ERROR_CODES — the registry"]
reg --> had["8 registered<br/>before this chapter"]
reg --> never["5 the platform SENT<br/>and never registered"]
never --> ladder["ProtocolErrorFilter's status ladder:<br/>invalid_request, unauthorized,<br/>forbidden, not_found, internal_error"]
ladder --> link["every one shipped a docs_url<br/>to a page that could not exist"]
reg --> now["13 codes"]
now --> url["docsUrl(code)"]
url --> frag["base + '#' + the code VERBATIM"]
frag --> anchor["## quota_exceeded in the reference<br/>anchors at #quota_exceeded"]
anchor --> slug["slugifyHeading keeps _<br/>so no transform lives in two repositories"]
style link fill:#7f1d1d,color:#fff,stroke:#dc2626
style now fill:#064e3b,color:#fff,stroke:#059669The registry called itself the documented vocabulary while documenting eight of thirteen:
@@ -34,11 +34,93 @@ export const ERROR_CODES = {
//
// REGISTERED HERE RATHER THAN WRITTEN INLINE. The frame schema types `code` as
// `z.string().min(1)`, so nothing forces this — but the registry is the
// documented vocabulary and `codes.test.ts` enforces its uniqueness, which is
// why the credentials chapter put `wrong_credential_type` in it instead of inventing it at
// the call site.
quota_exceeded:
"a monthly quota is exhausted; the message names the dimension, the figures and the date it resumes",
+ // The refusal beside `wrong_credential_type`, one dimension over:
+ // the class presented is RIGHT and the service is not. Two platform credentials
+ // exist — the dispatcher's and the gateway's — and until this chapter a route
+ // could say which class may call it and not which service, so the gateway's
+ // credential reached `POST /internal/dispatch/replay`.
+ //
+ // NOT `forbidden`. The credentials chapter made this argument when it added
+ // `wrong_credential_type` rather than answering a wrong-credential mistake with
+ // a generic 403: the response has to say what actually happened, and "you lack a
+ // permission" is a different fact from "that credential belongs to another
+ // service". The MESSAGE names the service and the permitted set and never the
+ // credential — a service name is a deployment label, a credential is a secret
+ // (NFR-SEC-06).
+ wrong_credential_service:
+ "the credential's service is not permitted on this route; the message names the service presented and the services allowed",
+ // FR-CHN-07's ceiling: a channel holds at most 1,000 members and
+ // an add that would cross it is refused with 422 and this code.
+ //
+ // The SRS names this code in its own worked example for EIR-API-04, which is
+ // the reason it is spelled this way rather than `member_limit_exceeded` — the
+ // document got there first and an integrating developer will have read it.
+ //
+ // NOT `quota_exceeded`. That one is a monthly, billable, resets-on-a-date
+ // refusal and its message promises a resume date; this is a structural limit on
+ // one channel that no amount of waiting changes. Same status code, different
+ // fact, and a client that retries on the wrong one waits for ever.
+ channel_member_limit_exceeded:
+ "the channel already holds its maximum members; the message names the limit and the channel",
+
+ // ── THE FIVE THE PLATFORM HAS ALWAYS SENT AND NEVER REGISTERED (FR-024)
+ // ──────────────────────────────────────────────────────────────────────────
+ //
+ // `ProtocolErrorFilter` maps a status to a code when the thrower names none,
+ // and those codes went out on the wire for twenty-two chapters without being in
+ // this object. The registry called itself "the documented vocabulary" while
+ // documenting eight of thirteen — and `docs_url` is derived from the code, so
+ // every one of these five shipped a link to a page that could not exist.
+ //
+ // Registering them is what makes the filter's ladder typable: with it annotated
+ // `ErrorCode`, a code that is not here stops compiling instead of reaching a
+ // customer with a 404 for a docs link.
+ invalid_request:
+ "the request body, query or path failed validation; `field` names the first offending key",
+ forbidden: "the credential is valid and is not permitted to do this",
+ not_found:
+ "no such resource for this tenant — and DELIBERATELY the same answer as for a resource in another tenant (FR-TEN-05)",
+ internal_error:
+ "the platform failed in a way it did not anticipate; the request_id is what a support ticket needs",
+ // The connection-metering chapter's. A connection belongs to one environment for its lifetime, and
+ // a second report naming a different one is a bug in the reporter rather than a
+ // state to reconcile — so it is refused rather than absorbed.
+ connection_environment_conflict:
+ "this connection was first reported for a different environment; a connection belongs to one environment for its whole life",
} as const;
export type ErrorCode = keyof typeof ERROR_CODES;
+
+/** The published reference, and the one place the URL is built (FR-027,
+ * `contracts/errors.md` §2).
+ *
+ * THE DEBT THIS CLOSES. `docs_url` has been in the error envelope since chapter
+ * 1.3 and constitution V calls it a reachable-page promise. Six construction sites
+ * built it with a template literal against `https://relay.example`, a host that
+ * does not resolve, and two codes — `rate_limited` and `quota_exceeded`
+ * (the quota chapter, the connection-metering chapter) — shipped links to pages that did not exist even in principle.
+ * The connection-metering chapter declined to add a third instance and named the debt; a chapter whose
+ * exit criterion is "integrates on public documentation alone" cannot ship a
+ * fourth.
+ *
+ * THE CODE IS THE ANCHOR, VERBATIM. No slug transform, no case change, no
+ * separator swap — the reference's `h2` headings ARE the codes, and
+ * `slugifyHeading` in the tutorial site keeps `_` so `## quota_exceeded` anchors
+ * at `#quota_exceeded`. Any transform here would be the same transform maintained
+ * in two repositories with no test able to see both sides.
+ *
+ * The base is overridable so a preview deployment can point at itself. It is read
+ * per call rather than captured at module load: a test that sets the variable in
+ * `beforeAll` would otherwise get the value from whenever this module was first
+ * imported. */
+export const DEFAULT_DOCS_BASE_URL = "https://relay.dev/docs/error-reference";
+
+export function docsUrl(code: ErrorCode): string {
+ const base = process.env["RELAY_DOCS_BASE_URL"] ?? DEFAULT_DOCS_BASE_URL;
+ return `${base}#${code}`;
+}The base is read per call rather than captured at module load, which is a small
thing with a specific reason: a test that sets RELAY_DOCS_BASE_URL in beforeAll
would otherwise get whatever the variable was when the module was first imported.
flowchart LR
typo["a typo in a code:<br/>wrong_credental_type"]
typo --> g1["ProtocolErrorFilter's ladder<br/>typed ErrorCode"]
typo --> g2["protocolError(code, …)<br/>a new helper"]
typo --> g3["sendError(socket, code, …)<br/>narrowed from string"]
typo --> g4["docsUrl(code)<br/>the two sites that write<br/>the envelope directly"]
g1 --> stop["stops compiling"]
g2 --> stop
g3 --> stop
g4 --> stop
before["BEFORE: HttpException's response is unknown,<br/>so eight sites named their code by hand"]
before --> ship["compiled, shipped,<br/>became a URL"]
style stop fill:#064e3b,color:#fff,stroke:#059669
style ship fill:#7f1d1d,color:#fff,stroke:#dc2626Chapter 3.2 introduced the convention that a thrower may name its code, because
wrong_credential_type is a distinction a status cannot carry. What it could not
introduce was any check on the string.
import { HttpException } from "@nestjs/common";
import type { ErrorCode } from "@relay/protocol";
/** An HTTP failure that NAMES ITS OWN CODE, typed (FR-025, FR-026).
*
* The credentials chapter introduced the convention that a thrower may name its code, because
* `wrong_credential_type` is a distinction a status cannot carry. What it could not
* introduce was any check on the string: `HttpException`'s response is `unknown`,
* so `code: "wrong_credental_type"` compiles, ships, and becomes a `docs_url`
* pointing at a page that does not exist. Eight sites named their code by hand.
*
* This is one function so `ErrorCode` is the only thing that fits. The value is
* exactly what `ProtocolErrorFilter` already reads — `code`, `message` and the
* optional `field` — so nothing about the envelope changes; what changes is that a
* typo stops compiling. */
export function protocolError(
code: ErrorCode,
message: string,
status: number,
field?: string,
): HttpException {
return new HttpException(
{ code, message, ...(field !== undefined ? { field } : {}) },
status,
);
}@@ -1,10 +1,12 @@
import type { ServerResponse } from "node:http";
+import { docsUrl, ERROR_CODES, type ErrorCode } from "@relay/protocol";
+
import {
Catch,
HttpException,
type ArgumentsHost,
type ExceptionFilter,
} from "@nestjs/common";
// EIR-API-04: one error shape, one home. Whatever throws — the router's own
@@ -32,31 +34,51 @@ export class ProtocolErrorFilter implements ExceptionFilter {
const response =
exception instanceof HttpException ? exception.getResponse() : null;
const named =
typeof response === "object" &&
response !== null &&
typeof (response as { code?: unknown }).code === "string"
? (response as { code: string }).code
: null;
- const code =
- named ??
- (status === 400
+ // TYPED AS `ErrorCode` (FR-025). The ladder emitted five codes
+ // that were not in the registry for twenty-two chapters — `invalid_request`,
+ // `forbidden`, `not_found`, `internal_error` and the frame codes — and
+ // `docs_url` is derived from the code, so each one shipped a link to a page
+ // that could not exist. With the annotation, an unregistered code stops
+ // compiling here instead of reaching a customer.
+ //
+ // `named` is checked against the registry rather than trusted: a thrower can
+ // put any string in `code`, and `ProtocolErrorFilter` is the last place that
+ // can notice before it becomes a URL.
+ const ladder: ErrorCode =
+ status === 400
? "invalid_request"
: status === 401
? "unauthorized"
: status === 403
? "forbidden"
: status === 404
? "not_found"
- : "internal_error");
+ : "internal_error";
+ const code: ErrorCode =
+ named !== null && named in ERROR_CODES ? (named as ErrorCode) : ladder;
const message =
exception instanceof HttpException
? exception.message
: "unexpected internal error";
+ // `field` travels the way `code` does — the thrower names it, because only the
+ // thrower knows it. Omitted rather than null when there is nothing to name: a
+ // key that is always present and usually empty teaches a client to ignore it.
+ const field =
+ typeof response === "object" &&
+ response !== null &&
+ typeof (response as { field?: unknown }).field === "string"
+ ? (response as { field: string }).field
+ : null;
res.statusCode = status;
res.setHeader("content-type", "application/json");
// FOUR FIELDS AS OF the rate-limit chapter, 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.
@@ -65,14 +87,15 @@ export class ProtocolErrorFilter implements ExceptionFilter {
// `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}`,
+ docs_url: docsUrl(code),
request_id: String(res.getHeader("X-Request-Id") ?? ""),
+ ...(field !== null ? { field } : {}),
}),
);
}
}The ladder is typed, and named is checked against the registry rather than
trusted — a thrower can put any string in code, and the filter is the last place
that can notice before it becomes a URL.
@@ -1,16 +1,17 @@
import {
BadRequestException,
- HttpException,
HttpStatus,
Injectable,
NotFoundException,
} from "@nestjs/common";
+import { protocolError } from "../protocol-error";
+
import {
ChannelNotFoundError,
Repository,
type MessageRow,
type MessageWithSender,
} from "../db/repository";
import { QuotaExceededError } from "../quotas/quota.error";
import { decodeCursor, encodeCursor } from "./cursor";
@@ -78,21 +79,19 @@ export class MessagesService {
// THE CODE IS NAMED HERE, and it has to be. `ProtocolErrorFilter` infers
// a code from the status for 400, 401, 403 and 404, and everything else
// becomes `internal_error` — so an unnamed `402` would emit a body
// calling itself an internal error while carrying a `402`. That is the
// lie chapter 2.2 fixed for 400 and the credentials chapter for 403, and the credentials chapter's
// mechanism — a thrower naming its own code — is what this uses. The
// filter builds the four-field envelope and derives `docs_url` from the
// code.
- throw new HttpException(
- {
- code: "quota_exceeded",
- message: error.publicMessage(),
- },
+ throw protocolError(
+ "quota_exceeded",
+ error.publicMessage(),
HttpStatus.PAYMENT_REQUIRED,
);
}
throw error;
}
}
/** A page of history (chapter 2.4). The cursor is opaque coming in and@@ -1,20 +1,21 @@
import {
Controller,
HttpCode,
- HttpException,
HttpStatus,
Inject,
Post,
Req,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
+import { protocolError } from "../protocol-error";
+
import type { InternalSessionResponse } from "@relay/protocol";
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 { connectPolicy, Repository } from "../db/repository";
import { periodOf } from "../quotas/period";
@@ -84,18 +85,19 @@ export class SessionController {
try {
policy = await connectPolicy(
this.db,
principal.environmentId,
periodOf(new Date()),
);
} catch (error) {
if (error instanceof QuotaExceededError) {
- throw new HttpException(
- { code: "quota_exceeded", message: error.publicMessage() },
+ throw protocolError(
+ "quota_exceeded",
+ error.publicMessage(),
HttpStatus.PAYMENT_REQUIRED,
);
}
throw error;
}
return {
environment_id: principal.environmentId,@@ -1,8 +1,9 @@
+import { docsUrl } from "@relay/protocol";
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";
@@ -114,17 +115,17 @@ export class RateLimitMiddleware implements NestMiddleware {
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",
+ docs_url: docsUrl("rate_limited"),
request_id: String(res.getHeader("X-Request-Id") ?? ""),
}),
);
return;
}
next();
return;
}
@@ -212,17 +213,17 @@ export class RateLimitMiddleware implements NestMiddleware {
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",
+ docs_url: docsUrl("rate_limited"),
request_id: String(res.getHeader("X-Request-Id") ?? ""),
}),
);
return;
}
next();
}@@ -1,15 +1,17 @@
import { randomUUID } from "node:crypto";
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import {
CLOSE_CODES,
+ docsUrl,
frameSchema,
+ type ErrorCode,
type Frame,
type Message,
} from "@relay/protocol";
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";
@@ -64,17 +66,17 @@ function send(socket: WebSocket, frame: Frame): void {
* 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",
+ docs_url: docsUrl("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}`,
@@ -84,28 +86,33 @@ function refuseUpgrade(socket: Duplex, decision: Decision): void {
"Connection: close",
"",
body,
].join("\r\n"),
);
socket.destroy();
}
+/** `ErrorCode`, not `string` (FR-025). Every code this function is
+ * given becomes a `docs_url`, so a typo used to ship a link to a page that could
+ * not exist — and the gateway is the surface where nobody sees a 404 until a
+ * customer clicks it. Narrowing the parameter is what makes the registry the
+ * vocabulary rather than a suggestion. */
function sendError(
socket: WebSocket,
- code: string,
+ code: ErrorCode,
message: string,
requestId: string = newRequestId(),
): void {
send(socket, {
type: "error",
payload: {
code,
message,
- docs_url: `https://relay.example/docs/errors/${code}`,
+ docs_url: docsUrl(code),
request_id: requestId,
},
});
}
export interface SessionServerOptions {
server: Server;
api: ApiClient;Narrowing sendError's parameter from string to ErrorCode was the one change
expected to break something, and it broke nothing: every existing call site already
used a registered code. The gate is there for the next one.
Chapter 3.13's test asked for a refused private channel to name the offending
field, and nothing in the platform had ever set one:
@@ -1,21 +1,42 @@
-import { BadRequestException, type PipeTransform } from "@nestjs/common";
+import type { PipeTransform } from "@nestjs/common";
+
+import { protocolError } from "../protocol-error";
import type { ZodType } from "zod";
// Boundary validation (chapter 2.2). safeParse, never parse: a throw
// from deep inside a library is not an error shape anyone can rely on.
// The BadRequestException carries the message; 1.4's ProtocolErrorFilter
// turns it into the EIR-API-04 envelope on the way out — one error shape,
// one home, unchanged since the skeleton.
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodType<T>) {}
transform(value: unknown): T {
const result = this.schema.safeParse(value);
if (!result.success) {
- throw new BadRequestException(
- result.error.issues[0]?.message ?? "invalid body",
+ const issue = result.error.issues[0];
+ // WHICH FIELD, and the isolation gauntlet is where that stopped being optional.
+ //
+ // EIR-API-04's error shape has carried a `field` since chapter 1.3 and
+ // `errorFrameSchema` declares it — and nothing in the api had ever set it.
+ // Every validation failure in twenty-two chapters said `Invalid input:
+ // expected "public"` and left the caller to work out which key that was
+ // about. This is the same habit as `request_id`, which was declared in 1.3
+ // and first sent in the rate-limit chapter: a field in the contract that the code never filled.
+ //
+ // Named here rather than in the filter because only the pipe knows the
+ // path. Zod's `path` is an array — `["metadata", "blob"]` — and it joins
+ // with dots, which is what a developer reading their own request body sees.
+ // An empty path means the whole body failed (a non-object, say), and then
+ // there is no field to name and the key is omitted rather than sent empty.
+ const path = issue?.path.join(".");
+ throw protocolError(
+ "invalid_request",
+ issue?.message ?? "invalid body",
+ 400,
+ path !== undefined && path.length > 0 ? path : undefined,
);
}
return result.data;
}
}issues[0].path is an array — ["metadata", "blob"] — and it joins with dots,
which is what a developer reading their own request body sees. An empty path means
the whole body failed, and then there is no field to name and the key is omitted
rather than sent empty.
packages/service-kit declares no dependencies at all, which is the property
that lets anything use it. Its not-found envelope needs a docs_url, and the
registry that owns URLs lives in @relay/protocol.
@@ -50,26 +50,44 @@ export function newRequestId(): string {
return randomUUID();
}
export interface ServeOptions {
service: string;
/** Extra fields merged into the /healthz payload. */
health: () => Record<string, unknown>;
logger?: Logger;
+ /** The `docs_url` for the not-found envelope this server answers unknown routes
+ * with (FR-027).
+ *
+ * REQUIRED, AND THE DEPENDENCY INVERTS RATHER THAN BEING ADDED. The obvious move
+ * is to import `docsUrl` from `@relay/protocol` here — and this package declares
+ * NO dependencies at all, which is the property that lets anything use it. So the
+ * caller supplies the URL instead, and because the field is required the compiler
+ * makes it do so: `serve()` has exactly one caller and cannot be given a stale
+ * placeholder by accident.
+ *
+ * Optional would have been the fourth instance of this chapter's own subject —
+ * `rate_limited`, close code 4008 and `request_id` were all declared and left
+ * unenforced, and an optional field with a default host is a placeholder with a
+ * longer life. */
+ notFoundDocsUrl: string;
}
/** Build (but do not start) a service's HTTP server: every response carries
* X-Request-Id (EIR-API-05), every request logs exactly one structured line
* carrying the same id (NFR-OBS-06's grep-ability starts here), GET /healthz
* answers with the service's health payload, and unknown routes get the
- * EIR-API-04 error shape. The docs_url host is a placeholder until the docs
- * site exists — constitution V's reachable-page promise lands with it. */
+ * EIR-API-04 error shape.
+ *
+ * The docs_url is no longer a placeholder — the isolation gauntlet made it a required option
+ * and the caller derives it from `@relay/protocol`'s registry, which is how a
+ * package with no dependencies can still emit a URL the registry owns. */
export function serve(options: ServeOptions): Server {
- const { service, health } = options;
+ const { service, health, notFoundDocsUrl } = options;
const logger = options.logger ?? createLogger(service);
return createServer((req, res) => {
const requestId = newRequestId();
const path = req.url ?? "/";
res.setHeader("X-Request-Id", requestId);
res.setHeader("content-type", "application/json");
let status: number;
@@ -77,17 +95,17 @@ export function serve(options: ServeOptions): Server {
if (req.method === "GET" && path === "/healthz") {
status = 200;
body = { status: "ok", service, ...health() };
} else {
status = 404;
body = {
code: "not_found",
message: `no route for ${req.method ?? "?"} ${path}`,
- docs_url: "https://relay.example/docs/errors/not_found",
+ docs_url: notFoundDocsUrl,
// 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;
res.end(JSON.stringify(body));@@ -1,9 +1,9 @@
-import { CLOSE_CODES, frameSchema } from "@relay/protocol";
+import { CLOSE_CODES, frameSchema, docsUrl } from "@relay/protocol";
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
@@ -22,16 +22,19 @@ export function createServer(logger?: Logger) {
const log = logger ?? createLogger("gateway");
const server = serve({
service: "gateway",
health: () => ({
uptime_s: Math.round(process.uptime()),
protocol: { frames, close_codes: closeCodes },
}),
logger: log,
+ // The registry owns the URL; `service-kit` owns no dependencies. So the URL
+ // crosses the boundary as data (FR-027, R9).
+ notFoundDocsUrl: docsUrl("not_found"),
});
// The socket server rides the SAME listener as health — one port, two
// protocols, which is what an upgrade handshake is for.
// Every instance is both publisher and subscriber: there is no leader
// here, and no instance knows how many others exist (ADR-07). Scaling
// out is adding a process.
const fanout = createFanout({ logger: log });
// A SECOND Redis client, not fanout's — one of fanout's two is aThe compiler named every call site — one in production and eight in tests:
@@ -1,17 +1,19 @@
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 { CLOSE_CODES, type Frame } from "@relay/protocol";
+import { CLOSE_CODES, type Frame,
+ docsUrl,
+} 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";
@@ -168,16 +170,17 @@ async function boot(
fanout?: Fanout,
resumeDeadlineMs?: number,
limits?: GatewayLimits,
): Promise<Harness> {
const server: Server = serve({
service: "gateway",
health: () => ({}),
logger: silent,
+ notFoundDocsUrl: docsUrl("not_found"),
});
const sessions = attachSessions({
server,
api,
logger: silent,
...(fanout !== undefined && { fanout }),
...(pingIntervalMs !== undefined && { pingIntervalMs }),
...(resumeDeadlineMs !== undefined && { resumeDeadlineMs }),+import { docsUrl } from "@relay/protocol";
const server: Server = serve({
service: "gateway",
health: () => ({}),
logger: silent,
+ notFoundDocsUrl: docsUrl("not_found"),
}); server = serve({
service: "gateway",
health: () => ({}),
logger: silent,
notFoundDocsUrl: docsUrl("not_found"),
});The reference is docs/08-error-reference.md: one h2 per code, the heading being
the code verbatim, each with what it means, what caused it, and what a client should
do. Every entry says whether it is retryable, because a client that retries a
refusal it can never satisfy waits for ever and one that gives up on a transient one
loses a message.
check-error-codes: 13 codes, 13 sections, each with a cause and a client action
The platform half of that check lives with the registry, because it can be self-contained — no file outside the workspace, so no turbo cache hole and no dependency on the parent repository:
import { describe, expect, it } from "vitest";
import { CLOSE_CODES, ERROR_CODES, docsUrl, DEFAULT_DOCS_BASE_URL } from "./codes.js";
// The failure vocabulary stays coherent: EIR-WS-06's four classes are all
// present, exactly once, with distinct meanings — and error codes never
// collide or go blank as chapters add to the registry.
describe("close codes cover EIR-WS-06's four classes", () => {
it("contains exactly 4001, 4002, 4008, 4009", () => {
expect(Object.keys(CLOSE_CODES).map(Number).sort()).toEqual([
4001, 4002, 4008, 4009,
]);
});
it("gives every code a distinct, non-empty meaning", () => {
const meanings = Object.values(CLOSE_CODES);
expect(new Set(meanings).size).toBe(meanings.length);
for (const meaning of meanings) expect(meaning.length).toBeGreaterThan(0);
});
});
describe("error codes stay unique and described", () => {
it("has no duplicate or empty descriptions", () => {
const descriptions = Object.values(ERROR_CODES);
expect(new Set(descriptions).size).toBe(descriptions.length);
for (const d of descriptions) expect(d.length).toBeGreaterThan(0);
});
it("uses snake_case machine-readable keys (EIR-API-04)", () => {
for (const code of Object.keys(ERROR_CODES)) {
expect(code).toMatch(/^[a-z][a-z_]*$/);
}
});
});
// ── THE PLATFORM HALF OF THE CLOSURE CHECK (FR-025, SC-011) ─────
//
// Every code the platform can emit is in `ERROR_CODES`. The tutorial repository
// holds the other half — that every code has a section in the published reference,
// and that every section names a code that exists — and it lives there rather than
// here for two measured reasons: `docs/` sits above `$TURBO_ROOT$` so it cannot be
// a turbo input, and a gate whose input turbo cannot see passes from cache after
// the reference changes; and `relay-platform` is independently clonable with a
// README promising its checks pass from a clean checkout, where `../docs` does not
// exist.
//
// What CAN be checked here is the registry's own closure: `docsUrl` accepts only
// `ErrorCode`, `ProtocolErrorFilter`'s ladder is typed `ErrorCode`, and
// `protocolError` and the gateway's `sendError` both take `ErrorCode` — so a code
// that is not in this object cannot be constructed anywhere in the platform without
// failing the build. This suite checks the shape of the object those types rest on.
describe("the registry is the whole vocabulary (FR-024)", () => {
it("holds thirteen codes", () => {
// A number, so adding one is a visible edit rather than a silent widening. The
// count is here and not in a comment because a comment does not fail.
expect(Object.keys(ERROR_CODES)).toHaveLength(13);
});
it("contains the five the status ladder emits", () => {
// `ProtocolErrorFilter` maps a status to one of these when a thrower names no
// code. All five went out on the wire for twenty-two chapters while absent
// from this object — and `docs_url` is derived from the code, so each one
// shipped a link to a page that could not exist.
for (const code of [
"invalid_request",
"unauthorized",
"forbidden",
"not_found",
"internal_error",
]) {
expect(ERROR_CODES, code).toHaveProperty(code);
}
});
it("contains every code the socket surface sends", () => {
for (const code of ["invalid_frame", "unknown_frame_type", "rate_limited", "quota_exceeded"]) {
expect(ERROR_CODES, code).toHaveProperty(code);
}
});
it("builds a docs_url whose fragment is the code verbatim", () => {
// No slug transform, in either direction. The reference's `h2` headings ARE
// the codes, and `slugifyHeading` in the tutorial site keeps `_` so that
// stays true — a transform here would be the same transform maintained in two
// repositories with no test able to see both sides.
for (const code of Object.keys(ERROR_CODES) as (keyof typeof ERROR_CODES)[]) {
expect(docsUrl(code).endsWith(`#${code}`), code).toBe(true);
}
});
it("reads the base URL per call, not at import", () => {
const before = process.env["RELAY_DOCS_BASE_URL"];
try {
process.env["RELAY_DOCS_BASE_URL"] = "https://preview.example/errors";
expect(docsUrl("not_found")).toBe("https://preview.example/errors#not_found");
} finally {
if (before === undefined) delete process.env["RELAY_DOCS_BASE_URL"];
else process.env["RELAY_DOCS_BASE_URL"] = before;
}
expect(docsUrl("not_found")).toBe(`${DEFAULT_DOCS_BASE_URL}#not_found`);
});
});The count is asserted rather than left in a comment, because a comment does not
fail. And the base URL is checked for being read per call, which is the one thing
about docsUrl a reader would not guess.
The check compares the registry against the headings in both directions. A code
with no section fails, because its docs_url would 404. And a section naming no
code fails too, because a reference documenting a retired code is how a
documentation set starts lying. Both demonstrated and reverted:
$ # a fourteenth code with no section
check-error-codes: these codes have no section in the reference, so their
docs_url 404s: probe_code_with_no_page
$ echo $?
1
$ # a section naming no code
check-error-codes: these sections name no code in ERROR_CODES — remove them
or the reference is lying: retired_code
$ echo $?
1
And the URL was fetched rather than pattern-matched. A live api with
RELAY_DOCS_BASE_URL pointed at a served site, three real error responses, and each
fragment checked against the id attributes in the returned HTML:
unauthorized → …/error-reference#unauthorized RESOLVES
not_found → …/error-reference#not_found RESOLVES
invalid_request → …/error-reference#invalid_request RESOLVES
{
"code": "invalid_request",
"message": "Invalid input: expected \"public\"",
"docs_url": "http://localhost:3999/docs/error-reference#invalid_request",
"request_id": "3b3c88fc-cee6-4c72-9b21-5817d929e9c4",
"field": "type"
}Five fields, and the fifth is the one chapter 3.13 asked for.