@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/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
@@ -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
- let buildRequestUrl = (error: AxiosError) => {
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
- return new URL(url, baseURL).toString();
110
- } catch {
111
- return url;
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
- let inferAxiosMessage = (error: AxiosError, data: unknown, code: string) => {
162
- return (
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: options.defaults?.message ?? inferAxiosMessage(error, responseData, code),
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
  };