@telorun/sdk 0.48.0 → 0.49.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/dist/index.d.ts CHANGED
@@ -17,6 +17,7 @@ export * from "./resource-context.js";
17
17
  export * from "./resource-instance.js";
18
18
  export * from "./resource-manifest.js";
19
19
  export * from "./invoke-error.js";
20
+ export * from "./network-fetch.js";
20
21
  export * from "./runtime-error.js";
21
22
  export * from "./runtime-event.js";
22
23
  export * from "./runtime-resource.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,UAAU,CAAC;AACzB,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AACxC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,qBAAqB,CAAC;AACpC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,UAAU,CAAC;AACzB,cAAc,sBAAsB,CAAC;AACrC,cAAc,kBAAkB,CAAC;AACjC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,yBAAyB,CAAC;AACxC,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC;AACxC,cAAc,qBAAqB,CAAC;AACpC,cAAc,uBAAuB,CAAC;AACtC,cAAc,wBAAwB,CAAC;AACvC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,aAAa,CAAC;AAC5B,cAAc,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ export * from "./resource-context.js";
17
17
  export * from "./resource-instance.js";
18
18
  export * from "./resource-manifest.js";
19
19
  export * from "./invoke-error.js";
20
+ export * from "./network-fetch.js";
20
21
  export * from "./runtime-error.js";
21
22
  export * from "./runtime-event.js";
22
23
  export * from "./runtime-resource.js";
@@ -8,7 +8,9 @@
8
8
  export declare class InvokeError extends Error {
9
9
  readonly code: string;
10
10
  readonly data?: unknown;
11
- constructor(code: string, message: string, data?: unknown);
11
+ constructor(code: string, message: string, data?: unknown, options?: {
12
+ cause?: unknown;
13
+ });
12
14
  }
13
15
  export declare function isInvokeError(err: unknown): err is InvokeError;
14
16
  //# sourceMappingURL=invoke-error.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"invoke-error.d.ts","sourceRoot":"","sources":["../src/invoke-error.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;gBAEZ,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO;CAiB1D;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,WAAW,CAM9D"}
