@ultimat3/core 29.3.2 → 30.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +45 -81
- package/README.md +137 -10
- package/package.json +19 -3
- package/src/address-class.ts +53 -12
- package/src/aws-sigv4.ts +104 -8
- package/src/breaker.ts +177 -0
- package/src/client-dispatch.ts +76 -17
- package/src/client-flight.ts +4 -4
- package/src/client-paths-pins.ts +92 -0
- package/src/client-problem.ts +25 -1
- package/src/client-scope.ts +3 -1
- package/src/client-stream.ts +106 -0
- package/src/client-transport.ts +16 -2
- package/src/config-assets.ts +64 -0
- package/src/config-client-errors.ts +132 -0
- package/src/config-defaults.ts +18 -4
- package/src/config-failure-capture.ts +70 -0
- package/src/config-health.ts +8 -0
- package/src/config-islands.ts +1 -1
- package/src/config-keys.ts +2 -0
- package/src/config-site.ts +16 -0
- package/src/config-time.ts +40 -0
- package/src/config.ts +37 -26
- package/src/context.ts +92 -10
- package/src/core-error-codes.ts +19 -0
- package/src/cursor.ts +7 -3
- package/src/deadline-signal.ts +57 -0
- package/src/declared-errors-openapi.ts +70 -0
- package/src/declared-errors-scan.ts +79 -0
- package/src/declared-errors.ts +185 -0
- package/src/define-error.ts +200 -0
- package/src/deprecation.ts +14 -3
- package/src/drain-deadline.ts +22 -0
- package/src/env.ts +8 -3
- package/src/error-fingerprint.ts +95 -0
- package/src/error-reporter-chain.ts +45 -0
- package/src/error-reporter-sentry.ts +4 -0
- package/src/error-reporter.ts +14 -1
- package/src/error-status-declared.ts +82 -0
- package/src/exercise-errors.ts +41 -0
- package/src/exercise-run.ts +176 -0
- package/src/exports/error-contract.ts +7 -0
- package/src/exports/observability.ts +26 -0
- package/src/failure-capture-flush.ts +93 -0
- package/src/failure-capture-mask.ts +54 -0
- package/src/failure-capture.ts +205 -0
- package/src/fill-wire.ts +30 -0
- package/src/finite-option.ts +7 -0
- package/src/health-disclosure.ts +2 -2
- package/src/hmac.ts +47 -0
- package/src/index.ts +172 -9
- package/src/json-storable.ts +21 -0
- package/src/keyed-fingerprint.ts +3 -7
- package/src/lifecycle.ts +5 -1
- package/src/logger.ts +1 -1
- package/src/otlp-metric-exporter.ts +7 -1
- package/src/otlp-post.ts +147 -0
- package/src/otlp-span-exporter.ts +7 -1
- package/src/otlp.ts +4 -37
- package/src/outbound-attempt.ts +235 -0
- package/src/outbound-dial.ts +103 -0
- package/src/outbound-errors.ts +193 -0
- package/src/outbound-hops.ts +135 -0
- package/src/outbound-read.ts +103 -0
- package/src/outbound-subpath.ts +26 -0
- package/src/outbound-types.ts +108 -0
- package/src/outbound.ts +223 -0
- package/src/page-meta.ts +8 -0
- package/src/page.ts +9 -0
- package/src/record-envelope-openapi.ts +6 -0
- package/src/record-envelope.ts +33 -9
- package/src/record-sink.ts +13 -0
- package/src/redis-client.ts +78 -0
- package/src/registrar.ts +11 -0
- package/src/render-node.ts +13 -0
- package/src/render-nodes.ts +137 -0
- package/src/retry-loop.ts +197 -0
- package/src/retry.ts +47 -145
- package/src/roles.ts +2 -2
- package/src/route-path-merge.ts +55 -0
- package/src/route-path-template.ts +124 -0
- package/src/runtime-metrics.ts +16 -1
- package/src/same-origin.ts +18 -2
- package/src/session-lost.ts +115 -0
- package/src/single-flight.ts +75 -40
- package/src/source-mask.ts +36 -2
- package/src/tool-description.ts +29 -0
- package/src/visible-interval.ts +153 -0
- package/src/webhook-signature.ts +9 -11
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Single responsibility: an action's or a query's `errors:` list — what may be listed, which throw
|
|
2
|
+
// out of a handler the list judges, and what a test run and a production run do with a miss. Here
|
|
3
|
+
// and not in `@ultimat3/action` because `@ultimat3/query` needs the same rule and tiers never go
|
|
4
|
+
// sideways; the HTTP status is passed in because `@ultimat3/http` sits above this package.
|
|
5
|
+
|
|
6
|
+
import { FRAMEWORK_CODE } from './client-wire';
|
|
7
|
+
import { tryResolveEnvironment } from './environment';
|
|
8
|
+
import { renderCauseValue } from './error-render';
|
|
9
|
+
import { isUltimateError, UltimateError } from './errors';
|
|
10
|
+
import { logger } from './logger';
|
|
11
|
+
import { counter } from './metrics';
|
|
12
|
+
|
|
13
|
+
/** The status a code answers, `undefined` when nothing declared one: `@ultimat3/http`'s reader. */
|
|
14
|
+
export type ErrorStatusOf = (code: string) => number | undefined;
|
|
15
|
+
|
|
16
|
+
/** Which declaration a verdict is about — the two primitives that carry `errors:`. */
|
|
17
|
+
export interface DeclaredErrorsOwner {
|
|
18
|
+
readonly primitive: 'action' | 'query';
|
|
19
|
+
readonly name: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The process's lifetime, never a refusal a handler chose: a drain or an abort reaches every
|
|
24
|
+
* handler alike, so listing them on each would be a list nobody could keep true.
|
|
25
|
+
*/
|
|
26
|
+
export const LIFETIME_ERROR_CODES: readonly string[] = ['X_ABORTED', 'X_DRAINING'];
|
|
27
|
+
|
|
28
|
+
/** `open` — no list declared; `exempt` — not a contract member; else the list's answer. */
|
|
29
|
+
export type DeclaredErrorVerdict = 'open' | 'exempt' | 'declared' | 'undeclared';
|
|
30
|
+
|
|
31
|
+
/** Is `status` a refusal the caller can branch on? A 5xx or no status is an outage. */
|
|
32
|
+
const isRefusal = (status: number | undefined): boolean =>
|
|
33
|
+
status !== undefined && status >= 400 && status <= 499;
|
|
34
|
+
|
|
35
|
+
/** The one judgement both halves make: the dynamic wrap and the contract's source scan. */
|
|
36
|
+
export function judgeThrown(
|
|
37
|
+
declared: readonly string[] | undefined,
|
|
38
|
+
code: string,
|
|
39
|
+
status: number | undefined,
|
|
40
|
+
): DeclaredErrorVerdict {
|
|
41
|
+
if (declared === undefined) return 'open';
|
|
42
|
+
if (LIFETIME_ERROR_CODES.includes(code) || !isRefusal(status)) return 'exempt';
|
|
43
|
+
return declared.includes(code) ? 'declared' : 'undeclared';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class ErrorUndeclaredError extends UltimateError {
|
|
47
|
+
static readonly code = 'X_ERROR_UNDECLARED';
|
|
48
|
+
override readonly name = 'ErrorUndeclaredError';
|
|
49
|
+
constructor(owner: DeclaredErrorsOwner, code: string, sourceError?: unknown) {
|
|
50
|
+
super({
|
|
51
|
+
code: ErrorUndeclaredError.code,
|
|
52
|
+
cause: `${owner.primitive} ${owner.name} threw ${code}, which its errors: list does not declare`,
|
|
53
|
+
fix: `errors: [ …, '${code}' ] # add it in the ${owner.name} definition, or stop throwing it there; then: x verify --only contract`,
|
|
54
|
+
meta: { primitive: owner.primitive, name: owner.name, undeclared: code },
|
|
55
|
+
sourceError,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class ActionErrorsInvalidError extends UltimateError {
|
|
61
|
+
static readonly code = 'X_ACTION_ERRORS_INVALID';
|
|
62
|
+
override readonly name = 'ActionErrorsInvalidError';
|
|
63
|
+
constructor(owner: string, listed: unknown, why: string) {
|
|
64
|
+
const shown = renderCauseValue(listed);
|
|
65
|
+
const code = typeof listed === 'string' && FRAMEWORK_CODE.test(listed) ? listed : '<CODE>';
|
|
66
|
+
super({
|
|
67
|
+
code: ActionErrorsInvalidError.code,
|
|
68
|
+
cause: `errors: in ${owner} lists ${shown}, which ${why}`,
|
|
69
|
+
fix: `defineError({ code: '${code}', title, status: 4xx, cause, fix }) # import it where ${owner} is declared, list its .code once; x errors explain ${code} --json`,
|
|
70
|
+
meta: { owner, listed: shown },
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The list as written, refused where it is written: malformed, unregistered, not a 4xx, or
|
|
77
|
+
* repeated. Answers a frozen, sorted copy — what the descriptor, the manifest and the OpenAPI
|
|
78
|
+
* document publish — so a caller mutating the literal it passed changes nothing that was checked.
|
|
79
|
+
*/
|
|
80
|
+
export function checkDeclaredErrors(
|
|
81
|
+
errors: readonly string[],
|
|
82
|
+
statusOf: ErrorStatusOf,
|
|
83
|
+
owner: string,
|
|
84
|
+
): readonly string[];
|
|
85
|
+
export function checkDeclaredErrors(
|
|
86
|
+
errors: readonly string[] | undefined,
|
|
87
|
+
statusOf: ErrorStatusOf,
|
|
88
|
+
owner: string,
|
|
89
|
+
): readonly string[] | undefined;
|
|
90
|
+
export function checkDeclaredErrors(
|
|
91
|
+
errors: readonly string[] | undefined,
|
|
92
|
+
statusOf: ErrorStatusOf,
|
|
93
|
+
owner: string,
|
|
94
|
+
): readonly string[] | undefined {
|
|
95
|
+
if (errors === undefined) return undefined;
|
|
96
|
+
if (!Array.isArray(errors)) throw new ActionErrorsInvalidError(owner, errors, 'is not an array');
|
|
97
|
+
const seen = new Set<string>();
|
|
98
|
+
for (const code of errors as readonly unknown[]) {
|
|
99
|
+
if (typeof code !== 'string' || !FRAMEWORK_CODE.test(code)) {
|
|
100
|
+
throw new ActionErrorsInvalidError(owner, code, 'is not an X_SCREAMING_SNAKE code');
|
|
101
|
+
}
|
|
102
|
+
if (seen.has(code)) throw new ActionErrorsInvalidError(owner, code, 'is listed twice');
|
|
103
|
+
const status = statusOf(code);
|
|
104
|
+
if (!isRefusal(status)) {
|
|
105
|
+
throw new ActionErrorsInvalidError(
|
|
106
|
+
owner,
|
|
107
|
+
code,
|
|
108
|
+
status === undefined ? 'has no registered HTTP status' : `answers ${status}, not a 4xx`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
seen.add(code);
|
|
112
|
+
}
|
|
113
|
+
return Object.freeze([...seen].sort());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const undeclaredErrors = counter('undeclared_errors_total', {
|
|
117
|
+
unit: '{error}',
|
|
118
|
+
description: 'Refusals a handler threw outside its declared errors: list, by primitive and name',
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Where a test or a dev run goes red. Anything that is not literally `test` or `development` —
|
|
123
|
+
* an unset environment included — takes the production branch: a bookkeeping miss must never turn
|
|
124
|
+
* a refusal into a 500 in front of a user.
|
|
125
|
+
*/
|
|
126
|
+
const raisesUndeclared = (): boolean => {
|
|
127
|
+
const environment = tryResolveEnvironment({ fallback: 'production' });
|
|
128
|
+
return environment === 'test' || environment === 'development';
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** The (primitive, name, code) triples already warned about in this process. */
|
|
132
|
+
const WARNED = new Set<string>();
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* What to throw in place of `error`, a throw out of a handler. The original, unless it is a 4xx
|
|
136
|
+
* code the list does not name: then `X_ERROR_UNDECLARED` in test and development (the
|
|
137
|
+
* original as its `sourceError`), and the original with a warning and a count in production.
|
|
138
|
+
*/
|
|
139
|
+
export function enforceDeclared(
|
|
140
|
+
error: unknown,
|
|
141
|
+
declared: readonly string[] | undefined,
|
|
142
|
+
statusOf: ErrorStatusOf,
|
|
143
|
+
owner: DeclaredErrorsOwner,
|
|
144
|
+
): unknown {
|
|
145
|
+
if (declared === undefined || !isUltimateError(error)) return error;
|
|
146
|
+
const { code } = error;
|
|
147
|
+
if (judgeThrown(declared, code, statusOf(code)) !== 'undeclared') return error;
|
|
148
|
+
if (raisesUndeclared()) return new ErrorUndeclaredError(owner, code, error);
|
|
149
|
+
undeclaredErrors.add(1, { primitive: owner.primitive, name: owner.name, code });
|
|
150
|
+
// One line per (primitive, name, code) per process: a hot endpoint is counted on every hit and
|
|
151
|
+
// logged once, never a line per request. The key space is bounded by the codebase.
|
|
152
|
+
const key = `${owner.primitive}\u0000${owner.name}\u0000${code}`;
|
|
153
|
+
if (!WARNED.has(key)) {
|
|
154
|
+
WARNED.add(key);
|
|
155
|
+
logger.warn('declared-errors.undeclared', { ...owner, code });
|
|
156
|
+
}
|
|
157
|
+
return error;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Run a handler under its list: the wrap `invoke` and the read path put around `handle`/`sql`. */
|
|
161
|
+
export async function withDeclaredErrors<T>(
|
|
162
|
+
declared: readonly string[] | undefined,
|
|
163
|
+
statusOf: ErrorStatusOf,
|
|
164
|
+
owner: DeclaredErrorsOwner,
|
|
165
|
+
run: () => T | Promise<T>,
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
try {
|
|
168
|
+
return await run();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
throw enforceDeclared(error, declared, statusOf, owner);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A tool's description, ending with the refusals its declaration lists — so an agent plans around
|
|
176
|
+
* a refusal before it calls. One sentence for `@ultimat3/mcp`'s tool and `@ultimat3/ai`'s, which
|
|
177
|
+
* the tier table keeps apart (`scripts/ai-mcp-tool-parity.test.ts`). Unchanged without a list.
|
|
178
|
+
*/
|
|
179
|
+
export function describeDeclaredErrors(
|
|
180
|
+
description: string,
|
|
181
|
+
errors: readonly string[] | undefined,
|
|
182
|
+
): string {
|
|
183
|
+
if (errors === undefined || errors.length === 0) return description;
|
|
184
|
+
return `${description} Declared refusals: ${errors.join(', ')}.`;
|
|
185
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Single responsibility: `defineError` — an APP code's one declaration: its title, its HTTP status
|
|
2
|
+
// (or `offSocket`), its retry, and the `UltimateError` subclass that throws it. Its own module so a
|
|
3
|
+
// chunk importing only `UltimateError` never retains it (`define-error-bytes.test.ts`). A framework
|
|
4
|
+
// code goes through `bun run new-error-code` instead; the split is in this package's CLAUDE.md.
|
|
5
|
+
|
|
6
|
+
import { describeErrorCode, hasErrorCode, registerErrorCodes } from './error-codes';
|
|
7
|
+
import { renderCauseValue } from './error-render';
|
|
8
|
+
import { type ErrorRetry, registerErrorRetry } from './error-retry';
|
|
9
|
+
import {
|
|
10
|
+
appErrorStatus,
|
|
11
|
+
declareAppErrorStatus,
|
|
12
|
+
definedErrorStatus,
|
|
13
|
+
isHttpStatus,
|
|
14
|
+
recordDefinedError,
|
|
15
|
+
} from './error-status-declared';
|
|
16
|
+
import { isUltimateError, UltimateError } from './errors';
|
|
17
|
+
|
|
18
|
+
/** A line written once, or computed from the throw's params. */
|
|
19
|
+
export type ErrorText<P> = string | ((params: P) => string);
|
|
20
|
+
|
|
21
|
+
interface ErrorSpecFields<P> {
|
|
22
|
+
/** `X_SCREAMING_SNAKE`, written as a literal here: the `errors` step and the manifest read it. */
|
|
23
|
+
readonly code: string;
|
|
24
|
+
readonly title: string;
|
|
25
|
+
readonly cause: ErrorText<P>;
|
|
26
|
+
/** A command to run, a call to paste, or an edit naming a file — `x verify --only errors` checks. */
|
|
27
|
+
readonly fix: ErrorText<P>;
|
|
28
|
+
readonly callerFix?: ErrorText<P> | undefined;
|
|
29
|
+
readonly docs?: string | undefined;
|
|
30
|
+
readonly retry?: ErrorRetry | undefined;
|
|
31
|
+
readonly meta?: ((params: P) => Readonly<Record<string, unknown>>) | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Exactly one of `status` (what a response answers) or `offSocket: true` (a boot, CLI or job fault
|
|
36
|
+
* no response ever carries). Neither was the 500-by-omission a reference app shipped for "post not
|
|
37
|
+
* found"; the type makes the choice, and `X_ERROR_DECLARATION_INVALID` holds it at runtime.
|
|
38
|
+
*/
|
|
39
|
+
export type DefineErrorSpec<P = undefined> = ErrorSpecFields<P> &
|
|
40
|
+
(
|
|
41
|
+
| { readonly status: number; readonly offSocket?: undefined }
|
|
42
|
+
| { readonly offSocket: true; readonly status?: undefined }
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/** What a throw site may add beyond the params: the value it wraps, and a one-site retry. */
|
|
46
|
+
export interface DefinedErrorExtra {
|
|
47
|
+
readonly sourceError?: unknown;
|
|
48
|
+
readonly retry?: ErrorRetry | undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type DefinedErrorArgs<P> = [P] extends [undefined]
|
|
52
|
+
? [params?: undefined, extra?: DefinedErrorExtra]
|
|
53
|
+
: [params: P, extra?: DefinedErrorExtra];
|
|
54
|
+
|
|
55
|
+
export interface DefinedError<P = undefined> {
|
|
56
|
+
new (...args: DefinedErrorArgs<P>): UltimateError;
|
|
57
|
+
readonly prototype: UltimateError;
|
|
58
|
+
readonly code: string;
|
|
59
|
+
/** `undefined` when declared `offSocket`. */
|
|
60
|
+
readonly status: number | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* The brand plus the code — never `instanceof`, which a browser's `RemoteActionError` and a
|
|
63
|
+
* duplicated module instance both fail.
|
|
64
|
+
*/
|
|
65
|
+
is(value: unknown): value is UltimateError;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const SCREAMING_SNAKE = /^X(?:_[A-Z0-9]+)+$/;
|
|
69
|
+
|
|
70
|
+
const invalid = (code: unknown, cause: string): UltimateError =>
|
|
71
|
+
new UltimateError({
|
|
72
|
+
code: 'X_ERROR_DECLARATION_INVALID',
|
|
73
|
+
cause: `defineError({ code: ${renderCauseValue(code)} }) ${cause}`,
|
|
74
|
+
fix: 'x verify --only errors # after editing the defineError call the cause names',
|
|
75
|
+
meta: { code: renderCauseValue(code) },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
const duplicate = (code: string, cause: string): UltimateError =>
|
|
79
|
+
new UltimateError({
|
|
80
|
+
code: 'X_ERROR_CODE_DUPLICATE',
|
|
81
|
+
cause: `${code} is already declared ${cause}`,
|
|
82
|
+
fix: `grep -rn "${code}" apps packages # keep one declaration of the code, then x verify --only errors`,
|
|
83
|
+
meta: { code },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Where each code was declared: the caller's stack frame, as text, never parsed here. The class a
|
|
88
|
+
* declaration returns is named `Defined` whatever the app binds it to, so `errors:`'s source scan
|
|
89
|
+
* (`declared-errors-scan.ts`) reads the binding off this line — test-time, on demand.
|
|
90
|
+
*/
|
|
91
|
+
const DECLARED_AT = new Map<string, string>();
|
|
92
|
+
|
|
93
|
+
/** Code → the stack frame that declared it. Read by `definedErrorClassCodes` alone. */
|
|
94
|
+
export const definedErrorSites = (): ReadonlyMap<string, string> => DECLARED_AT;
|
|
95
|
+
|
|
96
|
+
function recordSite(code: string): void {
|
|
97
|
+
if (DECLARED_AT.has(code)) return;
|
|
98
|
+
const holder: { stack?: string } = {};
|
|
99
|
+
Error.captureStackTrace(holder, defineError);
|
|
100
|
+
const frame = holder.stack?.split('\n')[1];
|
|
101
|
+
if (frame !== undefined) DECLARED_AT.set(code, frame);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const text = <P>(value: ErrorText<P>, params: P): string =>
|
|
105
|
+
typeof value === 'function' ? value(params) : value;
|
|
106
|
+
|
|
107
|
+
/** Every refusal, before any table is written: a refused declaration leaves no half behind. */
|
|
108
|
+
function refusalOf<P>(spec: DefineErrorSpec<P>): UltimateError | undefined {
|
|
109
|
+
const { code } = spec;
|
|
110
|
+
if (typeof code !== 'string' || !SCREAMING_SNAKE.test(code)) {
|
|
111
|
+
return invalid(code, 'is not X_SCREAMING_SNAKE (X_ then upper-case words and digits)');
|
|
112
|
+
}
|
|
113
|
+
const hasStatus = spec.status !== undefined;
|
|
114
|
+
const offSocket = spec.offSocket === true;
|
|
115
|
+
if (hasStatus === offSocket) {
|
|
116
|
+
return invalid(
|
|
117
|
+
code,
|
|
118
|
+
hasStatus ? 'gives both status and offSocket' : 'gives neither status nor offSocket',
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (hasStatus && !isHttpStatus(spec.status)) {
|
|
122
|
+
return invalid(
|
|
123
|
+
code,
|
|
124
|
+
`gives status ${renderCauseValue(spec.status)}, which is not an HTTP status (100-599)`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
const defined = definedErrorStatus(code);
|
|
128
|
+
if (hasErrorCode(code)) {
|
|
129
|
+
// Registered and not by this function: a framework package's code (or an app table's). Its
|
|
130
|
+
// status is not this declaration's to give — X_FORBIDDEN's class would claim 200 while http
|
|
131
|
+
// answers 403 — so a matching title is no licence.
|
|
132
|
+
if (defined === undefined) return duplicate(code, 'by the framework');
|
|
133
|
+
const title = describeErrorCode(code).title;
|
|
134
|
+
if (title !== spec.title) {
|
|
135
|
+
return duplicate(code, `with the title ${renderCauseValue(title)}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Off-socket is a declaration too, so a later `status: 404` for it is a disagreement — which only
|
|
139
|
+
// the record can see: the status table holds no row for an off-socket code.
|
|
140
|
+
const status = defined === undefined ? appErrorStatus(code) : defined.status;
|
|
141
|
+
if ((defined !== undefined || status !== undefined) && status !== spec.status) {
|
|
142
|
+
return duplicate(code, status === undefined ? 'off-socket' : `with the status ${status}`);
|
|
143
|
+
}
|
|
144
|
+
return undefined;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Declare an app error code and get its class back. Importing the module that calls it IS the
|
|
149
|
+
* registration — `registerErrorCodes`' convention. The same declaration twice is a no-op (a module
|
|
150
|
+
* re-evaluated under `x dev`); a different title or status is `X_ERROR_CODE_DUPLICATE`.
|
|
151
|
+
*
|
|
152
|
+
* ```ts
|
|
153
|
+
* export const PostNotFound = defineError<{ readonly ref: string }>({
|
|
154
|
+
* code: 'X_POST_NOT_FOUND',
|
|
155
|
+
* title: 'no such post in this organisation',
|
|
156
|
+
* status: 404,
|
|
157
|
+
* cause: (p) => `no post ${p.ref} in the actor's organisation`,
|
|
158
|
+
* fix: 'x queries show postById --json, then pass an id it returns',
|
|
159
|
+
* });
|
|
160
|
+
* throw new PostNotFound({ ref });
|
|
161
|
+
* ```
|
|
162
|
+
*/
|
|
163
|
+
export function defineError<P = undefined>(spec: DefineErrorSpec<P>): DefinedError<P> {
|
|
164
|
+
const refused = refusalOf(spec);
|
|
165
|
+
if (refused !== undefined) throw refused;
|
|
166
|
+
const { code } = spec;
|
|
167
|
+
// `registerErrorRetry` refuses an unknown kind or a conflicting one BEFORE it writes, and the
|
|
168
|
+
// two writes after it cannot fail: `refusalOf` already answered for both.
|
|
169
|
+
if (spec.retry !== undefined) registerErrorRetry({ [code]: spec.retry });
|
|
170
|
+
if (!hasErrorCode(code)) {
|
|
171
|
+
registerErrorCodes({ [code]: { title: spec.title, docs: spec.docs } });
|
|
172
|
+
}
|
|
173
|
+
if (spec.status !== undefined) declareAppErrorStatus(code, spec.status);
|
|
174
|
+
recordDefinedError(code, spec.status);
|
|
175
|
+
recordSite(code);
|
|
176
|
+
|
|
177
|
+
class Defined extends UltimateError {
|
|
178
|
+
static readonly code = code;
|
|
179
|
+
static readonly status = spec.status;
|
|
180
|
+
|
|
181
|
+
static is(value: unknown): value is UltimateError {
|
|
182
|
+
return isUltimateError(value) && value.code === code;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
constructor(...[params, extra]: DefinedErrorArgs<P>) {
|
|
186
|
+
const input = params as P;
|
|
187
|
+
super({
|
|
188
|
+
code,
|
|
189
|
+
cause: text(spec.cause, input),
|
|
190
|
+
fix: text(spec.fix, input),
|
|
191
|
+
callerFix: spec.callerFix === undefined ? undefined : text(spec.callerFix, input),
|
|
192
|
+
docs: spec.docs,
|
|
193
|
+
meta: spec.meta?.(input),
|
|
194
|
+
retry: extra?.retry,
|
|
195
|
+
sourceError: extra?.sourceError,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return Defined;
|
|
200
|
+
}
|
package/src/deprecation.ts
CHANGED
|
@@ -44,6 +44,19 @@ export function recordDeprecatedCall(primitive: 'action' | 'query', name: string
|
|
|
44
44
|
deprecatedCalls.add(1, { primitive, name });
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/** The RFC 8288 link naming a retired declaration's replacement. One spelling, written and read. */
|
|
48
|
+
export function successorLink(path: string): string {
|
|
49
|
+
return `<${path}>; rel="successor-version"`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The path `successorLink` wrote, or `undefined` for a link that is not exactly one: another
|
|
54
|
+
* relation is an app's own header, and whoever re-serves a route has no claim on it.
|
|
55
|
+
*/
|
|
56
|
+
export function successorPathOf(link: string): string | undefined {
|
|
57
|
+
return /^<([^<>]*)>; rel="successor-version"$/.exec(link)?.[1];
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
/**
|
|
48
61
|
* `Deprecation` is a structured-field Date (`@` + unix seconds, RFC 9745); `Sunset` is an
|
|
49
62
|
* HTTP-date (IMF-fixdate, RFC 8594). Two spellings of one instant because two RFCs chose
|
|
@@ -64,9 +77,7 @@ export function renderDeprecation(
|
|
|
64
77
|
};
|
|
65
78
|
// The successor's URL, derived by the caller from its own route naming — the one the typed
|
|
66
79
|
// client uses. A link built here from the export name would be a second URL derivation.
|
|
67
|
-
if (successorPath !== undefined)
|
|
68
|
-
headers['link'] = `<${successorPath}>; rel="successor-version"`;
|
|
69
|
-
}
|
|
80
|
+
if (successorPath !== undefined) headers['link'] = successorLink(successorPath);
|
|
70
81
|
|
|
71
82
|
const meta: Record<string, string> = {
|
|
72
83
|
since: new Date(since).toISOString(),
|
package/src/drain-deadline.ts
CHANGED
|
@@ -26,8 +26,16 @@ export const DRAIN_DEADLINE_MAX_MS = 3_600_000;
|
|
|
26
26
|
*/
|
|
27
27
|
export const WORKER_DRAIN_DEADLINE_MAX_MS = 86_400_000;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* An hour: how long a retiring worker (`SIGUSR2`, the chart's `preStop`) may finish what it holds,
|
|
31
|
+
* aborting nothing, before the SIGTERM. Not a second drain budget: this one cancels nothing. On by
|
|
32
|
+
* default because an idle worker exits at once, so it costs only while a job really holds a pod.
|
|
33
|
+
*/
|
|
34
|
+
export const WORKER_RETIRE_DEFAULT_MS = 3_600_000;
|
|
35
|
+
|
|
29
36
|
const DEADLINE_KEY = 'drain.deadlineMs';
|
|
30
37
|
const WORKER_DEADLINE_KEY = 'drain.workerDeadlineMs';
|
|
38
|
+
const WORKER_RETIRE_KEY = 'drain.workerRetireMs';
|
|
31
39
|
|
|
32
40
|
/**
|
|
33
41
|
* Why a value is not a drain budget, or `undefined` when it is one. A whole number of milliseconds
|
|
@@ -55,15 +63,29 @@ export function workerDrainDeadlineIssue(value: unknown): string | undefined {
|
|
|
55
63
|
: undefined;
|
|
56
64
|
}
|
|
57
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Why a value is not the worker's retire, or `undefined` when it is one. A whole number in
|
|
68
|
+
* `0 ≤ v ≤ 86400000`; unlike a drain budget 0 is a real answer here: no retire, the plain SIGTERM.
|
|
69
|
+
*/
|
|
70
|
+
export function workerRetireIssue(value: unknown): string | undefined {
|
|
71
|
+
const count = countIssue(WORKER_RETIRE_KEY, value, 0);
|
|
72
|
+
if (count !== undefined) return count;
|
|
73
|
+
return (value as number) > WORKER_DRAIN_DEADLINE_MAX_MS
|
|
74
|
+
? `${WORKER_RETIRE_KEY} must be at most ${WORKER_DRAIN_DEADLINE_MAX_MS} milliseconds (a day) — a job longer than that belongs in steps, which replay instead of re-running`
|
|
75
|
+
: undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
/** The `drain` section's issues, one per key, for `defineConfig`'s validator. */
|
|
59
79
|
export function drainIssues(drain: {
|
|
60
80
|
readonly readinessGraceMs: unknown;
|
|
61
81
|
readonly deadlineMs: unknown;
|
|
62
82
|
readonly workerDeadlineMs?: unknown;
|
|
83
|
+
readonly workerRetireMs?: unknown;
|
|
63
84
|
}): readonly (string | undefined)[] {
|
|
64
85
|
return [
|
|
65
86
|
readinessGraceIssue(drain.readinessGraceMs),
|
|
66
87
|
drainDeadlineIssue(drain.deadlineMs),
|
|
67
88
|
workerDrainDeadlineIssue(drain.workerDeadlineMs),
|
|
89
|
+
workerRetireIssue(drain.workerRetireMs),
|
|
68
90
|
];
|
|
69
91
|
}
|
package/src/env.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// is reported in ONE error — an agent should never have to restart the process six times to
|
|
3
3
|
// discover six missing variables.
|
|
4
4
|
|
|
5
|
+
import { numeric } from '@ultimat3/schema';
|
|
5
6
|
import { describeValue } from './error-render';
|
|
6
7
|
import { EnvMissingError } from './errors';
|
|
7
8
|
import { REDACTED, redactKeys } from './logger';
|
|
@@ -135,8 +136,9 @@ function parseValue(decl: EnvVarDecl, raw: string): { ok: true; value: unknown }
|
|
|
135
136
|
case 'number':
|
|
136
137
|
case 'integer':
|
|
137
138
|
case 'port': {
|
|
138
|
-
|
|
139
|
-
|
|
139
|
+
// Schema's one decimal reader: `Number()` reads `' '` as 0 and `0x1F90` as 8080.
|
|
140
|
+
const value = numeric(raw);
|
|
141
|
+
if (value === undefined) return { ok: false };
|
|
140
142
|
if (decl.type !== 'number' && !Number.isInteger(value)) return { ok: false };
|
|
141
143
|
if (decl.type === 'port' && (value < 1 || value > 65535)) return { ok: false };
|
|
142
144
|
if (decl.min !== undefined && value < decl.min) return { ok: false };
|
|
@@ -150,7 +152,10 @@ function parseValue(decl: EnvVarDecl, raw: string): { ok: true; value: unknown }
|
|
|
150
152
|
case 'url':
|
|
151
153
|
return URL.canParse(raw) ? { ok: true, value: raw } : { ok: false };
|
|
152
154
|
default:
|
|
153
|
-
if (decl.pattern
|
|
155
|
+
if (decl.pattern === undefined) return { ok: true, value: raw };
|
|
156
|
+
// A `g`/`y` pattern resumes at its `lastIndex`: a second check would refuse a valid value.
|
|
157
|
+
decl.pattern.lastIndex = 0;
|
|
158
|
+
if (!decl.pattern.test(raw)) return { ok: false };
|
|
154
159
|
return { ok: true, value: raw };
|
|
155
160
|
}
|
|
156
161
|
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Single responsibility: which error reports are the same fault. A pure function over a report,
|
|
2
|
+
// never a field on it: `ErrorReport` is public, and a member every hand-built report must now
|
|
3
|
+
// carry, or every reader must now tolerate missing, is a break for a key a monitor can derive.
|
|
4
|
+
|
|
5
|
+
import { fingerprint } from './canonical-json';
|
|
6
|
+
import { stringField } from './error-render';
|
|
7
|
+
import type { ErrorReport } from './error-reporter';
|
|
8
|
+
|
|
9
|
+
/** `errorReport()`'s code for a throw nobody classified: the one code too wide to group on. */
|
|
10
|
+
const UNCLASSIFIED = 'X_INTERNAL';
|
|
11
|
+
|
|
12
|
+
/** ` at fn (path:1:2)`, ` at async fn (path:1:2)`, ` at path:1:2`. */
|
|
13
|
+
const FRAME = /^\s*at\s+(?:async\s+)?(?:(.*?)\s+\((.*)\)|(.*))$/;
|
|
14
|
+
|
|
15
|
+
/** A line and a column, or a line alone: an edit above the throw moves both. */
|
|
16
|
+
const POSITION = /(?::\d+){1,2}$/;
|
|
17
|
+
|
|
18
|
+
/** Library frames: the first frame that is the APP's names the fault, not the library under it. */
|
|
19
|
+
const LIBRARY = /(?:^|\/)(?:node_modules|@ultimat3)\//;
|
|
20
|
+
|
|
21
|
+
/** Reads that cannot throw: this runs on the path that reports a failure, and adds none. */
|
|
22
|
+
function read<T>(get: () => T): T | undefined {
|
|
23
|
+
try {
|
|
24
|
+
return get();
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function workingDirectory(): string | undefined {
|
|
31
|
+
const cwd = read(() => process.cwd());
|
|
32
|
+
return cwd === undefined ? undefined : `${cwd.replaceAll('\\', '/').replace(/\/$/, '')}/`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** `file://`, the working directory and the position dropped; `/` separators on every OS. */
|
|
36
|
+
function relativePath(location: string, root: string | undefined): string {
|
|
37
|
+
let path = location.trim().replace(POSITION, '').replaceAll('\\', '/');
|
|
38
|
+
if (path.startsWith('file://')) path = path.slice('file://'.length);
|
|
39
|
+
if (root !== undefined && path.startsWith(root)) path = path.slice(root.length);
|
|
40
|
+
return path.replace(/^\.\//, '');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The stack's FRAMES: the trailing run of `at …` lines. Read from the end, because the header
|
|
45
|
+
* above them is the error's message, and a message may itself hold `\n at x (y:1:1)` — which
|
|
46
|
+
* read top-down would set the group to whatever the message's author typed.
|
|
47
|
+
*/
|
|
48
|
+
function framesOf(stack: string): readonly RegExpExecArray[] {
|
|
49
|
+
const frames: RegExpExecArray[] = [];
|
|
50
|
+
const lines = stack.trimEnd().split('\n');
|
|
51
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
52
|
+
const match = FRAME.exec(lines[index] ?? '');
|
|
53
|
+
if (match === null) break;
|
|
54
|
+
frames.unshift(match);
|
|
55
|
+
}
|
|
56
|
+
return frames;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The top application frame as `fn path`, or `undefined`. A frame with no file location (`native`,
|
|
61
|
+
* `<anonymous>`, `processTicksAndRejections (native:7:39)`) is skipped as well: it is the
|
|
62
|
+
* runtime's, and two faults share it. A file location has a `/`; a runtime's never does.
|
|
63
|
+
*/
|
|
64
|
+
function applicationFrame(stack: string | undefined): string | undefined {
|
|
65
|
+
if (stack === undefined) return undefined;
|
|
66
|
+
const root = workingDirectory();
|
|
67
|
+
for (const match of framesOf(stack)) {
|
|
68
|
+
const location = match[2] ?? match[3] ?? '';
|
|
69
|
+
if (!POSITION.test(location.trim())) continue;
|
|
70
|
+
if (!location.replaceAll('\\', '/').includes('/')) continue;
|
|
71
|
+
const path = relativePath(location, root);
|
|
72
|
+
if (path === '' || LIBRARY.test(path)) continue;
|
|
73
|
+
const name = (match[1] ?? '').replace(/^new\s+/, '').trim();
|
|
74
|
+
return name === '' ? path : `${name} ${path}`;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A stable, 16-hex grouping key for a report. A coded fault groups on `code`, `source` and
|
|
81
|
+
* `scope.operation`; `X_INTERNAL` adds the top application frame (function and root-relative path,
|
|
82
|
+
* no line or column), because on its code alone every unclassified throw in an app is one group.
|
|
83
|
+
* Never `cause`, `meta`, an id or `release`: a row id splits one fault into a group per row, and a
|
|
84
|
+
* deploy is not a new fault. Total: a report it cannot read degrades to the frameless key.
|
|
85
|
+
*/
|
|
86
|
+
export function errorFingerprint(report: ErrorReport): string {
|
|
87
|
+
const code = stringField(report, 'code');
|
|
88
|
+
const source = stringField(report, 'source');
|
|
89
|
+
const operation = stringField(
|
|
90
|
+
read(() => report.scope),
|
|
91
|
+
'operation',
|
|
92
|
+
);
|
|
93
|
+
const frame = code === UNCLASSIFIED ? applicationFrame(stringField(report, 'stack')) : undefined;
|
|
94
|
+
return fingerprint({ code, source, operation, frame });
|
|
95
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Single responsibility: several `ErrorReporter`s behind the ONE seam — a vendor transport beside
|
|
2
|
+
// an app's own sink (`@ultimat3/admin`'s `errorGroupsSink`). Not a second seam: the chain IS a
|
|
3
|
+
// reporter, installed through `configureErrorReporting` like any other.
|
|
4
|
+
|
|
5
|
+
import { renderThrowable } from './error-render';
|
|
6
|
+
import { type ErrorReport, type ErrorReporter, noopErrorReporter } from './error-reporter';
|
|
7
|
+
import { logger } from './logger';
|
|
8
|
+
|
|
9
|
+
/** Which reporters a chain was built from — read back by `errorReportersOf`, never mutated. */
|
|
10
|
+
const members = new WeakMap<ErrorReporter, readonly ErrorReporter[]>();
|
|
11
|
+
|
|
12
|
+
/** The flat member list a reporter stands for: a chain's members, the no-op's none, else itself. */
|
|
13
|
+
export function errorReportersOf(reporter: ErrorReporter): readonly ErrorReporter[] {
|
|
14
|
+
if (reporter === noopErrorReporter) return [];
|
|
15
|
+
return members.get(reporter) ?? [reporter];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* One reporter that hands every report to each member, in order. A member that throws is logged
|
|
20
|
+
* and costs ONLY itself — `reportError`'s own catch guards the whole reporter, so without this one
|
|
21
|
+
* a vendor that is down would silence every member after it. Chains flatten and a member listed
|
|
22
|
+
* twice reports once, so a boot that composes with what is installed cannot double a sink.
|
|
23
|
+
*/
|
|
24
|
+
export function chainErrorReporters(...reporters: readonly ErrorReporter[]): ErrorReporter {
|
|
25
|
+
const flat = [...new Set(reporters.flatMap(errorReportersOf))];
|
|
26
|
+
if (flat.length === 0) return noopErrorReporter;
|
|
27
|
+
if (flat.length === 1) return flat[0] ?? noopErrorReporter;
|
|
28
|
+
const chain: ErrorReporter = {
|
|
29
|
+
report(event: ErrorReport): void {
|
|
30
|
+
flat.forEach((member, index) => {
|
|
31
|
+
try {
|
|
32
|
+
member.report(event);
|
|
33
|
+
} catch (failure) {
|
|
34
|
+
logger.warn('error reporter failed', {
|
|
35
|
+
source: event.source,
|
|
36
|
+
member: index,
|
|
37
|
+
error: renderThrowable(failure),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
members.set(chain, Object.freeze(flat));
|
|
44
|
+
return chain;
|
|
45
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// is documented and several self-hostable monitors ingest it. Nothing here names a host, a project
|
|
4
4
|
// or an organisation — the DSN is the app's own typed env, passed in at wiring time.
|
|
5
5
|
|
|
6
|
+
import { errorFingerprint } from './error-fingerprint';
|
|
6
7
|
import { renderThrowable } from './error-render';
|
|
7
8
|
import type { ErrorReport, ErrorReporter, ErrorSeverity } from './error-reporter';
|
|
8
9
|
import { type CodedErrorInit, UltimateError } from './errors';
|
|
@@ -89,6 +90,9 @@ function payloadOf(report: ErrorReport, eventId: string): Record<string, unknown
|
|
|
89
90
|
server_name: report.resource.serviceName,
|
|
90
91
|
...(report.release === null ? {} : { release: report.release }),
|
|
91
92
|
...(report.scope.operation === undefined ? {} : { transaction: report.scope.operation }),
|
|
93
|
+
// The group. Without it the monitor groups on its own reading of the exception, which is the
|
|
94
|
+
// code, so every unclassified throw in the app was one issue (`errorFingerprint`).
|
|
95
|
+
fingerprint: [errorFingerprint(report)],
|
|
92
96
|
// Tags are the monitor's facets, so only bounded values go here — the same rule metric labels
|
|
93
97
|
// follow. `requestId` and `actorId` are unbounded and live in `extra`.
|
|
94
98
|
tags: {
|