@ultimat3/core 29.4.0 → 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
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
assertFiniteOtlpBound,
|
|
8
8
|
OTLP_SCOPE,
|
|
9
9
|
type OtlpKeyValue,
|
|
10
|
+
type OtlpPostOptions,
|
|
10
11
|
otlpAttributes,
|
|
11
12
|
otlpEndpoint,
|
|
12
13
|
otlpHeaders,
|
|
@@ -110,6 +111,10 @@ export interface OtlpSpanExporterOptions {
|
|
|
110
111
|
readonly timeoutMs?: number | undefined;
|
|
111
112
|
/** Injected by tests; the preload seals the real one. */
|
|
112
113
|
readonly fetch?: typeof globalThis.fetch | undefined;
|
|
114
|
+
/** The retry's wait, die and clock, passed to `postOtlp`. Injected by tests. */
|
|
115
|
+
readonly sleep?: OtlpPostOptions['sleep'];
|
|
116
|
+
readonly random?: OtlpPostOptions['random'];
|
|
117
|
+
readonly now?: OtlpPostOptions['now'];
|
|
113
118
|
}
|
|
114
119
|
|
|
115
120
|
export interface OtlpSpanExporter extends SpanExporter {
|
|
@@ -135,6 +140,7 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
135
140
|
options.flushIntervalMs ?? 5_000,
|
|
136
141
|
);
|
|
137
142
|
const send = options.fetch ?? globalThis.fetch;
|
|
143
|
+
const seams = { sleep: options.sleep, random: options.random, now: options.now };
|
|
138
144
|
const queue: ReadableSpan[] = [];
|
|
139
145
|
/** The ONE batch in flight, or `undefined`. Never a chain: see `pump`. */
|
|
140
146
|
let sending: Promise<void> | undefined;
|
|
@@ -158,7 +164,7 @@ export function otlpSpanExporter(options: OtlpSpanExporterOptions = {}): OtlpSpa
|
|
|
158
164
|
});
|
|
159
165
|
return Promise.resolve();
|
|
160
166
|
}
|
|
161
|
-
return postOtlp({ url, headers, body, timeoutMs, fetch: send });
|
|
167
|
+
return postOtlp({ url, headers, body, timeoutMs, fetch: send, ...seams });
|
|
162
168
|
};
|
|
163
169
|
|
|
164
170
|
/**
|
package/src/otlp.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
// Single responsibility: the pieces both OTLP exporters share — endpoint resolution from the env
|
|
2
|
-
// an operator already sets, header parsing, the OTLP/JSON value encoding, and one POST that
|
|
3
|
-
// throws. A serialisation, not a vendor (axiom 7), exactly as `metrics-text.ts` is to Prometheus.
|
|
2
|
+
// an operator already sets, header parsing, the OTLP/JSON value encoding, and (from `otlp-post.ts`) one POST that
|
|
3
|
+
// never throws. A serialisation, not a vendor (axiom 7), exactly as `metrics-text.ts` is to Prometheus.
|
|
4
4
|
|
|
5
5
|
import { assert } from './assert';
|
|
6
|
-
import { renderThrowable } from './error-render';
|
|
7
6
|
import { type CodedErrorInit, UltimateError } from './errors';
|
|
8
|
-
import { logger } from './logger';
|
|
9
7
|
import type { AttributeValue, SpanResource } from './telemetry';
|
|
10
8
|
|
|
11
9
|
export class OtlpEndpointInvalidError extends UltimateError {
|
|
@@ -270,36 +268,5 @@ export function otlpResource(resource: SpanResource): {
|
|
|
270
268
|
/** The instrumentation scope every signal this package emits belongs to. */
|
|
271
269
|
export const OTLP_SCOPE = Object.freeze({ name: '@ultimat3/core' });
|
|
272
270
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
readonly headers: Readonly<Record<string, string>>;
|
|
276
|
-
readonly body: string;
|
|
277
|
-
readonly timeoutMs: number;
|
|
278
|
-
readonly fetch: typeof globalThis.fetch;
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Never throws and never rejects. Telemetry delivery is not the request: a collector that is down
|
|
283
|
-
* must not become the app's outage, and the alternative — an unhandled rejection from a `void`ed
|
|
284
|
-
* promise — takes the process with it on Bun.
|
|
285
|
-
*/
|
|
286
|
-
export async function postOtlp(options: OtlpPostOptions): Promise<void> {
|
|
287
|
-
try {
|
|
288
|
-
const response = await options.fetch(options.url, {
|
|
289
|
-
method: 'POST',
|
|
290
|
-
headers: { ...options.headers },
|
|
291
|
-
body: options.body,
|
|
292
|
-
signal: AbortSignal.timeout(options.timeoutMs),
|
|
293
|
-
});
|
|
294
|
-
if (!response.ok) {
|
|
295
|
-
logger.warn('otlp export rejected', { url: options.url, status: response.status });
|
|
296
|
-
}
|
|
297
|
-
} catch (failure) {
|
|
298
|
-
logger.warn('otlp export failed', {
|
|
299
|
-
url: options.url,
|
|
300
|
-
// `renderThrowable`: the read that renders a caught value must not itself throw, or the
|
|
301
|
-
// export failure this catch exists to swallow escapes as an unhandled rejection.
|
|
302
|
-
error: renderThrowable(failure),
|
|
303
|
-
});
|
|
304
|
-
}
|
|
305
|
-
}
|
|
271
|
+
// Delivery, retry included, is its own module; re-exported so the exporters' one import stays.
|
|
272
|
+
export { OTLP_RETRY_STATUSES, type OtlpPostOptions, postOtlp } from './otlp-post';
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Single responsibility: ONE attempt of an `outbound()` call — its signal, its hops, its status
|
|
2
|
+
// read as a coded answer, and its body under the idle watchdog and the cap. Every way it can end
|
|
3
|
+
// is classified here, so `retry()` above it reads a code and never an unclassified throw (which
|
|
4
|
+
// it retries: measured, an unclassified throw from a POST ran three attempts).
|
|
5
|
+
|
|
6
|
+
import { retryAfterSecondsOf } from './client-retry-after';
|
|
7
|
+
import { tryUseContext } from './context';
|
|
8
|
+
import type { DeadlineTimer } from './deadline-signal';
|
|
9
|
+
import { deadlineSignal } from './deadline-signal';
|
|
10
|
+
import { isUltimateError } from './errors';
|
|
11
|
+
import { logger, redactFields } from './logger';
|
|
12
|
+
import type { OutboundSite } from './outbound-errors';
|
|
13
|
+
import {
|
|
14
|
+
callerDeadline,
|
|
15
|
+
outboundRedirected,
|
|
16
|
+
outboundRefused,
|
|
17
|
+
outboundThrottled,
|
|
18
|
+
outboundTimeout,
|
|
19
|
+
outboundTooLarge,
|
|
20
|
+
outboundUnavailable,
|
|
21
|
+
outboundUnreachable,
|
|
22
|
+
} from './outbound-errors';
|
|
23
|
+
import type { HopCall } from './outbound-hops';
|
|
24
|
+
import { redactedUrl, sendHops } from './outbound-hops';
|
|
25
|
+
import type { WatchedFailure } from './outbound-read';
|
|
26
|
+
import { watchedBody } from './outbound-read';
|
|
27
|
+
import { readWithinLimit } from './read-capped';
|
|
28
|
+
import { isRetryableStatus } from './retryable-status';
|
|
29
|
+
|
|
30
|
+
export interface AttemptCall extends HopCall {
|
|
31
|
+
/** The call's own signal, heard beside the ambient one. */
|
|
32
|
+
readonly signal: AbortSignal | undefined;
|
|
33
|
+
/** Monotonic instant the whole call must end by. */
|
|
34
|
+
readonly endsAt: number;
|
|
35
|
+
readonly deadlineMs: number;
|
|
36
|
+
readonly idleMs: number;
|
|
37
|
+
readonly maxBytes: number;
|
|
38
|
+
readonly read: 'bytes' | 'stream';
|
|
39
|
+
readonly now: () => number;
|
|
40
|
+
readonly timer: DeadlineTimer;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What a settled attempt hands up: a 2xx, with its body read or still to be read. */
|
|
44
|
+
export type AttemptAnswer =
|
|
45
|
+
| { readonly status: number; readonly headers: Headers; readonly bytes: Uint8Array }
|
|
46
|
+
| {
|
|
47
|
+
readonly status: number;
|
|
48
|
+
readonly headers: Headers;
|
|
49
|
+
readonly stream: ReadableStream<Uint8Array>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** How many bytes of a refusal's body are read for its `meta.detail`, and how much is kept. */
|
|
53
|
+
const DETAIL_READ_BYTES = 1_024;
|
|
54
|
+
const DETAIL_CHARS = 200;
|
|
55
|
+
|
|
56
|
+
/** The first `DETAIL_READ_BYTES` of a body, the rest cancelled unread: an endless 5xx stays cheap. */
|
|
57
|
+
async function prefixOf(body: ReadableStream<Uint8Array>): Promise<string> {
|
|
58
|
+
const reader = body.getReader();
|
|
59
|
+
const decoder = new TextDecoder();
|
|
60
|
+
let text = '';
|
|
61
|
+
try {
|
|
62
|
+
while (text.length < DETAIL_READ_BYTES) {
|
|
63
|
+
const next = await reader.read();
|
|
64
|
+
if (next.done) break;
|
|
65
|
+
text += decoder.decode(next.value, { stream: true });
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
await reader.cancel().catch(() => undefined);
|
|
69
|
+
}
|
|
70
|
+
return text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A refusal's body, short and with every credential-named field redacted. Never throws. */
|
|
74
|
+
async function detailOf(body: ReadableStream<Uint8Array>): Promise<string> {
|
|
75
|
+
let text = '';
|
|
76
|
+
try {
|
|
77
|
+
text = await prefixOf(body);
|
|
78
|
+
} catch {
|
|
79
|
+
return '';
|
|
80
|
+
}
|
|
81
|
+
// A JSON OBJECT, redacted by key, or nothing: raw text is never kept. A body cut at the prefix,
|
|
82
|
+
// an array, a form (`client_secret=…`) or an HTML page carries a credential no key names.
|
|
83
|
+
try {
|
|
84
|
+
const parsed: unknown = JSON.parse(text);
|
|
85
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return '';
|
|
86
|
+
return JSON.stringify(redactFields(parsed as Record<string, unknown>)).slice(0, DETAIL_CHARS);
|
|
87
|
+
} catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The ambient request's time left, in ms, or `Infinity` when it has no deadline. */
|
|
93
|
+
function ambientLeftMs(): number {
|
|
94
|
+
const ctx = tryUseContext();
|
|
95
|
+
if (ctx === undefined || ctx.deadlineAt === null) return Number.POSITIVE_INFINITY;
|
|
96
|
+
return ctx.deadlineAt - ctx.now().getTime();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A non-2xx as the code it is. The body has been read for the detail, or cancelled. */
|
|
100
|
+
async function refusalOf(
|
|
101
|
+
site: OutboundSite,
|
|
102
|
+
response: Response,
|
|
103
|
+
body: ReadableStream<Uint8Array>,
|
|
104
|
+
url: URL,
|
|
105
|
+
): Promise<unknown> {
|
|
106
|
+
const { status, headers } = response;
|
|
107
|
+
if (status >= 300 && status < 400) {
|
|
108
|
+
await body.cancel().catch(() => undefined);
|
|
109
|
+
const location = headers.get('location');
|
|
110
|
+
const where = location === null ? 'nowhere' : redactedUrl(new URL(location, url));
|
|
111
|
+
return outboundRedirected(site, status, where);
|
|
112
|
+
}
|
|
113
|
+
if (isRetryableStatus(status)) {
|
|
114
|
+
const after = retryAfterSecondsOf(headers.get('retry-after'), headers.get('date'));
|
|
115
|
+
const detail = await detailOf(body);
|
|
116
|
+
return after === undefined
|
|
117
|
+
? outboundUnavailable(site, status, detail)
|
|
118
|
+
: outboundThrottled(site, status, after, detail);
|
|
119
|
+
}
|
|
120
|
+
return outboundRefused(site, status, await detailOf(body));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export async function runAttempt(call: AttemptCall, attempt: number): Promise<AttemptAnswer> {
|
|
124
|
+
const startedAt = call.now();
|
|
125
|
+
// The ambient caller's signal and the call's own, each heard; neither composed into one that
|
|
126
|
+
// could not be undone (`AbortSignal.any` would leave a listener on a long-lived one per call).
|
|
127
|
+
const outers = [tryUseContext()?.signal, call.signal].filter(
|
|
128
|
+
(signal): signal is AbortSignal => signal !== undefined,
|
|
129
|
+
);
|
|
130
|
+
const gone = (): AbortSignal | undefined => outers.find((signal) => signal.aborted);
|
|
131
|
+
const outer = {
|
|
132
|
+
get aborted() {
|
|
133
|
+
return gone() !== undefined;
|
|
134
|
+
},
|
|
135
|
+
get reason(): unknown {
|
|
136
|
+
return gone()?.reason;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
if (outer.aborted) throw outer.reason;
|
|
140
|
+
// Whole milliseconds, up: a timer armed for 899.97 ms is a 900 ms deadline read late.
|
|
141
|
+
const ownLeft = call.endsAt - startedAt;
|
|
142
|
+
const ambientLeft = ambientLeftMs();
|
|
143
|
+
// Which bound fires decides the code: the caller's own deadline is no fact about the vendor.
|
|
144
|
+
const callerBinds = ambientLeft < ownLeft;
|
|
145
|
+
const timedOut = (): unknown =>
|
|
146
|
+
callerBinds
|
|
147
|
+
? callerDeadline(call.site)
|
|
148
|
+
: outboundTimeout(call.site, 'deadline', call.deadlineMs);
|
|
149
|
+
const leftMs = Math.ceil(Math.min(ownLeft, ambientLeft));
|
|
150
|
+
if (leftMs <= 0) throw timedOut();
|
|
151
|
+
const local = new AbortController();
|
|
152
|
+
const forward = (): void => local.abort(outer.reason);
|
|
153
|
+
for (const signal of outers) signal.addEventListener('abort', forward, { once: true });
|
|
154
|
+
const deadline = deadlineSignal(leftMs, local.signal, call.timer);
|
|
155
|
+
let idle = false;
|
|
156
|
+
let open = true;
|
|
157
|
+
const release = (): void => {
|
|
158
|
+
if (!open) return;
|
|
159
|
+
open = false;
|
|
160
|
+
deadline[Symbol.dispose]();
|
|
161
|
+
for (const signal of outers) signal.removeEventListener('abort', forward);
|
|
162
|
+
};
|
|
163
|
+
// A caller gone is ITS reason, unchanged (`X_DRAINING` keeps its meaning); else which bound fired.
|
|
164
|
+
const coded = (error: unknown): unknown => {
|
|
165
|
+
if (outer.aborted) return outer.reason;
|
|
166
|
+
if (isUltimateError(error)) return error;
|
|
167
|
+
if (deadline.timedOut()) return timedOut();
|
|
168
|
+
if (idle) return outboundTimeout(call.site, 'idle', call.idleMs);
|
|
169
|
+
return outboundUnreachable(call.site, error);
|
|
170
|
+
};
|
|
171
|
+
const failure = (cause: WatchedFailure): unknown => {
|
|
172
|
+
if (cause.kind === 'too-large') return outboundTooLarge(call.site, call.maxBytes);
|
|
173
|
+
return coded(cause.kind === 'idle' ? undefined : cause.error);
|
|
174
|
+
};
|
|
175
|
+
let status: number | undefined;
|
|
176
|
+
try {
|
|
177
|
+
const response = await sendHops(call, deadline.signal);
|
|
178
|
+
status = response.status;
|
|
179
|
+
const body = watchedBody(response.body ?? new Blob([]).stream(), {
|
|
180
|
+
idleMs: call.idleMs,
|
|
181
|
+
...(call.read === 'stream' ? { maxBytes: call.maxBytes } : {}),
|
|
182
|
+
timer: call.timer,
|
|
183
|
+
signal: deadline.signal,
|
|
184
|
+
onIdle: () => {
|
|
185
|
+
idle = true;
|
|
186
|
+
local.abort();
|
|
187
|
+
},
|
|
188
|
+
failure,
|
|
189
|
+
onSettled: release,
|
|
190
|
+
});
|
|
191
|
+
if (status < 200 || status >= 300) throw await refusalOf(call.site, response, body, call.url);
|
|
192
|
+
if (call.read === 'stream') {
|
|
193
|
+
logAttempt(call, attempt, startedAt, status, undefined);
|
|
194
|
+
return { status, headers: response.headers, stream: body };
|
|
195
|
+
}
|
|
196
|
+
const read = await readWithinLimit(body, call.maxBytes);
|
|
197
|
+
if ('over' in read) throw outboundTooLarge(call.site, call.maxBytes);
|
|
198
|
+
logAttempt(call, attempt, startedAt, status, undefined);
|
|
199
|
+
return { status, headers: response.headers, bytes: read.bytes };
|
|
200
|
+
} catch (error) {
|
|
201
|
+
const thrown = coded(error);
|
|
202
|
+
logAttempt(call, attempt, startedAt, status, thrown);
|
|
203
|
+
release();
|
|
204
|
+
throw thrown;
|
|
205
|
+
} finally {
|
|
206
|
+
if (call.read === 'bytes') release();
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* One line per attempt: the call's label, never its query or headers. `debug` for an answer,
|
|
212
|
+
* `warn` for a failure, so a healthy vendor costs no line at the default level.
|
|
213
|
+
*/
|
|
214
|
+
function logAttempt(
|
|
215
|
+
call: AttemptCall,
|
|
216
|
+
attempt: number,
|
|
217
|
+
startedAt: number,
|
|
218
|
+
status: number | undefined,
|
|
219
|
+
thrown: unknown,
|
|
220
|
+
): void {
|
|
221
|
+
const fields = {
|
|
222
|
+
...call.site,
|
|
223
|
+
attempt,
|
|
224
|
+
ms: Math.round(call.now() - startedAt),
|
|
225
|
+
...(status === undefined ? {} : { status }),
|
|
226
|
+
};
|
|
227
|
+
if (thrown === undefined) {
|
|
228
|
+
logger.debug('outbound.attempt', fields);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
logger.warn('outbound.attempt', {
|
|
232
|
+
...fields,
|
|
233
|
+
code: isUltimateError(thrown) ? thrown.code : 'uncoded',
|
|
234
|
+
});
|
|
235
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Single responsibility: where a dial to a URL may land, decided BEFORE the first byte leaves —
|
|
2
|
+
// resolve the name, classify every address it answers by core's one classifier, and pin the
|
|
3
|
+
// connection to an approved address. `@ultimat3/jobs`' webhook target, `@ultimat3/scraping`'s
|
|
4
|
+
// wildcard leg and `outbound({ dial: 'public' })` each held a copy of this; now they call it.
|
|
5
|
+
|
|
6
|
+
import type { AddressClass } from './address-class';
|
|
7
|
+
import { classifyAddress } from './address-class';
|
|
8
|
+
import { renderThrowable } from './error-render';
|
|
9
|
+
|
|
10
|
+
/** Every address a hostname answers. Injected in tests: the network is sealed in this repo. */
|
|
11
|
+
export type DialResolve = (hostname: string) => Promise<readonly string[]>;
|
|
12
|
+
|
|
13
|
+
/** `Bun.dns.lookup` — the runtime's own resolver, every family, every address. The one call site. */
|
|
14
|
+
export const resolveDialHost: DialResolve = async (hostname) =>
|
|
15
|
+
(await Bun.dns.lookup(hostname)).map((entry) => entry.address);
|
|
16
|
+
|
|
17
|
+
/** The connection a screened dial makes: an approved address, and the name it proves. */
|
|
18
|
+
export interface DialPin {
|
|
19
|
+
/** The URL with its host replaced by the approved address. */
|
|
20
|
+
readonly url: string;
|
|
21
|
+
/** The original `host[:port]`, sent as the `Host` header. */
|
|
22
|
+
readonly host: string;
|
|
23
|
+
/** For `https:` — the certificate is verified against the NAME, not the address. */
|
|
24
|
+
readonly serverName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What the screen decided. `unresolved` is a name that did not resolve — a TRANSIENT fact the
|
|
29
|
+
* caller decides about (a webhook retries it, a scrape refuses it), carrying the resolver's error
|
|
30
|
+
* when there was one. `refused` names the address CLASS, never the address: a reason reaches a
|
|
31
|
+
* durable dead-letter row.
|
|
32
|
+
*/
|
|
33
|
+
export type DialVerdict =
|
|
34
|
+
| { readonly pin: DialPin }
|
|
35
|
+
| { readonly refused: AddressClass | 'unclassifiable' }
|
|
36
|
+
| { readonly unresolved: string; readonly error?: unknown };
|
|
37
|
+
|
|
38
|
+
export interface ScreenDialOptions {
|
|
39
|
+
readonly resolve?: DialResolve | undefined;
|
|
40
|
+
/** Skip the class check, still pin: a receiver the app declared to be inside its network. */
|
|
41
|
+
readonly allowPrivate?: boolean | undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Why `url` may not be dialled as a public vendor, before any name is resolved, or `undefined`.
|
|
46
|
+
* `http:` only where a dev receiver lives (`local`), and no credentials in the URL: they would ride
|
|
47
|
+
* every hop and every log line. Asked of each redirect hop as well as the first URL.
|
|
48
|
+
*/
|
|
49
|
+
export function publicUrlRefusal(url: URL, local: boolean): string | undefined {
|
|
50
|
+
if (url.protocol !== 'https:' && !(local && url.protocol === 'http:')) {
|
|
51
|
+
return `${url.protocol} is not https:, the only scheme a public dial takes outside a local environment`;
|
|
52
|
+
}
|
|
53
|
+
if (url.username !== '' || url.password !== '') {
|
|
54
|
+
return 'the URL carries credentials, which would ride every hop and every log line';
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The refusal reason for a verdict that is not a pin, phrased by class, never by address. */
|
|
60
|
+
export function dialRefusalReason(verdict: DialVerdict): string | undefined {
|
|
61
|
+
if ('refused' in verdict) return `the host resolves to a ${verdict.refused} address`;
|
|
62
|
+
if ('unresolved' in verdict) return verdict.unresolved;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Screen and pin `url`. An address literal is classified as itself; a name is resolved and ONE
|
|
68
|
+
* address that is not public refuses it, because the socket may pick any of them. Pinned: the
|
|
69
|
+
* screen approved THESE addresses, and connecting by name would resolve again — a name answering
|
|
70
|
+
* public here and private to the socket (DNS rebinding) would walk past.
|
|
71
|
+
*/
|
|
72
|
+
export async function screenDial(url: URL, options: ScreenDialOptions = {}): Promise<DialVerdict> {
|
|
73
|
+
const hostname = url.hostname.replace(/^\[|\]$/g, '');
|
|
74
|
+
let addresses: readonly string[];
|
|
75
|
+
if (classifyAddress(hostname) !== undefined) {
|
|
76
|
+
addresses = [hostname];
|
|
77
|
+
} else {
|
|
78
|
+
try {
|
|
79
|
+
addresses = await (options.resolve ?? resolveDialHost)(hostname);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return { unresolved: `could not resolve the host: ${renderThrowable(error)}`, error };
|
|
82
|
+
}
|
|
83
|
+
if (addresses.length === 0) return { unresolved: 'the host resolved to no address' };
|
|
84
|
+
}
|
|
85
|
+
if (options.allowPrivate !== true) {
|
|
86
|
+
const refused = addresses
|
|
87
|
+
.map(
|
|
88
|
+
(address): AddressClass | 'unclassifiable' => classifyAddress(address) ?? 'unclassifiable',
|
|
89
|
+
)
|
|
90
|
+
.find((kind) => kind !== 'public');
|
|
91
|
+
if (refused !== undefined) return { refused };
|
|
92
|
+
}
|
|
93
|
+
const pinned = addresses[0] ?? hostname;
|
|
94
|
+
const target = new URL(url.href);
|
|
95
|
+
target.hostname = pinned.includes(':') ? `[${pinned}]` : pinned;
|
|
96
|
+
return {
|
|
97
|
+
pin: {
|
|
98
|
+
url: target.href,
|
|
99
|
+
host: url.host,
|
|
100
|
+
...(url.protocol === 'https:' ? { serverName: hostname } : {}),
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Single responsibility: the ten refusals `outbound()` can end on, each built from the call's
|
|
2
|
+
// redacted label. Never a query string, never a header: a cause reaches a log store and a
|
|
3
|
+
// problem document, and a signed URL's query is the credential.
|
|
4
|
+
|
|
5
|
+
import { renderFixShellArg, renderThrowable } from './error-render';
|
|
6
|
+
import { registerErrorRetry } from './error-retry';
|
|
7
|
+
import { UltimateError } from './errors';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The ten codes' retry classes, registered HERE and not in `error-retry.ts`'s core table: that
|
|
11
|
+
* table rides every browser page, and these are server-only (axiom 6). The transport faults are
|
|
12
|
+
* retryable, a stated delay is `retry-after`, and every answer the same request gets again is
|
|
13
|
+
* terminal — unclassified, `retry()` would replay a 401 three times.
|
|
14
|
+
*/
|
|
15
|
+
registerErrorRetry({
|
|
16
|
+
X_OUTBOUND_TIMEOUT: 'retryable',
|
|
17
|
+
X_OUTBOUND_UNREACHABLE: 'retryable',
|
|
18
|
+
X_OUTBOUND_UNAVAILABLE: 'retryable',
|
|
19
|
+
X_OUTBOUND_THROTTLED: 'retry-after',
|
|
20
|
+
X_OUTBOUND_REFUSED: 'terminal',
|
|
21
|
+
X_OUTBOUND_TOO_LARGE: 'terminal',
|
|
22
|
+
X_OUTBOUND_HOST_BLOCKED: 'terminal',
|
|
23
|
+
X_OUTBOUND_REDIRECTED: 'terminal',
|
|
24
|
+
X_OUTBOUND_RETRY_UNSAFE: 'terminal',
|
|
25
|
+
X_OUTBOUND_BODY_INVALID: 'terminal',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** What a failure may say about the call: who, how, and where, never the query or a header. */
|
|
29
|
+
export interface OutboundSite {
|
|
30
|
+
readonly vendor: string;
|
|
31
|
+
readonly method: string;
|
|
32
|
+
readonly origin: string;
|
|
33
|
+
readonly path: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `resend POST https://api.resend.com/emails` — the one spelling every cause opens with. */
|
|
37
|
+
export const siteLabel = (site: OutboundSite): string =>
|
|
38
|
+
`${site.vendor} ${site.method} ${site.origin}${site.path}`;
|
|
39
|
+
|
|
40
|
+
const siteMeta = (site: OutboundSite): Readonly<Record<string, string>> => ({
|
|
41
|
+
vendor: site.vendor,
|
|
42
|
+
method: site.method,
|
|
43
|
+
origin: site.origin,
|
|
44
|
+
path: site.path,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
/** Which bound ran out: the whole call's `deadlineMs`, or a body that went quiet for `idleMs`. */
|
|
48
|
+
export type OutboundTimeoutPhase = 'deadline' | 'idle';
|
|
49
|
+
|
|
50
|
+
export function outboundTimeout(
|
|
51
|
+
site: OutboundSite,
|
|
52
|
+
phase: OutboundTimeoutPhase,
|
|
53
|
+
boundMs: number,
|
|
54
|
+
): UltimateError {
|
|
55
|
+
const bound = phase === 'deadline' ? 'deadlineMs' : 'idleMs';
|
|
56
|
+
const what =
|
|
57
|
+
phase === 'deadline'
|
|
58
|
+
? `did not finish within its deadline of ${String(boundMs)} ms, retries and waits included`
|
|
59
|
+
: `sent no body bytes for ${String(boundMs)} ms`;
|
|
60
|
+
return new UltimateError({
|
|
61
|
+
code: 'X_OUTBOUND_TIMEOUT',
|
|
62
|
+
cause: `${siteLabel(site)} ${what}`,
|
|
63
|
+
fix: `outbound({ url, vendor: '${site.vendor}', dial, ${bound}: ${String(boundMs * 2)} }) # raise ${bound} on this call, or check the vendor's latency`,
|
|
64
|
+
meta: { ...siteMeta(site), phase, [bound]: boundMs },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A transport failure in its own words with every query string cut (`?…`): a runtime's message
|
|
70
|
+
* may quote the URL it was dialling, and the query is where a signed URL keeps its credential.
|
|
71
|
+
*/
|
|
72
|
+
const transportText = (error: unknown): string =>
|
|
73
|
+
renderThrowable(error).replaceAll(/\?[^\s"'`)]*/g, '?…');
|
|
74
|
+
|
|
75
|
+
/** The refusals that say the CALLER's bound ran out, not the vendor: a breaker hears nothing. */
|
|
76
|
+
const CALLER_BOUND = new WeakSet<UltimateError>();
|
|
77
|
+
|
|
78
|
+
/** Whether `error` is `callerDeadline`'s: the caller's time ran out, nothing about the vendor. */
|
|
79
|
+
export const isCallerBound = (error: unknown): boolean =>
|
|
80
|
+
error instanceof UltimateError && CALLER_BOUND.has(error);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The AMBIENT deadline was the binding bound and it ran out — the request's or a `bound`'s, never
|
|
84
|
+
* `deadlineMs`. Core's `X_TIMEOUT`, the code the caller's own deadline already answers with.
|
|
85
|
+
*/
|
|
86
|
+
export function callerDeadline(site: OutboundSite): UltimateError {
|
|
87
|
+
const error = new UltimateError({
|
|
88
|
+
code: 'X_TIMEOUT',
|
|
89
|
+
cause: `the caller's own deadline ran out before ${siteLabel(site)} answered; its deadlineMs was not the bound that fired`,
|
|
90
|
+
fix: 'configureHttp({ requestTimeoutMs: 60_000 }) # at module scope in a file under apps/*/, if the request deadline is the one that is too short for this vendor',
|
|
91
|
+
meta: { ...siteMeta(site), bound: 'caller' },
|
|
92
|
+
});
|
|
93
|
+
CALLER_BOUND.add(error);
|
|
94
|
+
return error;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function outboundUnreachable(site: OutboundSite, error: unknown): UltimateError {
|
|
98
|
+
return new UltimateError({
|
|
99
|
+
code: 'X_OUTBOUND_UNREACHABLE',
|
|
100
|
+
cause: `${siteLabel(site)} got no response: ${transportText(error)}`,
|
|
101
|
+
fix: `curl -sS -m 5 -o /dev/null ${renderFixShellArg(site.origin, '<vendor origin>')} # reachable from here? else check the egress rule and ${site.vendor}'s status, then re-run the call`,
|
|
102
|
+
meta: siteMeta(site),
|
|
103
|
+
sourceError: error,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function outboundThrottled(
|
|
108
|
+
site: OutboundSite,
|
|
109
|
+
status: number,
|
|
110
|
+
retryAfterSeconds: number,
|
|
111
|
+
detail = '',
|
|
112
|
+
): UltimateError {
|
|
113
|
+
return new UltimateError({
|
|
114
|
+
code: 'X_OUTBOUND_THROTTLED',
|
|
115
|
+
cause: `${siteLabel(site)} answered ${String(status)} and asked to wait ${String(retryAfterSeconds)} s`,
|
|
116
|
+
fix: `await Bun.sleep(${String(retryAfterSeconds * 1000)}) # then call ${site.vendor} again, or lower the call rate at the site that calls outbound()`,
|
|
117
|
+
meta: { ...siteMeta(site), status, retryAfterSeconds, detail },
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function outboundUnavailable(
|
|
122
|
+
site: OutboundSite,
|
|
123
|
+
status: number,
|
|
124
|
+
detail = '',
|
|
125
|
+
): UltimateError {
|
|
126
|
+
return new UltimateError({
|
|
127
|
+
code: 'X_OUTBOUND_UNAVAILABLE',
|
|
128
|
+
cause: `${siteLabel(site)} answered ${String(status)} on every attempt: the vendor's fault, not the request's`,
|
|
129
|
+
fix: `curl -sS -m 5 -o /dev/null -w '%{http_code}\\n' ${renderFixShellArg(site.origin, '<vendor origin>')} # the vendor's fault: re-run the call; if ${String(status)} persists, check ${site.vendor}'s status page`,
|
|
130
|
+
meta: { ...siteMeta(site), status, detail },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function outboundRefused(site: OutboundSite, status: number, detail: string): UltimateError {
|
|
135
|
+
return new UltimateError({
|
|
136
|
+
code: 'X_OUTBOUND_REFUSED',
|
|
137
|
+
cause: `${siteLabel(site)} answered ${String(status)}, and the same request will get it again`,
|
|
138
|
+
fix:
|
|
139
|
+
status === 401
|
|
140
|
+
? `outbound({ url, vendor: '${site.vendor}', headers: { authorization } }) # with a refreshed credential, then call again`
|
|
141
|
+
: `outbound({ url, vendor: '${site.vendor}', body }) # with the request fixed from meta.detail and meta.status`,
|
|
142
|
+
meta: { ...siteMeta(site), status, detail },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function outboundTooLarge(site: OutboundSite, maxBytes: number): UltimateError {
|
|
147
|
+
return new UltimateError({
|
|
148
|
+
code: 'X_OUTBOUND_TOO_LARGE',
|
|
149
|
+
cause: `${siteLabel(site)} answered more than ${String(maxBytes)} bytes; the read stopped at the cap`,
|
|
150
|
+
fix: `outbound({ url, vendor: '${site.vendor}', dial, deadlineMs, maxBytes: ${String(maxBytes * 4)} }) # or read: 'stream' to consume it in chunks`,
|
|
151
|
+
meta: { ...siteMeta(site), maxBytes },
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function outboundHostBlocked(site: OutboundSite, reason: string): UltimateError {
|
|
156
|
+
return new UltimateError({
|
|
157
|
+
code: 'X_OUTBOUND_HOST_BLOCKED',
|
|
158
|
+
cause: `${siteLabel(site)} was refused before dialling: ${reason}`,
|
|
159
|
+
fix: `outbound({ url, vendor: '${site.vendor}', dial: 'named' }) # for a host this app names in code; otherwise give it a public https:// URL`,
|
|
160
|
+
meta: { ...siteMeta(site), reason },
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function outboundRedirected(
|
|
165
|
+
site: OutboundSite,
|
|
166
|
+
status: number,
|
|
167
|
+
location: string,
|
|
168
|
+
): UltimateError {
|
|
169
|
+
return new UltimateError({
|
|
170
|
+
code: 'X_OUTBOUND_REDIRECTED',
|
|
171
|
+
cause: `${siteLabel(site)} answered ${String(status)} towards ${location}, and redirects are not followed by default`,
|
|
172
|
+
fix: `outbound({ url, vendor: '${site.vendor}', redirect: 'follow' }) # or call the Location the cause names directly`,
|
|
173
|
+
meta: { ...siteMeta(site), status, location },
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function outboundRetryUnsafe(site: OutboundSite): UltimateError {
|
|
178
|
+
return new UltimateError({
|
|
179
|
+
code: 'X_OUTBOUND_RETRY_UNSAFE',
|
|
180
|
+
cause: `${siteLabel(site)} was given a retry policy, and a ${site.method} replayed is a second write`,
|
|
181
|
+
fix: `outbound({ …, idempotent: true, headers: { 'idempotency-key': key } }) # with ${site.vendor}'s key header; or drop retry`,
|
|
182
|
+
meta: siteMeta(site),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function outboundBodyInvalid(site: OutboundSite, issues: readonly string[]): UltimateError {
|
|
187
|
+
return new UltimateError({
|
|
188
|
+
code: 'X_OUTBOUND_BODY_INVALID',
|
|
189
|
+
cause: `${siteLabel(site)} answered a body its schema refused at ${issues.join(', ') || 'the root'}`,
|
|
190
|
+
fix: `answer.json(schema) # edit that schema for ${site.vendor}, or ask the vendor whether the response changed`,
|
|
191
|
+
meta: { ...siteMeta(site), issues },
|
|
192
|
+
});
|
|
193
|
+
}
|