Files
zitadel/apps/docs/scripts/generate-error-reference.ts
T
e8d95f306b docs: add searchable API error reference (#12502)
## Summary
- Add a searchable catalog of every error ID ZITADEL's gRPC/Connect APIs
can return (`content/apis/errors.mdx`), wired into the sidebar next to
"gRPC Status Codes" (with a cross-link added there too). There was
previously no such reference — error IDs are assigned ad hoc at ~3,800
call sites across the backend.
- `scripts/generate-error-reference.ts` scans `internal/**/*.go` and
`backend/**/*.go` for `zerrors.Throw*()` call sites, resolves i18n
messages, and clusters ~3,200 distinct IDs into ~1,160 distinct causes
by (subsystem, gRPC code, message) — so identical causes are merged into
one entry instead of repeated ~3,200 times.
- Detects real ID collisions (same ID thrown for genuinely different
reasons at different call sites) and surfaces them as warnings, rather
than silently merging them.
- Generates a worked example API response for every cause.
- 116 of the highest-impact causes (covering the majority of call sites)
carry an explanation written against the actual source rather than the
raw message text; the rest carry a mechanically derived explanation —
both are visibly labeled by source so the depth of research is
transparent.
- New `ErrorReference` component renders the catalog as a searchable,
collapsible subsystem → gRPC code → cause tree.

The generator is a standalone script (`pnpm generate:error-reference` in
`apps/docs`), not wired into the docs build's `generate` chain, since
scanning the whole Go backend on every docs build would be wasteful. The
generated `data.json` and the curated overrides are committed, the same
way `content/apis/benchmarks/**/output.json` is already committed
despite being externally generated.

## Test plan
- [x] `pnpm nx run @zitadel/docs:check-types` passes
- [x] `pnpm nx run @zitadel/docs:lint` passes
- [x] `pnpm nx run @zitadel/docs:check-links` passes
- [x] Verified locally in `pnpm nx run @zitadel/docs:dev`: page renders,
tree expands/collapses, search filters by ID/message, collision warnings
render correctly (e.g. `AUTHZ-Kl3p0`)

---------

Co-authored-by: Rajat Singh <[email protected]>
Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Marco A. <[email protected]>
2026-09-08 09:47:58 +00:00

571 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Scans the Go backend for zerrors.Throw<Kind>[f]("ID", "message") call
// sites and emits apps/docs/components/ErrorReference/data.json: a
// 3-level tree (subsystem -> gRPC kind -> cluster) that the ErrorReference
// component renders. Regenerate after backend error call sites change:
// pnpm nx run @zitadel/docs:generate-error-reference (from repo root)
// pnpm generate:error-reference (from apps/docs)
// This file is TypeScript/ESM (run via tsx, see package.json) — plain
// `node` won't execute it without a loader.
//
// This is intentionally NOT wired into the Nx `generate` chain (which
// `dev`/`build`/`lint` all depend on) — scanning 2000+ backend Go files on
// every docs build would cache-invalidate the whole site on unrelated
// backend PRs. Output (data.json + overrides) is committed instead, the
// same way apps/docs/content/apis/benchmarks/**/output.json is committed
// despite being externally generated.
import { readFileSync, writeFileSync, mkdirSync, readdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import { globSync } from 'glob';
import yaml from 'js-yaml';
import { GRPC_STATUS, THROW_KIND_TO_GRPC_STATUS } from '../lib/grpc-status';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '../../..');
const OUTPUT_PATH = join(__dirname, '../components/ErrorReference/data.json');
const OVERRIDES_DIR = join(__dirname, 'error-reference-overrides');
// ---------------------------------------------------------------------------
// Types (mirrored, informally, by apps/docs/components/ErrorReference/index.tsx)
// ---------------------------------------------------------------------------
interface RawEntry {
id: string;
kind: string; // GRPC_STATUS key
message: string;
messageType: 'i18n' | 'literal';
i18nResolved?: boolean;
file: string;
line: number;
}
interface ErrorLocation {
file: string;
line: number;
}
interface ErrorIdEntry {
id: string;
locations: ErrorLocation[];
}
interface ExampleResponse {
httpStatus: number;
grpcCode: number;
body: {
code: number;
message: string;
details: [{ '@type': string; id: string; message: string }];
};
}
interface ErrorCluster {
key: string;
message: string;
messageType: 'i18n' | 'literal';
ids: ErrorIdEntry[];
why: string;
whySource: 'curated' | 'i18n-decomposition' | 'defensive-guard-template' | 'generic-fallback';
example: ExampleResponse;
collidesWith: { key: string; message: string; subsystem: string }[];
}
interface KindBucket {
kind: string;
clusters: ErrorCluster[];
}
interface Subsystem {
id: string;
name: string;
summary: string;
kinds: KindBucket[];
}
interface ErrorReferenceData {
meta: {
generatedAt: string;
sourceRef: string;
totalCallSites: number;
totalIds: number;
totalClusters: number;
totalCollisions: number;
};
subsystems: Subsystem[];
}
// ---------------------------------------------------------------------------
// Stage: subsystem mapping (directory prefix -> subsystem id)
// ---------------------------------------------------------------------------
const SUBSYSTEMS: { id: string; name: string; summary: string }[] = [
{
id: 'command',
name: 'Command layer',
summary:
'internal/command is the write side of ZITADEL’s CQRS/event-sourcing core: nearly every state-changing gRPC/Connect call resolves to a Commands method covering users, orgs, instances, projects, IDPs, policies, providers, quotas, actions/executions, sessions, and groups. Errors cluster into three layers mapped to distinct gRPC codes: InvalidArgument for cheap syntactic validation before any eventstore read, NotFound/AlreadyExists for aggregate-existence checks against replayed write-model state, and PreconditionFailed for state-conditional rules discovered mid-command (not changed, already active, lockouts).',
},
{
id: 'query',
name: 'Query layer & legacy read models',
summary:
'internal/query is the current read side (Postgres projections), alongside the legacy internal/auth/internal/user/internal/view read-model stack it is gradually replacing. Errors here are dominated by NotFound (a projection row doesn’t exist or hasn’t caught up yet) and Internal (SQL statement construction / scan failures).',
},
{
id: 'api',
name: 'API surface, authn/authz, and identity federation',
summary:
'internal/api (gRPC/Connect handlers, REST gateway, login UI), internal/authz (token/permission checks), internal/idp (external identity provider integrations), and internal/webauthn sit at the transport/identity boundary. Errors here span request-shape validation, auth/session failures, and third-party IdP response handling.',
},
{
id: 'storage',
name: 'Storage/eventstore infrastructure & the new v3 backend',
summary:
'internal/eventstore (event append/query, Postgres-backed), internal/static (asset storage), and backend/v3 (the newer hexagonal-architecture, relational-storage-backed rewrite) — infrastructure errors: connection failures, constraint violations, unmarshal guards on stored event payloads.',
},
{
id: 'scim',
name: 'SCIM provisioning',
summary:
'internal/api/scim implements SCIM 2.0 user/group provisioning. Errors mirror the SCIM spec’s own error model (invalid filters, invalid paths, bulk-operation limits) more than ZITADEL’s general gRPC error shape.',
},
{
id: 'notification',
name: 'Notification channels',
summary:
'internal/notification renders and sends emails/SMS (templates, SMTP/Twilio/webhook providers). Errors are mostly provider-configuration and template-rendering failures.',
},
{
id: 'crypto',
name: 'Cryptography & key management',
summary:
'internal/crypto covers encryption, hashing, and key/secret generation used throughout the rest of the backend. Errors here are almost entirely configuration/algorithm mismatches, not runtime request data.',
},
{
id: 'domain',
name: 'Domain value objects, legacy event payloads, and misc infra',
summary:
'internal/domain (value-object validation), internal/repository (event definitions/unmarshalling for most aggregates), internal/execution, and other infra that doesn’t fit the groups above. A large share of this group is Internal-coded defensive guards inside event-unmarshalling code that only fire on event-schema corruption, not from ordinary API usage.',
},
];
function subsystemForPath(relPath: string): string {
if (relPath.startsWith('internal/api/scim/')) return 'scim';
if (relPath.startsWith('internal/notification/')) return 'notification';
if (relPath.startsWith('internal/crypto/')) return 'crypto';
if (
relPath.startsWith('internal/eventstore/') ||
relPath.startsWith('internal/static/') ||
relPath.startsWith('backend/v3/')
)
return 'storage';
if (
relPath.startsWith('internal/api/') ||
relPath.startsWith('internal/authz/') ||
relPath.startsWith('internal/idp/') ||
relPath.startsWith('internal/webauthn/')
)
return 'api';
if (
relPath.startsWith('internal/query/') ||
relPath.startsWith('internal/auth/') ||
relPath.startsWith('internal/user/') ||
relPath.startsWith('internal/view/') ||
relPath.includes('/repository/view/') // legacy view-model repos for org/project/iam/user aggregates
)
return 'query';
if (relPath.startsWith('internal/command/')) return 'command';
// internal/repository/{org,instance,project} hold the event definitions
// (including unmarshal-guard errors) for the aggregates internal/command
// writes to — they're the write model's event payloads, not a read model,
// so route them to 'command' too. ID prefix isn't a reliable signal for
// this: internal/repository/instance mostly throws INST-, not INSTANCE-,
// and internal/repository/project also throws APPLICATION-/API-/OIDC-/
// SAML-/USER-, not just PROJECT-.
if (
relPath.startsWith('internal/repository/org/') ||
relPath.startsWith('internal/repository/instance/') ||
relPath.startsWith('internal/repository/project/')
)
return 'command';
return 'domain';
}
// ---------------------------------------------------------------------------
// Stage: scan .go files for zerrors.Throw*() call sites
// ---------------------------------------------------------------------------
const KIND_ALIASES: Record<string, keyof typeof GRPC_STATUS> = {
...THROW_KIND_TO_GRPC_STATUS,
Error: 'UNKNOWN', // ThrowError always creates KindUnknown (see internal/zerrors/zerror.go)
};
const ID_SHAPE = /^[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]+$/;
const CALL_RE = /zerrors\.Throw([A-Z][A-Za-z]*?)(f)?\(/g;
// Reads up to 2 top-level (paren-depth-1) double-quoted string literals
// starting right after a Throw*( call's opening paren.
function extractTopLevelStrings(content: string, startIdx: number): string[] {
const strings: string[] = [];
let depth = 1;
let i = startIdx;
const limit = Math.min(content.length, startIdx + 4000);
while (i < limit && depth > 0 && strings.length < 2) {
const ch = content[i];
if (ch === '"' && depth === 1) {
let j = i + 1;
let raw = '';
while (j < content.length && content[j] !== '"') {
if (content[j] === '\\') {
raw += content[j] + content[j + 1];
j += 2;
} else {
raw += content[j];
j++;
}
}
try {
strings.push(JSON.parse('"' + raw + '"'));
} catch {
strings.push(raw);
}
i = j + 1;
continue;
}
if (ch === '(') depth++;
else if (ch === ')') depth--;
i++;
}
return strings;
}
function lineOf(content: string, idx: number): number {
let line = 1;
for (let i = 0; i < idx; i++) if (content[i] === '\n') line++;
return line;
}
// True if `idx` is textually preceded, on its own source line, by a `//`
// comment marker — i.e. the call site is commented out. This is a line-level
// heuristic, not real Go parsing: it doesn't know whether an earlier `//` on
// the line sits inside a string literal, and it doesn't handle `/* */` block
// comments. Neither case is a realistic way to write commented-out Go code,
// so this is enough to skip the actual failure mode (a fully commented-out
// `// zerrors.ThrowX(...)` line) without the cost of a real parser.
function isCommentedOut(content: string, idx: number): boolean {
const lineStart = content.lastIndexOf('\n', idx) + 1;
return content.slice(lineStart, idx).includes('//');
}
function scanFile(absPath: string, relPath: string): RawEntry[] {
const content = readFileSync(absPath, 'utf8');
const entries: RawEntry[] = [];
let m: RegExpExecArray | null;
CALL_RE.lastIndex = 0;
while ((m = CALL_RE.exec(content))) {
if (isCommentedOut(content, m.index)) continue;
const kindRaw = m[1];
const grpcKey = KIND_ALIASES[kindRaw];
if (!grpcKey) continue; // not a real Throw<Kind> (defensive)
const strings = extractTopLevelStrings(content, CALL_RE.lastIndex);
if (strings.length < 2 || !ID_SHAPE.test(strings[0])) continue;
const [id, message] = strings;
const messageType: 'i18n' | 'literal' = /^[A-Za-z][A-Za-z0-9]*(\.[A-Za-z][A-Za-z0-9]*)+$/.test(message)
? 'i18n'
: 'literal';
entries.push({
id,
kind: grpcKey,
message,
messageType,
file: relPath,
line: lineOf(content, m.index),
});
}
return entries;
}
// ---------------------------------------------------------------------------
// Stage: i18n resolution
// ---------------------------------------------------------------------------
function loadYaml(relPath: string): any {
try {
return yaml.load(readFileSync(join(REPO_ROOT, relPath), 'utf8'));
} catch {
return {};
}
}
const I18N_BUNDLES = [
loadYaml('internal/static/i18n/en.yaml'),
loadYaml('internal/api/ui/login/static/i18n/en.yaml'),
loadYaml('internal/notification/static/i18n/en.yaml'),
];
function resolveI18n(key: string): string | undefined {
for (const bundle of I18N_BUNDLES) {
let node: any = bundle;
for (const segment of key.split('.')) {
if (node == null || typeof node !== 'object') {
node = undefined;
break;
}
node = node[segment];
}
if (typeof node === 'string') return node;
}
return undefined;
}
// ---------------------------------------------------------------------------
// Stage: "why" generation (mechanical baseline — curated overrides applied later)
// ---------------------------------------------------------------------------
const GUARD_PATTERNS = [/^unable to unmarshal /i, /^reduce\.wrong\.event\.type/i, /^cannot scan type /i, /^could not unmarshal /i];
const KIND_WHY_VERB: Record<string, (subject: string) => string> = {
NOT_FOUND: (s) => `${s} could not be found.`,
ALREADY_EXISTS: (s) => `${s} already exists.`,
INVALID_ARGUMENT: (s) => `The request was rejected because ${s.toLowerCase()} is missing or invalid.`,
FAILED_PRECONDITION: (s) => `The request was rejected because of ${s.toLowerCase()} — the current state doesn’t allow this operation.`,
PERMISSION_DENIED: (s) => `The caller is authenticated but not authorized: ${s.toLowerCase()}.`,
UNAUTHENTICATED: (s) => `The caller could not be authenticated: ${s.toLowerCase()}.`,
RESOURCE_EXHAUSTED: (s) => `A limit was exceeded: ${s.toLowerCase()}.`,
UNIMPLEMENTED: (s) => `${s} is not supported on this code path.`,
INTERNAL: (s) => `Internal failure related to ${s.toLowerCase()}.`,
};
function humanizeI18nKey(key: string): string {
const segments = key.split('.').filter((s) => s.toLowerCase() !== 'errors');
const words = segments
.map((seg) => seg.replace(/([a-z0-9])([A-Z])/g, '$1 $2'))
.join(' ')
.trim();
return words.length > 0 ? `The ${words.toLowerCase()}` : 'This condition';
}
function mechanicalWhy(kind: string, message: string, messageType: 'i18n' | 'literal', i18nResolved: boolean): {
why: string;
whySource: ErrorCluster['whySource'];
} {
if (GUARD_PATTERNS.some((re) => re.test(message))) {
return {
why: 'Internal defensive guard around event/projection unmarshalling — not expected to occur from ordinary API usage. Indicates event-schema corruption or a routing bug if it does.',
whySource: 'defensive-guard-template',
};
}
// Only decompose while `message` is still the raw dotted key (unresolved).
// Once resolveI18n() has replaced it with the actual translated English
// sentence, decomposing that sentence as if it were a key produces mangled
// prose (e.g. "Errors.Internal" -> "An internal error occurred" fed back
// through humanizeI18nKey reads as "the an internal error occurred").
if (messageType === 'i18n' && !i18nResolved) {
const subject = humanizeI18nKey(message);
const verb = KIND_WHY_VERB[kind];
return {
why: verb ? verb(subject) : `${subject} (${kind}).`,
whySource: 'i18n-decomposition',
};
}
return {
why: `${kind} error: “${message}” — see the linked source location(s) for the exact trigger condition.`,
whySource: 'generic-fallback',
};
}
// ---------------------------------------------------------------------------
// Main pipeline
// ---------------------------------------------------------------------------
function main() {
const files = globSync(['internal/**/*.go', 'backend/**/*.go'], {
cwd: REPO_ROOT,
ignore: ['**/*_test.go'],
});
const rawEntries: RawEntry[] = [];
for (const relPath of files) {
const abs = join(REPO_ROOT, relPath);
rawEntries.push(...scanFile(abs, relPath));
}
// Resolve i18n text used for clustering/display, without discarding the raw key.
for (const e of rawEntries) {
if (e.messageType === 'i18n') {
const resolved = resolveI18n(e.message);
e.i18nResolved = resolved !== undefined;
if (resolved !== undefined) e.message = resolved;
}
}
// Cluster by (subsystem, kind, trimmed message).
const clusterMap = new Map<
string,
{
subsystem: string;
kind: string;
message: string;
messageType: 'i18n' | 'literal';
i18nResolved: boolean;
entries: RawEntry[];
}
>();
for (const e of rawEntries) {
const subsystem = subsystemForPath(e.file);
const message = e.message.trim();
const key = `${subsystem}|${e.kind}|${message}`;
if (!clusterMap.has(key)) {
clusterMap.set(key, {
subsystem,
kind: e.kind,
message,
messageType: e.messageType,
i18nResolved: e.i18nResolved ?? false,
entries: [],
});
}
clusterMap.get(key)!.entries.push(e);
}
// Collision detection: which cluster keys does each raw id appear in?
const idToClusterKeys = new Map<string, Set<string>>();
for (const [key, c] of clusterMap) {
for (const e of c.entries) {
if (!idToClusterKeys.has(e.id)) idToClusterKeys.set(e.id, new Set());
idToClusterKeys.get(e.id)!.add(key);
}
}
// Load curated overrides (clusterKey -> { why }).
const overrides = new Map<string, { why: string }>();
if (existsSync(OVERRIDES_DIR)) {
for (const f of readdirSync(OVERRIDES_DIR).filter((f: string) => f.endsWith('.json'))) {
const parsed = JSON.parse(readFileSync(join(OVERRIDES_DIR, f), 'utf8'));
for (const [key, val] of Object.entries<{ why: string }>(parsed)) overrides.set(key, val);
}
}
const subsystems: Subsystem[] = SUBSYSTEMS.map((s) => ({ ...s, kinds: [] }));
const subsystemIndex = new Map(subsystems.map((s) => [s.id, s]));
let totalClusters = 0;
let totalCollisions = 0;
for (const [key, c] of clusterMap) {
totalClusters++;
const idMap = new Map<string, ErrorIdEntry>();
for (const e of c.entries) {
if (!idMap.has(e.id)) idMap.set(e.id, { id: e.id, locations: [] });
idMap.get(e.id)!.locations.push({ file: e.file, line: e.line });
}
const ids = [...idMap.values()].sort((a, b) => a.id.localeCompare(b.id));
// A cluster key already bakes in (subsystem, kind, message), so the same
// id turning up under a different key is only a *real* collision — i.e.
// the id means something genuinely different depending on call site — if
// the message actually differs. Without this check, an id copy-pasted
// verbatim into files that happen to sit in two different subsystems
// would be flagged as colliding with itself.
const collidesWith: ErrorCluster['collidesWith'] = [];
const seenCollisionKeys = new Set<string>();
for (const idEntry of ids) {
for (const otherKey of idToClusterKeys.get(idEntry.id)!) {
if (otherKey === key || seenCollisionKeys.has(otherKey)) continue;
const other = clusterMap.get(otherKey)!;
if (other.message === c.message) continue;
seenCollisionKeys.add(otherKey);
collidesWith.push({ key: otherKey, message: other.message, subsystem: other.subsystem });
}
}
if (collidesWith.length > 0) totalCollisions++;
const override = overrides.get(key);
const mech = mechanicalWhy(c.kind, c.message, c.messageType, c.i18nResolved);
const why = override?.why ?? mech.why;
const whySource: ErrorCluster['whySource'] = override ? 'curated' : mech.whySource;
const status = GRPC_STATUS[c.kind];
const repId = ids[0].id;
// The server always appends " (ID)" to the top-level message (see
// internal/api/grpc/gerrors/zitadel_errors.go: msg := key; msg += " (" + id + ")").
// The nested details[].message is the plain text without that suffix.
const example: ExampleResponse = {
httpStatus: status.httpStatus,
grpcCode: status.code,
body: {
code: status.code,
message: `${c.message} (${repId})`,
details: [{ '@type': 'type.googleapis.com/zitadel.v1.ErrorDetail', id: repId, message: c.message }],
},
};
const cluster: ErrorCluster = {
key,
message: c.message,
messageType: c.messageType,
ids,
why,
whySource,
example,
collidesWith,
};
const subsystem = subsystemIndex.get(c.subsystem)!;
let bucket = subsystem.kinds.find((k) => k.kind === c.kind);
if (!bucket) {
bucket = { kind: c.kind, clusters: [] };
subsystem.kinds.push(bucket);
}
bucket.clusters.push(cluster);
}
// Sort clusters within each kind bucket by size descending (most common first),
// then sort kind buckets within each subsystem by total id count descending.
for (const s of subsystems) {
for (const bucket of s.kinds) {
bucket.clusters.sort((a, b) => b.ids.length - a.ids.length || a.message.localeCompare(b.message));
}
s.kinds.sort((a, b) => {
const aCount = a.clusters.reduce((n, c) => n + c.ids.length, 0);
const bCount = b.clusters.reduce((n, c) => n + c.ids.length, 0);
return bCount - aCount;
});
}
subsystems.sort((a, b) => {
const count = (s: Subsystem) => s.kinds.reduce((n, k) => n + k.clusters.reduce((m, c) => m + c.ids.length, 0), 0);
return count(b) - count(a);
});
let sourceRef = 'main';
try {
sourceRef = execSync('git rev-parse HEAD', { cwd: REPO_ROOT }).toString().trim();
} catch {
// best-effort only
}
const data: ErrorReferenceData = {
meta: {
generatedAt: new Date().toISOString(),
sourceRef,
totalCallSites: rawEntries.length,
totalIds: idToClusterKeys.size,
totalClusters,
totalCollisions,
},
subsystems,
};
mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
writeFileSync(OUTPUT_PATH, JSON.stringify(data));
console.log(`[error-reference] ${files.length} files scanned`);
console.log(`[error-reference] ${rawEntries.length} call sites, ${idToClusterKeys.size} distinct IDs`);
console.log(`[error-reference] ${totalClusters} clusters, ${totalCollisions} with a real collision`);
console.log(`[error-reference] ${overrides.size} curated overrides applied`);
console.log(`[error-reference] wrote ${OUTPUT_PATH}`);
}
main();