@lowdefy/errors 7.0.0 → 7.2.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.
@@ -12,7 +12,10 @@
12
12
  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
13
  See the License for the specific language governing permissions and
14
14
  limitations under the License.
15
- */ /**
15
+ */ import findInErrorChain from './findInErrorChain.js';
16
+ import hasTimeoutInChain from './hasTimeoutInChain.js';
17
+ import isTimeoutError from './isTimeoutError.js';
18
+ /**
16
19
  * Network error codes that indicate service issues (not config problems).
17
20
  */ const SERVICE_ERROR_CODES = new Set([
18
21
  'ECONNREFUSED',
@@ -37,11 +40,22 @@ function readHttpStatus(error) {
37
40
  const numericCode = Number.isInteger(error.code) && error.code >= 100 && error.code < 600 ? error.code : undefined;
38
41
  return error.statusCode ?? error.status ?? error.response?.status ?? error.$metadata?.httpStatusCode ?? numericCode;
39
42
  }
40
- // The first HTTP status down a retrying client's lastError and the cause chain.
41
- function findHttpStatus(error, seen = new Set()) {
42
- if (!error || typeof error !== 'object' || seen.has(error)) return undefined;
43
- seen.add(error);
44
- return readHttpStatus(error) ?? findHttpStatus(error.lastError, seen) ?? findHttpStatus(error.cause, seen);
43
+ // What failed, read once from the error and its chain. A call that ran out of time got no
44
+ // answer, so any status attached above the cancellation is one a client made up, not a
45
+ // server response: the AI gateway client reports 500 for an AbortSignal.timeout, and 408
46
+ // for an undici timeout. A real 5xx or 429 wrapped around a timeout looks the same, so the
47
+ // timeout wins.
48
+ function classifyFailure(error) {
49
+ if (hasTimeoutInChain(error)) {
50
+ return {
51
+ timedOut: true,
52
+ statusCode: undefined
53
+ };
54
+ }
55
+ return {
56
+ timedOut: false,
57
+ statusCode: findInErrorChain(error, readHttpStatus)
58
+ };
45
59
  }
46
60
  // 5xx is a failing service; 429 is a service throttling its callers. Both pass with
47
61
  // time, and neither is fixed by changing config.
@@ -87,6 +101,10 @@ function readRetryAfterHeader(headers) {
87
101
  */ static isServiceError(error, seen = new Set()) {
88
102
  if (!error || seen.has(error)) return false;
89
103
  seen.add(error);
104
+ // A connection that has already classified its error as the service refusing the request
105
+ // (a duplicate key, an invalid query) says so. The message checks below would otherwise
106
+ // match words in a collection name or in the document values a driver message quotes.
107
+ if (error.isServiceError === false) return false;
90
108
  // A client that retried and gave up (the AI SDK's RetryError) carries the
91
109
  // failure it retried on; its own message and fields hold no status.
92
110
  if (error.lastError) {
@@ -97,8 +115,8 @@ function readRetryAfterHeader(headers) {
97
115
  return true;
98
116
  }
99
117
  // A call cancelled because it ran out of time (AbortSignal.timeout, the AI SDK's
100
- // timeout option, the server's request timeout).
101
- if (error.name === 'TimeoutError') {
118
+ // timeout option, the server's request timeout, undici's timeouts).
119
+ if (isTimeoutError(error)) {
102
120
  return true;
103
121
  }
104
122
  // AWS SDK v3 marks throttling exceptions as retryable throttling; some services
@@ -121,10 +139,12 @@ function readRetryAfterHeader(headers) {
121
139
  /**
122
140
  * Enhances an error message with more helpful context.
123
141
  * @param {Error} error - The original error
142
+ * @param {Object} [failure] - Whether the call timed out and the status it failed with,
143
+ * when the caller already worked them out
124
144
  * @returns {string} Enhanced error message
125
- */ static enhanceMessage(error) {
145
+ */ static enhanceMessage(error, failure = classifyFailure(error)) {
126
146
  const code = error.code;
127
- const statusCode = findHttpStatus(error);
147
+ const { timedOut, statusCode } = failure;
128
148
  if (code === 'ECONNREFUSED') {
129
149
  return `Connection refused. The service may be down or the address may be incorrect. ${error.message ?? ''}`;
130
150
  }
@@ -137,6 +157,9 @@ function readRetryAfterHeader(headers) {
137
157
  if (code === 'ECONNRESET') {
138
158
  return `Connection reset by the server. ${error.message ?? ''}`;
139
159
  }
160
+ if (timedOut) {
161
+ return `Request timed out. The service may be slow, or the timeout too short. ${error.message ?? ''}`;
162
+ }
140
163
  if (statusCode === 429) {
141
164
  const retryAfter = ServiceError.readRetryAfter(error);
142
165
  const retry = retryAfter ? ` Retry after ${retryAfter}.` : '';
@@ -151,16 +174,10 @@ function readRetryAfterHeader(headers) {
151
174
  * Reads the Retry-After header a throttling service sent, from the error, the
152
175
  * failure a retrying client gave up on, or its cause chain.
153
176
  * @param {Error} error - The error to read
154
- * @param {Set} [seen] - Errors already read, so a cyclic cause chain ends
155
177
  * @returns {string|null} The header value (seconds or an HTTP date), or null
156
- */ static readRetryAfter(error, seen = new Set()) {
157
- if (!error || typeof error !== 'object' || seen.has(error)) return null;
158
- seen.add(error);
159
- const retryAfter = readRetryAfterHeader(error.responseHeaders) ?? readRetryAfterHeader(error.response?.headers) ?? readRetryAfterHeader(error.headers) ?? readRetryAfterHeader(error.$response?.headers);
160
- if (retryAfter !== undefined && retryAfter !== null) {
161
- return String(retryAfter);
162
- }
163
- return ServiceError.readRetryAfter(error.lastError, seen) ?? ServiceError.readRetryAfter(error.cause, seen);
178
+ */ static readRetryAfter(error) {
179
+ const retryAfter = findInErrorChain(error, (node)=>readRetryAfterHeader(node.responseHeaders) ?? readRetryAfterHeader(node.response?.headers) ?? readRetryAfterHeader(node.headers) ?? readRetryAfterHeader(node.$response?.headers) ?? undefined);
180
+ return retryAfter === undefined ? null : String(retryAfter);
164
181
  }
165
182
  /**
166
183
  * Creates a ServiceError instance with formatted message.
@@ -178,9 +195,13 @@ function readRetryAfterHeader(headers) {
178
195
  */ constructor(message, { cause, service, code, statusCode, configKey, hint, retryAfter } = {}){
179
196
  // Extract info from wrapped error if provided
180
197
  const errorCode = code ?? cause?.code;
181
- const errorStatusCode = statusCode ?? findHttpStatus(cause);
198
+ // A status passed in is the caller saying a service answered with it.
199
+ const failure = statusCode === undefined || statusCode === null ? classifyFailure(cause) : {
200
+ timedOut: false,
201
+ statusCode
202
+ };
182
203
  // Use provided message, or enhance wrapped error's message
183
- const baseMessage = message ?? (cause ? ServiceError.enhanceMessage(cause) : 'Service error');
204
+ const baseMessage = message ?? (cause ? ServiceError.enhanceMessage(cause, failure) : 'Service error');
184
205
  // Message without prefix - logger uses error.name for display
185
206
  // Include service in message if provided
186
207
  const formattedMessage = service ? `${service}: ${baseMessage}` : baseMessage;
@@ -193,7 +214,7 @@ function readRetryAfterHeader(headers) {
193
214
  this.service = service;
194
215
  this.code = errorCode;
195
216
  this.hint = hint ?? cause?.hint ?? null;
196
- this.statusCode = errorStatusCode;
217
+ this.statusCode = failure.statusCode;
197
218
  this.retryAfter = retryAfter ?? ServiceError.readRetryAfter(cause);
198
219
  // Extract from the wrapped error like PluginError does, so a ServiceError
199
220
  // wrapped around an error that already resolved its config location keeps it.
@@ -0,0 +1,68 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import hasTimeoutInChain from './hasTimeoutInChain.js';
16
+ import readErrorCodes from './readErrorCodes.js';
17
+ // What a log may say about an error. Chat text and other inputs can hold personal information,
18
+ // and logs have their own retention and access, so a log carries no prompt, request property,
19
+ // tool input or document. An error carries them where a plain `{ err }` log would write them: a
20
+ // driver's error embeds the document it rejected, an AI SDK TypeValidationError's or
21
+ // JSONParseError's message embeds the value it rejected (the whole conversation, a tool's
22
+ // input), InvalidToolInputError, ToolCallRepairError and RetryError repeat their cause's
23
+ // message, and every stack opens with its message. So an AI SDK error is summarised by name
24
+ // and code alone, except APICallError, whose message is the provider's reply; any other error
25
+ // keeps its name, code, status code and message, cut to 500 characters, and none of its other
26
+ // fields. Causes are followed the same way, three deep.
27
+ const MESSAGE_LIMIT = 500;
28
+ const CAUSE_DEPTH = 3;
29
+ // AISDKError.isInstance reads this marker; the error classes themselves are not imported so
30
+ // any copy of the AI SDK in the app is recognised.
31
+ const aiSdkErrorMarker = Symbol.for('vercel.ai.error');
32
+ function isAiSdkError(error) {
33
+ return error[aiSdkErrorMarker] === true;
34
+ }
35
+ // An error from another realm, such as a DOMException in a VM context, fails instanceof Error.
36
+ function isError(value) {
37
+ if (value instanceof Error) return true;
38
+ return typeof value === 'object' && value !== null && typeof value.stack === 'string' && typeof value.message === 'string';
39
+ }
40
+ function truncate(text) {
41
+ if (text.length <= MESSAGE_LIMIT) return text;
42
+ return `${text.slice(0, MESSAGE_LIMIT - 1)}…`;
43
+ }
44
+ function errorSummaryForLog(error, depth = 0) {
45
+ if (!isError(error)) {
46
+ return {
47
+ name: typeof error
48
+ };
49
+ }
50
+ const summary = {
51
+ name: error.name
52
+ };
53
+ if (!isAiSdkError(error) || error.name === 'AI_APICallError') {
54
+ summary.message = truncate(String(error.message));
55
+ }
56
+ const { code, statusCode } = readErrorCodes(error);
57
+ if (code !== undefined) summary.code = code;
58
+ // A status above a call that ran out of time is one the client made up, not a server
59
+ // response (see ServiceError), so neither the log nor the client is told it.
60
+ if (statusCode !== undefined && !hasTimeoutInChain(error)) summary.statusCode = statusCode;
61
+ // A RetryError holds the attempt that failed last as lastError rather than as its cause.
62
+ const cause = error.lastError ?? error.cause;
63
+ if (cause !== undefined && depth < CAUSE_DEPTH) {
64
+ summary.cause = errorSummaryForLog(cause, depth + 1);
65
+ }
66
+ return summary;
67
+ }
68
+ export default errorSummaryForLog;
@@ -0,0 +1,22 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // The first value `read` gives for an error, the failure a retrying client gave up on
16
+ // (lastError), or the cause chain, in that order. Stops on a cycle.
17
+ function findInErrorChain(error, read, seen = new Set()) {
18
+ if (!error || typeof error !== 'object' || seen.has(error)) return undefined;
19
+ seen.add(error);
20
+ return read(error) ?? findInErrorChain(error.lastError, read, seen) ?? findInErrorChain(error.cause, read, seen);
21
+ }
22
+ export default findInErrorChain;
@@ -0,0 +1,22 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import findInErrorChain from './findInErrorChain.js';
16
+ import isTimeoutError from './isTimeoutError.js';
17
+ // Whether the error, or anything down its lastError and cause chain, is a call cancelled
18
+ // because it ran out of time.
19
+ function hasTimeoutInChain(error) {
20
+ return findInErrorChain(error, (node)=>isTimeoutError(node) ? true : undefined) === true;
21
+ }
22
+ export default hasTimeoutInChain;
package/dist/index.js CHANGED
@@ -73,7 +73,9 @@
73
73
  * shouldSuppressBuildCheck - Check ~ignoreBuildChecks in parent chain
74
74
  *
75
75
  * Error Field Utilities:
76
+ * errorSummaryForLog - {name, message, code, statusCode, cause} an error may log
76
77
  * expectedErrorNames - Set of error class names that are expected outcomes, not faults
78
+ * isExpectedError - The one test for an expected outcome versus a fault
77
79
  * readErrorCodes - {code, statusCode} from one error node's own fields
78
80
  * lowdefyErrorNames - Set of Lowdefy error class names
79
81
  * lowdefyErrorTypes - Lowdefy error class by name, for reviving and reshaping errors
@@ -84,8 +86,10 @@ import BlockError from './BlockError.js';
84
86
  import BuildError from './BuildError.js';
85
87
  import ConfigError from './ConfigError.js';
86
88
  import ConfigWarning from './ConfigWarning.js';
89
+ import errorSummaryForLog from './errorSummaryForLog.js';
87
90
  import errorToDisplayString from './errorToDisplayString.js';
88
91
  import expectedErrorNames from './expectedErrorNames.js';
92
+ import isExpectedError from './isExpectedError.js';
89
93
  import LowdefyInternalError from './LowdefyInternalError.js';
90
94
  import lowdefyErrorNames from './lowdefyErrorNames.js';
91
95
  import lowdefyErrorTypes from './lowdefyErrorTypes.js';
@@ -101,4 +105,4 @@ import shouldSuppressBuildCheck, { PLUGIN_CHECKS_SLUG, PLUGIN_SCHEMA_SLUG, VALID
101
105
  import TenantIntegrityError from './TenantIntegrityError.js';
102
106
  import TwoFactorEnrolmentRequiredError from './TwoFactorEnrolmentRequiredError.js';
103
107
  import UserError from './UserError.js';
104
- export { ActionError, AuthenticationError, AuthorizationError, BlockError, BuildError, ConfigError, ConfigWarning, errorToDisplayString, expectedErrorNames, LowdefyInternalError, lowdefyErrorNames, lowdefyErrorTypes, OperatorError, PluginError, PLUGIN_CHECKS_SLUG, PLUGIN_SCHEMA_SLUG, readErrorCodes, RequestError, resolveConfigLocation, loadAndResolveErrorLocation, resolveErrorLocation, ServiceError, shouldSuppressBuildCheck, TenantIntegrityError, TwoFactorEnrolmentRequiredError, UserError, VALID_CHECK_SLUGS };
108
+ export { ActionError, AuthenticationError, AuthorizationError, BlockError, BuildError, ConfigError, ConfigWarning, errorSummaryForLog, errorToDisplayString, expectedErrorNames, isExpectedError, LowdefyInternalError, lowdefyErrorNames, lowdefyErrorTypes, OperatorError, PluginError, PLUGIN_CHECKS_SLUG, PLUGIN_SCHEMA_SLUG, readErrorCodes, RequestError, resolveConfigLocation, loadAndResolveErrorLocation, resolveErrorLocation, ServiceError, shouldSuppressBuildCheck, TenantIntegrityError, TwoFactorEnrolmentRequiredError, UserError, VALID_CHECK_SLUGS };
@@ -0,0 +1,24 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ import expectedErrorNames from './expectedErrorNames.js';
16
+ // The one test for "the system worked and said no" (an expected outcome) versus a
17
+ // fault. Every site that picks a log level, decides on Sentry or feeds the dev
18
+ // agent feed asks this, so a refusal is classified the same way on every route
19
+ // and transport. Keyed on the name: a serializer round trip or another package's
20
+ // copy of a class keeps the name but not the class identity.
21
+ function isExpectedError(error) {
22
+ return expectedErrorNames.has(error?.name);
23
+ }
24
+ export default isExpectedError;
@@ -0,0 +1,37 @@
1
+ /*
2
+ Copyright 2020-2026 Lowdefy, Inc
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Unless required by applicable law or agreed to in writing, software
11
+ distributed under the License is distributed on an "AS IS" BASIS,
12
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ See the License for the specific language governing permissions and
14
+ limitations under the License.
15
+ */ // Codes a client sets when it gave up waiting: Node sockets, request, and undici's
16
+ // connect, headers and body timeouts.
17
+ const TIMEOUT_CODES = new Set([
18
+ 'ETIMEDOUT',
19
+ 'ESOCKETTIMEDOUT',
20
+ 'UND_ERR_CONNECT_TIMEOUT',
21
+ 'UND_ERR_HEADERS_TIMEOUT',
22
+ 'UND_ERR_BODY_TIMEOUT'
23
+ ]);
24
+ // Names of the errors undici and AbortSignal.timeout throw when a call runs out of time.
25
+ const TIMEOUT_NAMES = new Set([
26
+ 'TimeoutError',
27
+ 'ConnectTimeoutError',
28
+ 'HeadersTimeoutError',
29
+ 'BodyTimeoutError'
30
+ ]);
31
+ // Whether this one error is a call cancelled because it ran out of time. Read from the
32
+ // error's own name and code only; a message that mentions a timeout is not enough.
33
+ function isTimeoutError(error) {
34
+ if (!error || typeof error !== 'object') return false;
35
+ return TIMEOUT_NAMES.has(error.name) || TIMEOUT_CODES.has(error.code);
36
+ }
37
+ export default isTimeoutError;
@@ -29,6 +29,7 @@
29
29
  'websocket-refs': 'Invalid websocket action reference warnings',
30
30
  'callmethod-refs': 'CallMethod actions targeting blocks not defined on the page',
31
31
  icons: 'Unresolvable icon name warnings',
32
+ 'native-ignored': 'Properties set on a mobile page that the native block ignores',
32
33
  types: 'All type validation (blocks, operators, actions, requests, connections)',
33
34
  schema: 'JSON schema validation errors',
34
35
  secrets: 'Environment variable names that are not set in the build environment',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lowdefy/errors",
3
- "version": "7.0.0",
3
+ "version": "7.2.0",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Lowdefy error classes for consistent error handling across build, server, and client",
6
6
  "homepage": "https://lowdefy.com",
@@ -34,11 +34,9 @@
34
34
  "dist/*"
35
35
  ],
36
36
  "devDependencies": {
37
- "@jest/globals": "28.1.3",
38
37
  "@swc/cli": "0.8.1",
39
38
  "@swc/core": "1.15.32",
40
- "@swc/jest": "0.2.39",
41
- "jest": "28.1.3"
39
+ "vitest": "4.1.12"
42
40
  },
43
41
  "publishConfig": {
44
42
  "access": "public"
@@ -46,6 +44,6 @@
46
44
  "scripts": {
47
45
  "build": "swc src --out-dir dist --config-file ../../../.swcrc --cli-config-file ../../../.swc-cli.json",
48
46
  "clean": "rm -rf dist",
49
- "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --passWithNoTests"
47
+ "test": "vitest run --passWithNoTests"
50
48
  }
51
49
  }