1
+ {"version":3,"file":"invoke-error.d.ts","sourceRoot":"","sources":["../src/invoke-error.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,qBAAa,WAAY,SAAQ,KAAK;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,OAAO,CAAC;gBAEZ,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,EAAE,OAAO,CAAC,EAAE;QAAE,KAAK,CAAC,EAAE,OAAO,CAAA;KAAE;CA8BzF;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,GAAG,IAAI,WAAW,CAM9D"}
@@ -9,7 +9,7 @@ const INVOKE_ERROR = Symbol.for("telo.InvokeError");
9
9
  export class InvokeError extends Error {
10
10
  code;
11
11
  data;
12
- constructor(code, message, data) {
12
+ constructor(code, message, data, options) {
13
13
  super(message);
14
14
  this.name = "InvokeError";
15
15
  this.code = code;
@@ -25,6 +25,19 @@ export class InvokeError extends Error {
25
25
  writable: false,
26
26
  configurable: false,
27
27
  });
28
+ // Preserve the error being wrapped. Defined rather than assigned so it is
29
+ // non-enumerable — matching how the Error constructor's own `cause` option
30
+ // behaves, and keeping it out of JSON serialisation / CEL property access
31
+ // for the same reason as the marker above. (`ErrorOptions` is not in this
32
+ // package's TS lib, hence the local option type.)
33
+ if (options && "cause" in options) {
34
+ Object.defineProperty(this, "cause", {
35
+ value: options.cause,
36
+ enumerable: false,
37
+ writable: true,
38
+ configurable: true,
39
+ });
40
+ }
28
41
  }
29
42
  }
30
43
  export function isInvokeError(err) {
@@ -0,0 +1,71 @@
1
+ /** Raised when a request never reached the peer — DNS, connect, reset, or TLS
2
+ * trust. Distinct from a non-OK HTTP response, which is a reply the caller
3
+ * interprets itself. */
4
+ export declare const ERR_NETWORK_UNREACHABLE = "ERR_NETWORK_UNREACHABLE";
5
+ /** The facts a transport failure carries. Deliberately structured rather than
6
+ * pre-rendered: a controller reports *what happened*, and whoever displays the
7
+ * error turns it into a sentence. Prose baked into a controller would have to
8
+ * be re-typed identically by every language SDK (TS, Rust, Go, …) and would
9
+ * drift; `cause: "ENOTFOUND"` is the same symbol in every language.
10
+ *
11
+ * `message` on the thrown error is a reasonable default for today's renderers;
12
+ * a kernel-side renderer can format from these fields instead. */
13
+ export interface NetworkErrorData {
14
+ /** What was being attempted, e.g. `"Embedding model request"`. */
15
+ operation: string;
16
+ url: string;
17
+ host: string;
18
+ port?: number;
19
+ /** The OS/undici code — `ENOTFOUND`, `ECONNREFUSED`, `CERT_HAS_EXPIRED`, … */
20
+ cause: string;
21
+ /** The underlying error's own message, kept verbatim. Carries detail no code
22
+ * mapping has (`SSL alert number 80`, a resolver's remarks), so wrapping is
23
+ * never a downgrade on an unmapped code. */
24
+ detail?: string;
25
+ /** `metadata.name` of the resource whose configuration produced `url`, so the
26
+ * error names the actual instance rather than its kind. */
27
+ resource?: string;
28
+ /** The setting to change — a manifest field (`baseUrl`) or a CLI/env name
29
+ * (`--registry`). Structured rather than a pre-written sentence: this is the
30
+ * one genuinely actionable part, and prose here would be the thing every
31
+ * other language SDK has to retype and keep in sync. */
32
+ setting?: string;
33
+ }
34
+ /**
35
+ * Walk the cause chain for the first `code` — undici wraps the real error one or
36
+ * more levels down, which is exactly the detail lost when only `message` is
37
+ * reported.
38
+ *
39
+ * Exported because classifying a network failure by substring-matching the
40
+ * message does not work: `fetch` rejects with the literal text `"fetch failed"`
41
+ * for DNS, refusal, and TLS alike, so a `message.includes("enotfound")` test
42
+ * silently never matches and every failure collapses into whichever branch is
43
+ * last. Callers with their own error contract should classify on this code
44
+ * rather than on prose.
45
+ */
46
+ export declare function networkCauseCode(err: unknown): string | undefined;
47
+ /**
48
+ * `fetch` that turns a transport-level failure into an {@link InvokeError}
49
+ * carrying {@link NetworkErrorData}, instead of undici's opaque
50
+ * `TypeError: fetch failed` whose real cause sits unread on `error.cause`.
51
+ *
52
+ * Only *transport* failures are wrapped. A non-OK response is returned
53
+ * untouched, because a status code is a reply the caller interprets (and often
54
+ * renders from the provider's own error body) — so this drops into an existing
55
+ * call site without changing status handling. Cancellation is re-thrown as-is:
56
+ * an aborted request is the caller's intent, not a network fault.
57
+ *
58
+ * @param context.operation What is being attempted, for the message.
59
+ * @param context.resource `metadata.name` of the resource whose configuration
60
+ * produced the URL, so the error names the instance, not just its kind.
61
+ * @param context.setting The manifest field or CLI/env name to change. Passed
62
+ * as a bare identifier, never a sentence — the wording is composed here, in
63
+ * one place, so another language's SDK supplies the same two facts rather
64
+ * than retyping the same English.
65
+ */
66
+ export declare function fetchOrThrow(input: string | URL, init: RequestInit | undefined, context: {
67
+ operation: string;
68
+ resource?: string;
69
+ setting?: string;
70
+ }): Promise<Response>;
71
+ //# sourceMappingURL=network-fetch.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"network-fetch.d.ts","sourceRoot":"","sources":["../src/network-fetch.ts"],"names":[],"mappings":"AAGA;;yBAEyB;AACzB,eAAO,MAAM,uBAAuB,4BAA4B,CAAC;AAEjE;;;;;;;mEAOmE;AACnE,MAAM,WAAW,gBAAgB;IAC/B,kEAAkE;IAClE,SAAS,EAAE,MAAM,CAAC;IAClB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,8EAA8E;IAC9E,KAAK,EAAE,MAAM,CAAC;IACd;;iDAE6C;IAC7C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;gEAC4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;6DAGyD;IACzD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAiDD;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAQjE;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAsB,YAAY,CAChC,KAAK,EAAE,MAAM,GAAG,GAAG,EACnB,IAAI,EAAE,WAAW,GAAG,SAAS,EAC7B,OAAO,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAClE,OAAO,CAAC,QAAQ,CAAC,CA4CnB"}
@@ -0,0 +1,140 @@
1
+ import { isCancellationError } from "./cancellation.js";
2
+ import { InvokeError } from "./invoke-error.js";
3
+ /** Raised when a request never reached the peer — DNS, connect, reset, or TLS
4
+ * trust. Distinct from a non-OK HTTP response, which is a reply the caller
5
+ * interprets itself. */
6
+ export const ERR_NETWORK_UNREACHABLE = "ERR_NETWORK_UNREACHABLE";
7
+ /** Human explanation per transport failure code. Interpolates only the facts
8
+ * already in `NetworkErrorData`, so a renderer in another language can produce
9
+ * the same sentence from the same fields. */
10
+ function explain(code, host, port) {
11
+ const target = port ? `${host}:${port}` : host;
12
+ switch (code) {
13
+ case "ENOTFOUND":
14
+ return `DNS lookup failed for host '${host}' — the name does not resolve`;
15
+ case "EAI_AGAIN":
16
+ return `DNS is temporarily unavailable resolving '${host}' — a transient resolver failure`;
17
+ case "ECONNREFUSED":
18
+ return `nothing is listening on ${target}`;
19
+ case "ECONNRESET":
20
+ return `the connection to ${target} was reset by the peer`;
21
+ case "EHOSTUNREACH":
22
+ return `no network route to ${host}`;
23
+ case "ETIMEDOUT":
24
+ case "UND_ERR_CONNECT_TIMEOUT":
25
+ return `the connection to ${target} timed out`;
26
+ case "CERT_HAS_EXPIRED":
27
+ return `the TLS certificate presented by ${host} has expired`;
28
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
29
+ case "SELF_SIGNED_CERT_IN_CHAIN":
30
+ return `the TLS certificate presented by ${host} is self-signed and not trusted`;
31
+ case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
32
+ return `the TLS certificate chain presented by ${host} could not be verified`;
33
+ case "EPROTO":
34
+ return `the TLS handshake with ${host} failed`;
35
+ default:
36
+ return `the request to ${target} failed at the transport layer`;
37
+ }
38
+ }
39
+ /** The deepest `message` in the cause chain — the one carrying detail a code
40
+ * mapping cannot have. Skips undici's own `"fetch failed"`, which is the
41
+ * placeholder this whole module exists to replace. */
42
+ function causeDetail(err) {
43
+ let current = err;
44
+ let detail;
45
+ for (let depth = 0; current && depth < 5; depth++) {
46
+ const message = current.message;
47
+ if (typeof message === "string" && message && message !== "fetch failed")
48
+ detail = message;
49
+ current = current.cause;
50
+ }
51
+ return detail;
52
+ }
53
+ /**
54
+ * Walk the cause chain for the first `code` — undici wraps the real error one or
55
+ * more levels down, which is exactly the detail lost when only `message` is
56
+ * reported.
57
+ *
58
+ * Exported because classifying a network failure by substring-matching the
59
+ * message does not work: `fetch` rejects with the literal text `"fetch failed"`
60
+ * for DNS, refusal, and TLS alike, so a `message.includes("enotfound")` test
61
+ * silently never matches and every failure collapses into whichever branch is
62
+ * last. Callers with their own error contract should classify on this code
63
+ * rather than on prose.
64
+ */
65
+ export function networkCauseCode(err) {
66
+ let current = err;
67
+ for (let depth = 0; current && depth < 5; depth++) {
68
+ const code = current.code;
69
+ if (typeof code === "string")
70
+ return code;
71
+ current = current.cause;
72
+ }
73
+ return undefined;
74
+ }
75
+ /**
76
+ * `fetch` that turns a transport-level failure into an {@link InvokeError}
77
+ * carrying {@link NetworkErrorData}, instead of undici's opaque
78
+ * `TypeError: fetch failed` whose real cause sits unread on `error.cause`.
79
+ *
80
+ * Only *transport* failures are wrapped. A non-OK response is returned
81
+ * untouched, because a status code is a reply the caller interprets (and often
82
+ * renders from the provider's own error body) — so this drops into an existing
83
+ * call site without changing status handling. Cancellation is re-thrown as-is:
84
+ * an aborted request is the caller's intent, not a network fault.
85
+ *
86
+ * @param context.operation What is being attempted, for the message.
87
+ * @param context.resource `metadata.name` of the resource whose configuration
88
+ * produced the URL, so the error names the instance, not just its kind.
89
+ * @param context.setting The manifest field or CLI/env name to change. Passed
90
+ * as a bare identifier, never a sentence — the wording is composed here, in
91
+ * one place, so another language's SDK supplies the same two facts rather
92
+ * than retyping the same English.
93
+ */
94
+ export async function fetchOrThrow(input, init, context) {
95
+ const url = typeof input === "string" ? input : input.toString();
96
+ try {
97
+ return await fetch(input, init);
98
+ }
99
+ catch (err) {
100
+ if (isCancellationError(err))
101
+ throw err;
102
+ if (err instanceof DOMException && err.name === "AbortError")
103
+ throw err;
104
+ const code = networkCauseCode(err) ?? "UNKNOWN";
105
+ let host = url;
106
+ let port;
107
+ try {
108
+ const parsed = new URL(url);
109
+ host = parsed.hostname;
110
+ if (parsed.port)
111
+ port = Number(parsed.port);
112
+ }
113
+ catch {
114
+ // Non-absolute URL — fall back to the raw string as the host label.
115
+ }
116
+ const detail = causeDetail(err);
117
+ const data = { operation: context.operation, url, host, cause: code };
118
+ if (port !== undefined)
119
+ data.port = port;
120
+ if (detail)
121
+ data.detail = detail;
122
+ if (context.resource)
123
+ data.resource = context.resource;
124
+ if (context.setting)
125
+ data.setting = context.setting;
126
+ // `detail` is appended only when the code has no mapping of its own —
127
+ // otherwise the explanation already says it better. Without this, wrapping
128
+ // an unmapped code would *lose* information relative to the raw error.
129
+ const explained = explain(code, host, port);
130
+ const isMapped = !explained.startsWith("the request to");
131
+ const because = isMapped || !detail ? explained : `${explained} (${detail})`;
132
+ const fix = context.setting
133
+ ? ` Check \`${context.setting}\`${context.resource ? ` on resource '${context.resource}'` : ""}.`
134
+ : "";
135
+ const message = `${context.operation} failed: cannot reach ${url} — ${code}: ${because}.${fix}`;
136
+ // The wrapped error stays reachable as `cause`: the code mapping is a
137
+ // convenience, never a reason to destroy what was actually thrown.
138
+ throw new InvokeError(ERR_NETWORK_UNREACHABLE, message, data, { cause: err });
139
+ }
140
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/sdk",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Telo SDK - Public API for Telo module authors.",
5
5
  "keywords": [
6
6
  "telo",
package/src/index.ts CHANGED
@@ -17,6 +17,7 @@ export * from "./resource-context.js";
17
17
  export * from "./resource-instance.js";
18
18
  export * from "./resource-manifest.js";
19
19
  export * from "./invoke-error.js";
20
+ export * from "./network-fetch.js";
20
21
  export * from "./runtime-error.js";
21
22
  export * from "./runtime-event.js";
22
23
  export * from "./runtime-resource.js";
@@ -11,7 +11,7 @@ export class InvokeError extends Error {
11
11
  readonly code: string;
12
12
  readonly data?: unknown;
13
13
 
14
- constructor(code: string, message: string, data?: unknown) {
14
+ constructor(code: string, message: string, data?: unknown, options?: { cause?: unknown }) {
15
15
  super(message);
16
16
  this.name = "InvokeError";
17
17
  this.code = code;
@@ -27,6 +27,19 @@ export class InvokeError extends Error {
27
27
  writable: false,
28
28
  configurable: false,
29
29
  });
30
+ // Preserve the error being wrapped. Defined rather than assigned so it is
31
+ // non-enumerable — matching how the Error constructor's own `cause` option
32
+ // behaves, and keeping it out of JSON serialisation / CEL property access
33
+ // for the same reason as the marker above. (`ErrorOptions` is not in this
34
+ // package's TS lib, hence the local option type.)
35
+ if (options && "cause" in options) {
36
+ Object.defineProperty(this, "cause", {
37
+ value: options.cause,
38
+ enumerable: false,
39
+ writable: true,
40
+ configurable: true,
41
+ });
42
+ }
30
43
  }
31
44
  }
32
45
 
@@ -0,0 +1,175 @@
1
+ import { isCancellationError } from "./cancellation.js";
2
+ import { InvokeError } from "./invoke-error.js";
3
+
4
+ /** Raised when a request never reached the peer — DNS, connect, reset, or TLS
5
+ * trust. Distinct from a non-OK HTTP response, which is a reply the caller
6
+ * interprets itself. */
7
+ export const ERR_NETWORK_UNREACHABLE = "ERR_NETWORK_UNREACHABLE";
8
+
9
+ /** The facts a transport failure carries. Deliberately structured rather than
10
+ * pre-rendered: a controller reports *what happened*, and whoever displays the
11
+ * error turns it into a sentence. Prose baked into a controller would have to
12
+ * be re-typed identically by every language SDK (TS, Rust, Go, …) and would
13
+ * drift; `cause: "ENOTFOUND"` is the same symbol in every language.
14
+ *
15
+ * `message` on the thrown error is a reasonable default for today's renderers;
16
+ * a kernel-side renderer can format from these fields instead. */
17
+ export interface NetworkErrorData {
18
+ /** What was being attempted, e.g. `"Embedding model request"`. */
19
+ operation: string;
20
+ url: string;
21
+ host: string;
22
+ port?: number;
23
+ /** The OS/undici code — `ENOTFOUND`, `ECONNREFUSED`, `CERT_HAS_EXPIRED`, … */
24
+ cause: string;
25
+ /** The underlying error's own message, kept verbatim. Carries detail no code
26
+ * mapping has (`SSL alert number 80`, a resolver's remarks), so wrapping is
27
+ * never a downgrade on an unmapped code. */
28
+ detail?: string;
29
+ /** `metadata.name` of the resource whose configuration produced `url`, so the
30
+ * error names the actual instance rather than its kind. */
31
+ resource?: string;
32
+ /** The setting to change — a manifest field (`baseUrl`) or a CLI/env name
33
+ * (`--registry`). Structured rather than a pre-written sentence: this is the
34
+ * one genuinely actionable part, and prose here would be the thing every
35
+ * other language SDK has to retype and keep in sync. */
36
+ setting?: string;
37
+ }
38
+
39
+ /** Human explanation per transport failure code. Interpolates only the facts
40
+ * already in `NetworkErrorData`, so a renderer in another language can produce
41
+ * the same sentence from the same fields. */
42
+ function explain(code: string, host: string, port?: number): string {
43
+ const target = port ? `${host}:${port}` : host;
44
+ switch (code) {
45
+ case "ENOTFOUND":
46
+ return `DNS lookup failed for host '${host}' — the name does not resolve`;
47
+ case "EAI_AGAIN":
48
+ return `DNS is temporarily unavailable resolving '${host}' — a transient resolver failure`;
49
+ case "ECONNREFUSED":
50
+ return `nothing is listening on ${target}`;
51
+ case "ECONNRESET":
52
+ return `the connection to ${target} was reset by the peer`;
53
+ case "EHOSTUNREACH":
54
+ return `no network route to ${host}`;
55
+ case "ETIMEDOUT":
56
+ case "UND_ERR_CONNECT_TIMEOUT":
57
+ return `the connection to ${target} timed out`;
58
+ case "CERT_HAS_EXPIRED":
59
+ return `the TLS certificate presented by ${host} has expired`;
60
+ case "DEPTH_ZERO_SELF_SIGNED_CERT":
61
+ case "SELF_SIGNED_CERT_IN_CHAIN":
62
+ return `the TLS certificate presented by ${host} is self-signed and not trusted`;
63
+ case "UNABLE_TO_VERIFY_LEAF_SIGNATURE":
64
+ return `the TLS certificate chain presented by ${host} could not be verified`;
65
+ case "EPROTO":
66
+ return `the TLS handshake with ${host} failed`;
67
+ default:
68
+ return `the request to ${target} failed at the transport layer`;
69
+ }
70
+ }
71
+
72
+ /** The deepest `message` in the cause chain — the one carrying detail a code
73
+ * mapping cannot have. Skips undici's own `"fetch failed"`, which is the
74
+ * placeholder this whole module exists to replace. */
75
+ function causeDetail(err: unknown): string | undefined {
76
+ let current: unknown = err;
77
+ let detail: string | undefined;
78
+ for (let depth = 0; current && depth < 5; depth++) {
79
+ const message = (current as { message?: unknown }).message;
80
+ if (typeof message === "string" && message && message !== "fetch failed") detail = message;
81
+ current = (current as { cause?: unknown }).cause;
82
+ }
83
+ return detail;
84
+ }
85
+
86
+ /**
87
+ * Walk the cause chain for the first `code` — undici wraps the real error one or
88
+ * more levels down, which is exactly the detail lost when only `message` is
89
+ * reported.
90
+ *
91
+ * Exported because classifying a network failure by substring-matching the
92
+ * message does not work: `fetch` rejects with the literal text `"fetch failed"`
93
+ * for DNS, refusal, and TLS alike, so a `message.includes("enotfound")` test
94
+ * silently never matches and every failure collapses into whichever branch is
95
+ * last. Callers with their own error contract should classify on this code
96
+ * rather than on prose.
97
+ */
98
+ export function networkCauseCode(err: unknown): string | undefined {
99
+ let current: unknown = err;
100
+ for (let depth = 0; current && depth < 5; depth++) {
101
+ const code = (current as { code?: unknown }).code;
102
+ if (typeof code === "string") return code;
103
+ current = (current as { cause?: unknown }).cause;
104
+ }
105
+ return undefined;
106
+ }
107
+
108
+ /**
109
+ * `fetch` that turns a transport-level failure into an {@link InvokeError}
110
+ * carrying {@link NetworkErrorData}, instead of undici's opaque
111
+ * `TypeError: fetch failed` whose real cause sits unread on `error.cause`.
112
+ *
113
+ * Only *transport* failures are wrapped. A non-OK response is returned
114
+ * untouched, because a status code is a reply the caller interprets (and often
115
+ * renders from the provider's own error body) — so this drops into an existing
116
+ * call site without changing status handling. Cancellation is re-thrown as-is:
117
+ * an aborted request is the caller's intent, not a network fault.
118
+ *
119
+ * @param context.operation What is being attempted, for the message.
120
+ * @param context.resource `metadata.name` of the resource whose configuration
121
+ * produced the URL, so the error names the instance, not just its kind.
122
+ * @param context.setting The manifest field or CLI/env name to change. Passed
123
+ * as a bare identifier, never a sentence — the wording is composed here, in
124
+ * one place, so another language's SDK supplies the same two facts rather
125
+ * than retyping the same English.
126
+ */
127
+ export async function fetchOrThrow(
128
+ input: string | URL,
129
+ init: RequestInit | undefined,
130
+ context: { operation: string; resource?: string; setting?: string },
131
+ ): Promise<Response> {
132
+ const url = typeof input === "string" ? input : input.toString();
133
+ try {
134
+ return await fetch(input, init);
135
+ } catch (err) {
136
+ if (isCancellationError(err)) throw err;
137
+ if (err instanceof DOMException && err.name === "AbortError") throw err;
138
+
139
+ const code = networkCauseCode(err) ?? "UNKNOWN";
140
+ let host = url;
141
+ let port: number | undefined;
142
+ try {
143
+ const parsed = new URL(url);
144
+ host = parsed.hostname;
145
+ if (parsed.port) port = Number(parsed.port);
146
+ } catch {
147
+ // Non-absolute URL — fall back to the raw string as the host label.
148
+ }
149
+
150
+ const detail = causeDetail(err);
151
+ const data: NetworkErrorData = { operation: context.operation, url, host, cause: code };
152
+ if (port !== undefined) data.port = port;
153
+ if (detail) data.detail = detail;
154
+ if (context.resource) data.resource = context.resource;
155
+ if (context.setting) data.setting = context.setting;
156
+
157
+ // `detail` is appended only when the code has no mapping of its own —
158
+ // otherwise the explanation already says it better. Without this, wrapping
159
+ // an unmapped code would *lose* information relative to the raw error.
160
+ const explained = explain(code, host, port);
161
+ const isMapped = !explained.startsWith("the request to");
162
+ const because = isMapped || !detail ? explained : `${explained} (${detail})`;
163
+
164
+ const fix = context.setting
165
+ ? ` Check \`${context.setting}\`${context.resource ? ` on resource '${context.resource}'` : ""}.`
166
+ : "";
167
+
168
+ const message =
169
+ `${context.operation} failed: cannot reach ${url} — ${code}: ${because}.${fix}`;
170
+
171
+ // The wrapped error stays reachable as `cause`: the code mapping is a
172
+ // convenience, never a reason to destroy what was actually thrown.
173
+ throw new InvokeError(ERR_NETWORK_UNREACHABLE, message, data, { cause: err });
174
+ }
175
+ }