@slates/provider 1.0.0-rc.27 → 1.0.0-rc.29
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.cjs +2305 -1876
- package/dist/index.d.cts +82 -8
- package/dist/index.d.ts +82 -8
- package/dist/index.module.js +2295 -1876
- package/package.json +1 -1
- package/src/action/getFileUrlTool.ts +2 -1
- package/src/action/triggerGroup.ts +65 -3
- package/src/auth/redact.test.ts +60 -2
- package/src/auth/redact.ts +59 -23
- package/src/axios/trace.test.ts +326 -1
- package/src/axios/trace.ts +334 -14
- package/src/error/api.test.ts +123 -1
- package/src/error/api.ts +148 -0
- package/src/error/axios.ts +48 -13
- package/src/error/utils.test.ts +654 -0
- package/src/error/utils.ts +268 -13
package/src/error/api.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { badRequestError, type ErrorRecord, ServiceError } from '@lowerdeck/error';
|
|
2
|
+
import {
|
|
3
|
+
BAGGAGE_REDACTED_VALUE,
|
|
4
|
+
BAGGAGE_TRUNCATED_VALUE,
|
|
5
|
+
BAGGAGE_UNSERIALIZABLE_VALUE
|
|
6
|
+
} from './utils';
|
|
2
7
|
|
|
3
8
|
export type ApiErrorStatus = number | string;
|
|
4
9
|
export type ApiServiceError = ServiceError<ErrorRecord<any, any>>;
|
|
@@ -21,6 +26,7 @@ export type ApiErrorBuildHelpers = {
|
|
|
21
26
|
collectDetails: (value: unknown, details: string[], options?: ApiErrorDetailOptions) => void;
|
|
22
27
|
extractMessage: (error: unknown, options?: ApiErrorDetailOptions) => string;
|
|
23
28
|
getResponse: (error: unknown) => ApiErrorResponse | undefined;
|
|
29
|
+
getResponseWithBody: (error: unknown) => ApiErrorResponse | undefined;
|
|
24
30
|
getStatus: (error: unknown) => ApiErrorStatus | undefined;
|
|
25
31
|
isRecord: (value: unknown) => value is Record<string, unknown>;
|
|
26
32
|
statusLabelFor: (status?: ApiErrorStatus, statusText?: string) => string;
|
|
@@ -143,6 +149,147 @@ export let getApiErrorResponse = (error: unknown): ApiErrorResponse | undefined
|
|
|
143
149
|
export let getApiErrorStatus = (error: unknown): ApiErrorStatus | undefined =>
|
|
144
150
|
getApiErrorResponse(error)?.status;
|
|
145
151
|
|
|
152
|
+
// Like getApiErrorResponse, but also recovers the upstream response body from
|
|
153
|
+
// interceptor-normalized SlateErrors. createAxios stores the body (sanitized by
|
|
154
|
+
// sanitizeForBaggage) at SlateError.data.baggage.response, so provider-specific
|
|
155
|
+
// message extractors need this to read upstream error details in production.
|
|
156
|
+
export let getApiErrorResponseWithBody = (error: unknown): ApiErrorResponse | undefined => {
|
|
157
|
+
let response = getApiErrorResponse(error);
|
|
158
|
+
if (response?.data !== undefined) return response;
|
|
159
|
+
if (
|
|
160
|
+
!isApiErrorRecord(error) ||
|
|
161
|
+
error.name !== 'SlateError' ||
|
|
162
|
+
!isApiErrorRecord(error.data)
|
|
163
|
+
) {
|
|
164
|
+
return response;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let baggage = isApiErrorRecord(error.data.baggage) ? error.data.baggage : undefined;
|
|
168
|
+
if (baggage?.response === undefined) return response;
|
|
169
|
+
|
|
170
|
+
return { ...response, data: baggage.response };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export let API_ERROR_MAX_DETAILS = 5;
|
|
174
|
+
export let API_ERROR_MAX_MESSAGE_LENGTH = 1_000;
|
|
175
|
+
|
|
176
|
+
// sanitizeForBaggage replaces values nested past its depth limit (and values cut
|
|
177
|
+
// by its size budget) with this placeholder; it never carries a useful upstream message.
|
|
178
|
+
// Kept as an alias of BAGGAGE_TRUNCATED_VALUE for existing importers.
|
|
179
|
+
export let API_ERROR_TRUNCATED_PLACEHOLDER = BAGGAGE_TRUNCATED_VALUE;
|
|
180
|
+
|
|
181
|
+
// Sanitized-baggage placeholders that stand in for removed values, never messages.
|
|
182
|
+
let BAGGAGE_PLACEHOLDERS = new Set([
|
|
183
|
+
BAGGAGE_TRUNCATED_VALUE,
|
|
184
|
+
BAGGAGE_REDACTED_VALUE,
|
|
185
|
+
BAGGAGE_UNSERIALIZABLE_VALUE
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
// Detects HTML error pages (gateway, proxy, or login pages) so they are not shown
|
|
189
|
+
// as error messages. Requires a doctype, a leading <html>/<head> tag (pages may be
|
|
190
|
+
// cut off by baggage truncation), a closing document tag, or a leading markup tag
|
|
191
|
+
// with its closing tag, so plain text such as "<id> is required" or
|
|
192
|
+
// "Invalid <body> parameter" survives.
|
|
193
|
+
export let looksLikeHtmlErrorBody = (value: string) =>
|
|
194
|
+
/^\s*<!doctype\s+html\b/i.test(value) ||
|
|
195
|
+
/^\s*<(html|head)\b[^>]*>/i.test(value) ||
|
|
196
|
+
/<\/(html|head|body)\s*>/i.test(value) ||
|
|
197
|
+
/^\s*<(title|h[1-6]|p|div|pre|center)\b[^>]*>[\s\S]*<\/\1\s*>/i.test(value);
|
|
198
|
+
|
|
199
|
+
export let truncateApiErrorMessage = (
|
|
200
|
+
value: string,
|
|
201
|
+
maxLength: number = API_ERROR_MAX_MESSAGE_LENGTH
|
|
202
|
+
) => (value.length > maxLength ? `${value.slice(0, maxLength)}...` : value);
|
|
203
|
+
|
|
204
|
+
export type ApiErrorTextOptions = {
|
|
205
|
+
// Accept numeric values (for example numeric provider error codes). Defaults to true.
|
|
206
|
+
includeNumbers?: boolean;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
// Returns a trimmed, displayable error detail, or undefined for non-text values,
|
|
210
|
+
// empty strings, sanitized-baggage placeholders, and HTML error pages.
|
|
211
|
+
export let readApiErrorText = (value: unknown, options: ApiErrorTextOptions = {}) => {
|
|
212
|
+
if (
|
|
213
|
+
typeof value !== 'string' &&
|
|
214
|
+
(options.includeNumbers === false || typeof value !== 'number')
|
|
215
|
+
) {
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let text = String(value).trim();
|
|
220
|
+
if (!text || BAGGAGE_PLACEHOLDERS.has(text) || looksLikeHtmlErrorBody(text)) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return text;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export type BoundedApiErrorDetailOptions = ApiErrorTextOptions & {
|
|
228
|
+
// Stop collecting once this many details are present. Defaults to 5.
|
|
229
|
+
maxDetails?: number;
|
|
230
|
+
// Prefix the detail as `${field}: ${text}` (for example a field validation error).
|
|
231
|
+
field?: string;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Appends a unique, displayable detail while keeping the list bounded.
|
|
235
|
+
export let pushBoundedApiErrorDetail = (
|
|
236
|
+
details: string[],
|
|
237
|
+
value: unknown,
|
|
238
|
+
options: BoundedApiErrorDetailOptions = {}
|
|
239
|
+
) => {
|
|
240
|
+
if (details.length >= (options.maxDetails ?? API_ERROR_MAX_DETAILS)) return;
|
|
241
|
+
|
|
242
|
+
let text = readApiErrorText(value, options);
|
|
243
|
+
if (!text) return;
|
|
244
|
+
|
|
245
|
+
let detail = options.field ? `${options.field}: ${text}` : text;
|
|
246
|
+
if (!details.includes(detail)) {
|
|
247
|
+
details.push(detail);
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
export type DecodeApiErrorBodyOptions = {
|
|
252
|
+
// Only decode this many leading bytes of binary bodies.
|
|
253
|
+
maxBytes?: number;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
// Text and binary (arraybuffer) responses carry provider JSON error bodies as
|
|
257
|
+
// strings or bytes. Decode them so the JSON structure is available to message
|
|
258
|
+
// extractors and survives sanitizeForBaggage. Returns parsed JSON when the text
|
|
259
|
+
// is a JSON object/array, the trimmed text otherwise, and other values unchanged.
|
|
260
|
+
export let decodeApiErrorBody = (
|
|
261
|
+
data: unknown,
|
|
262
|
+
options: DecodeApiErrorBodyOptions = {}
|
|
263
|
+
): unknown => {
|
|
264
|
+
let text: string;
|
|
265
|
+
|
|
266
|
+
if (typeof data === 'string') {
|
|
267
|
+
text = data;
|
|
268
|
+
} else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) {
|
|
269
|
+
let bytes: Uint8Array =
|
|
270
|
+
data instanceof ArrayBuffer
|
|
271
|
+
? new Uint8Array(data)
|
|
272
|
+
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
273
|
+
if (options.maxBytes !== undefined && bytes.byteLength > options.maxBytes) {
|
|
274
|
+
bytes = bytes.subarray(0, options.maxBytes);
|
|
275
|
+
}
|
|
276
|
+
text = new TextDecoder('utf-8').decode(bytes);
|
|
277
|
+
} else {
|
|
278
|
+
return data;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
let trimmed = text.trim();
|
|
282
|
+
if (/^[{[]/.test(trimmed)) {
|
|
283
|
+
try {
|
|
284
|
+
return JSON.parse(trimmed);
|
|
285
|
+
} catch {
|
|
286
|
+
// Not JSON after all; fall back to the trimmed text.
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return trimmed;
|
|
291
|
+
};
|
|
292
|
+
|
|
146
293
|
export let statusLabelForApiError = (status?: ApiErrorStatus, statusText?: string) =>
|
|
147
294
|
status !== undefined ? `HTTP ${status}${statusText ? ` ${statusText}` : ''}: ` : '';
|
|
148
295
|
|
|
@@ -200,6 +347,7 @@ let helpers: ApiErrorBuildHelpers = {
|
|
|
200
347
|
collectDetails: collectApiErrorDetails,
|
|
201
348
|
extractMessage: extractApiErrorMessage,
|
|
202
349
|
getResponse: getApiErrorResponse,
|
|
350
|
+
getResponseWithBody: getApiErrorResponseWithBody,
|
|
203
351
|
getStatus: getApiErrorStatus,
|
|
204
352
|
isRecord: isApiErrorRecord,
|
|
205
353
|
statusLabelFor: statusLabelForApiError
|
package/src/error/axios.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import type { AxiosError } from 'axios';
|
|
2
2
|
import { isAxiosError } from 'axios';
|
|
3
|
+
import type { AuthConfigSecretRedactor } from '../auth/redact';
|
|
4
|
+
import { sanitizeUrl } from '../axios/trace';
|
|
3
5
|
import { getCurrentContext } from '../context/hook';
|
|
4
6
|
import type { SlateAxiosErrorOptions, SlateErrorResponse } from './types';
|
|
5
7
|
import {
|
|
8
|
+
BAGGAGE_MAX_STRING_LENGTH,
|
|
9
|
+
BAGGAGE_RESPONSE_MAX_DEPTH,
|
|
10
|
+
getCurrentAuthConfigRedactor,
|
|
6
11
|
getDefaultMessage,
|
|
7
12
|
isRecord,
|
|
8
13
|
mergeRecords,
|
|
9
14
|
normalizeKind,
|
|
10
15
|
normalizeRetryable,
|
|
11
16
|
normalizeSlateErrorInput,
|
|
17
|
+
sanitizeErrorText,
|
|
12
18
|
sanitizeForBaggage
|
|
13
19
|
} from './utils';
|
|
14
20
|
|
|
@@ -98,18 +104,29 @@ let getHeaderValue = (headers: unknown, key: string): string | undefined => {
|
|
|
98
104
|
return undefined;
|
|
99
105
|
};
|
|
100
106
|
|
|
101
|
-
|
|
107
|
+
// Credentials embedded in URL paths (Telegram `/bot<token>/`, Alchemy `/v2/<apiKey>`)
|
|
108
|
+
// are only recognizable with the auth-config redactor, mirroring HTTP traces.
|
|
109
|
+
let sanitizeRequestUrl = (url: string, redactor?: AuthConfigSecretRedactor) => {
|
|
110
|
+
try {
|
|
111
|
+
return sanitizeUrl(url, redactor);
|
|
112
|
+
} catch {
|
|
113
|
+
return sanitizeUrl(url);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
let buildRequestUrl = (error: AxiosError, redactor?: AuthConfigSecretRedactor) => {
|
|
102
118
|
let baseURL = error.config?.baseURL;
|
|
103
119
|
let url = error.config?.url;
|
|
104
120
|
|
|
105
121
|
if (!url) return undefined;
|
|
106
|
-
if (!baseURL) return url;
|
|
122
|
+
if (!baseURL) return sanitizeRequestUrl(url, redactor);
|
|
107
123
|
|
|
124
|
+
let absoluteUrl = url;
|
|
108
125
|
try {
|
|
109
|
-
|
|
110
|
-
} catch {
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
absoluteUrl = new URL(url, baseURL).toString();
|
|
127
|
+
} catch {}
|
|
128
|
+
|
|
129
|
+
return sanitizeRequestUrl(absoluteUrl, redactor);
|
|
113
130
|
};
|
|
114
131
|
|
|
115
132
|
let getProviderDefaults = (): Partial<SlateErrorResponse> => {
|
|
@@ -158,13 +175,26 @@ let inferAxiosCode = (error: AxiosError, data: unknown) => {
|
|
|
158
175
|
return 'upstream.error';
|
|
159
176
|
};
|
|
160
177
|
|
|
161
|
-
|
|
162
|
-
|
|
178
|
+
// Upstream bodies can echo credentials back (request dumps, debug fields), so the
|
|
179
|
+
// inferred message is scrubbed and bounded before it becomes SlateError.message.
|
|
180
|
+
let inferAxiosMessage = (
|
|
181
|
+
error: AxiosError,
|
|
182
|
+
data: unknown,
|
|
183
|
+
code: string,
|
|
184
|
+
redactor?: AuthConfigSecretRedactor
|
|
185
|
+
) => {
|
|
186
|
+
let message =
|
|
163
187
|
getNestedMessage(data) ??
|
|
164
188
|
error.response?.statusText ??
|
|
165
189
|
error.message ??
|
|
166
|
-
getDefaultMessage(code)
|
|
167
|
-
|
|
190
|
+
getDefaultMessage(code);
|
|
191
|
+
|
|
192
|
+
// Scrub before cutting so the cut cannot leave a credential fragment the patterns
|
|
193
|
+
// no longer recognize; sanitizeErrorText bounds its own scan.
|
|
194
|
+
let scrubbed = sanitizeErrorText(message, redactor ?? null);
|
|
195
|
+
return scrubbed.length > BAGGAGE_MAX_STRING_LENGTH
|
|
196
|
+
? `${scrubbed.slice(0, BAGGAGE_MAX_STRING_LENGTH)}...`
|
|
197
|
+
: scrubbed;
|
|
168
198
|
};
|
|
169
199
|
|
|
170
200
|
let inferAxiosRetryable = (error: AxiosError, code: string) => {
|
|
@@ -184,12 +214,14 @@ export let inferSlateErrorFromAxios = (
|
|
|
184
214
|
: undefined;
|
|
185
215
|
|
|
186
216
|
let code = inferAxiosCode(error, responseData);
|
|
217
|
+
let redactor = getCurrentAuthConfigRedactor();
|
|
187
218
|
|
|
188
219
|
return normalizeSlateErrorInput({
|
|
189
220
|
...getProviderDefaults(),
|
|
190
221
|
...options.defaults,
|
|
191
222
|
code: options.defaults?.code ?? code,
|
|
192
|
-
message:
|
|
223
|
+
message:
|
|
224
|
+
options.defaults?.message ?? inferAxiosMessage(error, responseData, code, redactor),
|
|
193
225
|
kind: options.defaults?.kind ?? normalizeKind(options.defaults?.code ?? code),
|
|
194
226
|
retryable:
|
|
195
227
|
options.defaults?.retryable ??
|
|
@@ -210,11 +242,14 @@ export let inferSlateErrorFromAxios = (
|
|
|
210
242
|
getHeaderValue(error.response?.headers, 'x-correlation-id') ??
|
|
211
243
|
options.defaults?.upstream?.requestId,
|
|
212
244
|
method: error.config?.method?.toUpperCase() ?? options.defaults?.upstream?.method,
|
|
213
|
-
url: buildRequestUrl(error) ?? options.defaults?.upstream?.url
|
|
245
|
+
url: buildRequestUrl(error, redactor) ?? options.defaults?.upstream?.url
|
|
214
246
|
},
|
|
215
247
|
baggage: mergeRecords(options.defaults?.baggage, {
|
|
216
248
|
axiosCode: error.code,
|
|
217
|
-
response: sanitizeForBaggage(responseData
|
|
249
|
+
response: sanitizeForBaggage(responseData, {
|
|
250
|
+
maxDepth: BAGGAGE_RESPONSE_MAX_DEPTH,
|
|
251
|
+
redactor: redactor ?? null
|
|
252
|
+
})
|
|
218
253
|
})
|
|
219
254
|
});
|
|
220
255
|
};
|