accessio 1.2.0 → 1.4.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.
Files changed (40) hide show
  1. package/README.md +21 -21
  2. package/cjs/accessio.cjs +68 -84
  3. package/cjs/accessio.cjs.map +1 -1
  4. package/cjs/core/accessioError.cjs +49 -3
  5. package/cjs/core/accessioError.cjs.map +1 -1
  6. package/cjs/core/buildURL.cjs +10 -4
  7. package/cjs/core/buildURL.cjs.map +1 -1
  8. package/cjs/core/fetchAdapter.cjs +134 -111
  9. package/cjs/core/fetchAdapter.cjs.map +1 -1
  10. package/cjs/core/request.cjs +97 -24
  11. package/cjs/core/request.cjs.map +1 -1
  12. package/cjs/core/retry.cjs +25 -0
  13. package/cjs/core/retry.cjs.map +1 -1
  14. package/cjs/helpers/debug.cjs +7 -1
  15. package/cjs/helpers/debug.cjs.map +1 -1
  16. package/cjs/helpers/flattenHeaders.cjs +37 -0
  17. package/cjs/helpers/flattenHeaders.cjs.map +1 -1
  18. package/cjs/helpers/rateLimiter.cjs +11 -22
  19. package/cjs/helpers/rateLimiter.cjs.map +1 -1
  20. package/cjs/helpers/settle.cjs +1 -1
  21. package/cjs/helpers/settle.cjs.map +1 -1
  22. package/cjs/helpers/transformData.cjs +2 -2
  23. package/cjs/helpers/transformData.cjs.map +1 -1
  24. package/cjs/interceptors/interceptorManager.cjs +25 -18
  25. package/cjs/interceptors/interceptorManager.cjs.map +1 -1
  26. package/index.d.ts +89 -21
  27. package/package.json +2 -2
  28. package/src/accessio.ts +104 -98
  29. package/src/core/accessioError.ts +50 -1
  30. package/src/core/buildURL.ts +14 -4
  31. package/src/core/fetchAdapter.ts +166 -130
  32. package/src/core/request.ts +115 -28
  33. package/src/core/retry.ts +19 -1
  34. package/src/helpers/debug.ts +7 -2
  35. package/src/helpers/flattenHeaders.ts +30 -0
  36. package/src/helpers/rateLimiter.ts +11 -24
  37. package/src/helpers/settle.ts +1 -1
  38. package/src/helpers/transformData.ts +2 -1
  39. package/src/interceptors/interceptorManager.ts +26 -19
  40. package/src/types.ts +1 -0
@@ -18,12 +18,20 @@ async function readResponseData(
18
18
  default: {
19
19
  const contentType = fetchResponse.headers.get('content-type') || '';
20
20
  if (contentType.includes('application/json')) {
21
- if (typeof fetchResponse.clone === 'function') {
22
- const text = await fetchResponse.clone().text();
23
- return text ? await fetchResponse.json() : '';
24
- } else {
25
- const text = await fetchResponse.text();
26
- return text ? JSON.parse(text) : '';
21
+ const text = await fetchResponse.text();
22
+ if (!text) return '';
23
+ try {
24
+ return JSON.parse(text);
25
+ } catch (err) {
26
+ throw new AccessioError(
27
+ `Failed to parse JSON response: ${(err as Error).message}. Raw body: ${
28
+ text.length > 500 ? `${text.slice(0, 500)}…` : text
29
+ }`,
30
+ AccessioError.ERR_BAD_RESPONSE,
31
+ config,
32
+ fetchResponse,
33
+ null,
34
+ );
27
35
  }
28
36
  }
29
37
  return await fetchResponse.text();
@@ -31,17 +39,27 @@ async function readResponseData(
31
39
  }
32
40
  }
33
41
 
34
- export default async function fetchAdapter(
35
- config: AccessioRequestConfig,
36
- fullURL: string,
37
- fetchOptions: RequestInit,
38
- requestStartTime: number,
39
- ): Promise<AccessioResponse> {
40
- let abortController: AbortController | null = null;
41
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
42
- let isTimedOut = false;
43
- let onUserAbort: (() => void) | null = null;
42
+ function assertValidURL(fullURL: string, config: AccessioRequestConfig): void {
43
+ if (!fullURL || !/^[a-z][a-z\d+\-.]*:/i.test(fullURL)) return;
44
+ try {
45
+ new URL(fullURL);
46
+ } catch {
47
+ throw new AccessioError(
48
+ `Invalid URL: ${fullURL}`,
49
+ AccessioError.ERR_INVALID_URL,
50
+ config,
51
+ null,
52
+ null,
53
+ );
54
+ }
55
+ }
56
+
57
+ interface AbortWiring {
58
+ isTimedOut: () => boolean;
59
+ cleanup: () => void;
60
+ }
44
61
 
62
+ function setupAbort(config: AccessioRequestConfig, fetchOptions: RequestInit): AbortWiring {
45
63
  if (
46
64
  config.timeout !== undefined &&
47
65
  (typeof config.timeout !== 'number' || isNaN(config.timeout) || config.timeout < 0)
@@ -56,84 +74,141 @@ export default async function fetchAdapter(
56
74
  }
57
75
 
58
76
  const timeoutValue = Number(config.timeout);
59
- if (!isNaN(timeoutValue) && timeoutValue > 0) {
60
- abortController = new AbortController();
61
-
62
- timeoutId = setTimeout(() => {
63
- isTimedOut = true;
64
- abortController!.abort(
65
- new AccessioError(
66
- `timeout of ${timeoutValue}ms exceeded`,
67
- AccessioError.ETIMEDOUT,
68
- config,
69
- null,
70
- null,
71
- ),
72
- );
73
- }, timeoutValue);
77
+ const hasTimeout = !isNaN(timeoutValue) && timeoutValue > 0;
78
+
79
+ if (!hasTimeout) {
80
+ if (config.signal) fetchOptions.signal = config.signal;
81
+ return { isTimedOut: () => false, cleanup: () => {} };
82
+ }
74
83
 
75
- if (config.signal) {
76
- if (typeof AbortSignal.any === 'function') {
77
- fetchOptions.signal = AbortSignal.any([config.signal, abortController.signal]);
84
+ let timedOut = false;
85
+ const abortController = new AbortController();
86
+ const timeoutId = setTimeout(() => {
87
+ timedOut = true;
88
+ abortController.abort(
89
+ new AccessioError(
90
+ `timeout of ${timeoutValue}ms exceeded`,
91
+ AccessioError.ETIMEDOUT,
92
+ config,
93
+ null,
94
+ null,
95
+ ),
96
+ );
97
+ }, timeoutValue);
98
+
99
+ let onUserAbort: (() => void) | null = null;
100
+
101
+ if (config.signal) {
102
+ if (typeof AbortSignal.any === 'function') {
103
+ fetchOptions.signal = AbortSignal.any([config.signal, abortController.signal]);
104
+ } else {
105
+ if (config.signal.aborted) {
106
+ abortController.abort(config.signal.reason);
78
107
  } else {
79
- if (config.signal.aborted) {
80
- abortController.abort(config.signal.reason);
81
- } else {
82
- onUserAbort = () => {
83
- if (!isTimedOut && abortController) {
84
- abortController.abort(config.signal!.reason);
85
- }
86
- };
87
- config.signal.addEventListener('abort', onUserAbort, {
88
- once: true,
89
- });
90
- }
91
- fetchOptions.signal = abortController.signal;
108
+ onUserAbort = () => {
109
+ if (!timedOut) abortController.abort(config.signal!.reason);
110
+ };
111
+ config.signal.addEventListener('abort', onUserAbort, { once: true });
92
112
  }
93
- } else {
94
113
  fetchOptions.signal = abortController.signal;
95
114
  }
96
- } else if (config.signal) {
97
- fetchOptions.signal = config.signal;
115
+ } else {
116
+ fetchOptions.signal = abortController.signal;
98
117
  }
99
118
 
100
- try {
101
- const fetchImpl = config.fetch || fetch;
102
- let fetchResponse = await fetchImpl(fullURL, fetchOptions);
103
-
104
- if (config.onDownloadProgress && fetchResponse.body && config.responseType !== 'stream') {
105
- const contentLength = fetchResponse.headers.get('content-length');
106
- const total = contentLength ? parseInt(contentLength, 10) : 0;
107
- let loaded = 0;
108
-
109
- const reader = fetchResponse.body.getReader();
110
- const stream = new ReadableStream({
111
- async start(controller) {
112
- try {
113
- while (true) {
114
- const { done, value } = await reader.read();
115
- if (done) {
116
- controller.close();
117
- break;
118
- }
119
- loaded += value.byteLength;
120
- config.onDownloadProgress!({ loaded, total });
121
- controller.enqueue(value);
122
- }
123
- } catch (e) {
124
- controller.error(e);
119
+ return {
120
+ isTimedOut: () => timedOut,
121
+ cleanup: () => {
122
+ clearTimeout(timeoutId);
123
+ if (onUserAbort && config.signal) {
124
+ config.signal.removeEventListener('abort', onUserAbort);
125
+ }
126
+ },
127
+ };
128
+ }
129
+
130
+ function wrapDownloadProgress(fetchResponse: Response, config: AccessioRequestConfig): Response {
131
+ if (!config.onDownloadProgress || !fetchResponse.body || config.responseType === 'stream') {
132
+ return fetchResponse;
133
+ }
134
+
135
+ const contentLength = fetchResponse.headers.get('content-length');
136
+ const total = contentLength ? parseInt(contentLength, 10) : 0;
137
+ let loaded = 0;
138
+
139
+ const reader = fetchResponse.body.getReader();
140
+ const stream = new ReadableStream({
141
+ async start(controller) {
142
+ try {
143
+ while (true) {
144
+ const { done, value } = await reader.read();
145
+ if (done) {
146
+ controller.close();
147
+ break;
125
148
  }
126
- },
127
- });
128
-
129
- fetchResponse = new Response(stream, {
130
- headers: fetchResponse.headers,
131
- status: fetchResponse.status,
132
- statusText: fetchResponse.statusText,
133
- });
134
- }
149
+ loaded += value.byteLength;
150
+ config.onDownloadProgress!({ loaded, total });
151
+ controller.enqueue(value);
152
+ }
153
+ } catch (e) {
154
+ controller.error(e);
155
+ }
156
+ },
157
+ });
135
158
 
136
- let responseData: unknown;
159
+ return new Response(stream, {
160
+ headers: fetchResponse.headers,
161
+ status: fetchResponse.status,
162
+ statusText: fetchResponse.statusText,
163
+ });
164
+ }
165
+
166
+ function classifyFetchError(
167
+ error: unknown,
168
+ config: AccessioRequestConfig,
169
+ isTimedOut: boolean,
170
+ ): AccessioError {
171
+ if (error instanceof AccessioError) return error;
172
+
173
+ if (isTimedOut) {
174
+ return new AccessioError(
175
+ `timeout of ${config.timeout}ms exceeded`,
176
+ AccessioError.ETIMEDOUT,
177
+ config,
178
+ null,
179
+ null,
180
+ );
181
+ }
182
+
183
+ const isAbort =
184
+ (error instanceof Error && error.name === 'AbortError') || !!config.signal?.aborted;
185
+ if (isAbort) {
186
+ return new AccessioError('Request aborted', AccessioError.ERR_CANCELED, config, null, null);
187
+ }
188
+
189
+ return AccessioError.from(
190
+ error instanceof Error ? error : new Error(String(error)),
191
+ AccessioError.ERR_NETWORK,
192
+ config,
193
+ null,
194
+ null,
195
+ );
196
+ }
197
+
198
+ export default async function fetchAdapter(
199
+ config: AccessioRequestConfig,
200
+ fullURL: string,
201
+ fetchOptions: RequestInit,
202
+ requestStartTime: number,
203
+ ): Promise<AccessioResponse> {
204
+ assertValidURL(fullURL, config);
205
+
206
+ const abort = setupAbort(config, fetchOptions);
207
+
208
+ try {
209
+ const fetchImpl = config.fetch || fetch;
210
+ const rawResponse = await fetchImpl(fullURL, fetchOptions);
211
+ const fetchResponse = wrapDownloadProgress(rawResponse, config);
137
212
 
138
213
  const contentLength = fetchResponse.headers.get('content-length');
139
214
  if (
@@ -150,6 +225,7 @@ export default async function fetchAdapter(
150
225
  );
151
226
  }
152
227
 
228
+ let responseData: unknown;
153
229
  try {
154
230
  responseData = await readResponseData(fetchResponse, config);
155
231
  if (config.schema) {
@@ -160,6 +236,7 @@ export default async function fetchAdapter(
160
236
  }
161
237
  }
162
238
  } catch (readError) {
239
+ if (readError instanceof AccessioError) throw readError;
163
240
  throw AccessioError.from(
164
241
  readError as Error,
165
242
  AccessioError.ERR_BAD_RESPONSE,
@@ -169,59 +246,18 @@ export default async function fetchAdapter(
169
246
  );
170
247
  }
171
248
 
172
- const responseHeaders = parseHeaders(fetchResponse.headers);
173
-
174
249
  return {
175
250
  data: responseData,
176
251
  status: fetchResponse.status,
177
252
  statusText: fetchResponse.statusText,
178
- headers: responseHeaders,
179
- config: config,
253
+ headers: parseHeaders(fetchResponse.headers),
254
+ config,
180
255
  request: fetchResponse,
181
256
  duration: Date.now() - requestStartTime,
182
257
  };
183
258
  } catch (error) {
184
- if (error instanceof AccessioError) {
185
- throw error;
186
- }
187
-
188
- if (error instanceof Error && error.name === 'AbortError') {
189
- if (isTimedOut) {
190
- throw new AccessioError(
191
- `timeout of ${config.timeout}ms exceeded`,
192
- AccessioError.ETIMEDOUT,
193
- config,
194
- null,
195
- null,
196
- );
197
- }
198
- throw new AccessioError('Request aborted', AccessioError.ERR_CANCELED, config, null, null);
199
- }
200
-
201
- if (
202
- error instanceof TypeError &&
203
- (error.message.toLowerCase().includes('url') || error.message.toLowerCase().includes('fetch'))
204
- ) {
205
- throw new AccessioError(
206
- `Invalid URL: ${fullURL}`,
207
- AccessioError.ERR_INVALID_URL,
208
- config,
209
- null,
210
- null,
211
- );
212
- }
213
-
214
- throw AccessioError.from(
215
- error instanceof Error ? error : new Error(String(error)),
216
- AccessioError.ERR_NETWORK,
217
- config,
218
- null,
219
- null,
220
- );
259
+ throw classifyFetchError(error, config, abort.isTimedOut());
221
260
  } finally {
222
- if (timeoutId) clearTimeout(timeoutId);
223
- if (config.signal && onUserAbort) {
224
- config.signal.removeEventListener('abort', onUserAbort);
225
- }
261
+ abort.cleanup();
226
262
  }
227
263
  }
@@ -1,5 +1,6 @@
1
1
  import buildURL from './buildURL';
2
- import AccessioError from './accessioError';
2
+ import AccessioError, { redactConfig } from './accessioError';
3
+ import { ERR_BAD_OPTION } from '../constants/errorCodes';
3
4
  import transformData from '../helpers/transformData';
4
5
  import settle from '../helpers/settle';
5
6
  import { flattenHeaders, removeContentType, buildFetchHeaders } from '../helpers/flattenHeaders';
@@ -9,6 +10,31 @@ import { defaultMemoryCache } from '../helpers/memoryCache';
9
10
  import type { AccessioRequestConfig, AccessioResponse, TransformFunction } from '../types';
10
11
 
11
12
  type HeadersConfig = Record<string, Record<string, string | string[]>>;
13
+ type FlatHeaders = Record<string, string | string[]>;
14
+
15
+ function lookupHeader(headers: FlatHeaders, name: string): string {
16
+ const target = name.toLowerCase();
17
+ for (const k of Object.keys(headers)) {
18
+ if (k.toLowerCase() === target) {
19
+ const v = headers[k];
20
+ return Array.isArray(v) ? v.join(',') : (v ?? '');
21
+ }
22
+ }
23
+ return '';
24
+ }
25
+
26
+ function buildCacheKey(
27
+ config: AccessioRequestConfig,
28
+ fullURL: string,
29
+ flatHeaders: FlatHeaders,
30
+ ): string {
31
+ const method = (config.method || 'GET').toUpperCase();
32
+ const auth = lookupHeader(flatHeaders, 'authorization');
33
+ const accept = lookupHeader(flatHeaders, 'accept');
34
+ const withCreds = config.withCredentials ? '1' : '0';
35
+ const respType = config.responseType || 'json';
36
+ return `${method}:${fullURL}|a=${auth}|x=${accept}|c=${withCreds}|t=${respType}`;
37
+ }
12
38
 
13
39
  function buildTransformArray(
14
40
  transform: TransformFunction | TransformFunction[] | undefined,
@@ -18,7 +44,45 @@ function buildTransformArray(
18
44
  return [transform];
19
45
  }
20
46
 
47
+ const DEFAULT_ALLOWED_PROTOCOLS = ['http:', 'https:'];
48
+
49
+ function assertAllowedProtocol(fullURL: string, config: AccessioRequestConfig): void {
50
+ if (config.allowedProtocols === null) return;
51
+ const allowed = config.allowedProtocols ?? DEFAULT_ALLOWED_PROTOCOLS;
52
+
53
+ let scheme: string | null = null;
54
+ const match = /^([a-z][a-z\d+\-.]*):/i.exec(fullURL);
55
+ if (match) scheme = `${match[1].toLowerCase()}:`;
56
+ if (!scheme) return;
57
+
58
+ if (!allowed.includes(scheme)) {
59
+ throw new AccessioError(
60
+ `URL protocol "${scheme}" is not allowed. Allowed: ${allowed.join(', ')}. ` +
61
+ 'Set config.allowedProtocols to extend, or null to disable the check.',
62
+ ERR_BAD_OPTION,
63
+ config,
64
+ null,
65
+ null,
66
+ );
67
+ }
68
+ }
69
+
21
70
  const activeRequests = new Map<string, Promise<AccessioResponse>>();
71
+ const MAX_ACTIVE_REQUESTS = 1024;
72
+
73
+ export function __activeRequestsSize(): number {
74
+ return activeRequests.size;
75
+ }
76
+
77
+ function trackActiveRequest(key: string, promise: Promise<AccessioResponse>): void {
78
+ activeRequests.set(key, promise);
79
+ // Evict the oldest entry if we've grown past the cap. Map preserves insertion order.
80
+ while (activeRequests.size > MAX_ACTIVE_REQUESTS) {
81
+ const oldest = activeRequests.keys().next().value;
82
+ if (oldest === undefined || oldest === key) break;
83
+ activeRequests.delete(oldest);
84
+ }
85
+ }
22
86
 
23
87
  export default async function dispatchRequest(
24
88
  config: AccessioRequestConfig,
@@ -32,32 +96,42 @@ export default async function dispatchRequest(
32
96
  config.paramsSerializer,
33
97
  );
34
98
 
99
+ assertAllowedProtocol(fullURL, config);
100
+
35
101
  if (config.hooks?.onBeforeRequest) {
36
102
  await config.hooks.onBeforeRequest(config);
37
103
  }
38
104
 
105
+ const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
106
+ setBasicAuth(config, flatHeaders);
107
+
39
108
  const isGet = (config.method || 'GET').toUpperCase() === 'GET';
40
- const cacheKey = isGet ? `GET:${fullURL}` : '';
109
+ const cacheKey = isGet ? buildCacheKey(config, fullURL, flatHeaders) : '';
41
110
 
42
111
  if (isGet && config.cache) {
43
112
  const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
44
113
  const cached = await cacheProvider.get(cacheKey);
45
114
  if (cached) {
115
+ const cachedView: AccessioResponse = {
116
+ ...cached,
117
+ config: redactConfig(config) as typeof cached.config,
118
+ };
46
119
  if (config.hooks?.onRequestResponse) {
47
- await config.hooks.onRequestResponse(cached);
120
+ await config.hooks.onRequestResponse(cachedView);
48
121
  }
49
- return cached;
122
+ return cachedView;
50
123
  }
51
124
  }
52
125
 
53
126
  if (isGet && config.dedupe) {
54
- if (activeRequests.has(cacheKey)) {
55
- return activeRequests.get(cacheKey)!;
127
+ const inflight = activeRequests.get(cacheKey);
128
+ if (inflight) {
129
+ const shared = await inflight;
130
+ return finalizeResponse(shared, config);
56
131
  }
57
132
  }
58
133
 
59
- const performRequest = async () => {
60
- const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
134
+ const performRequest = async (): Promise<AccessioResponse> => {
61
135
  const requestTransforms = buildTransformArray(config.transformRequest);
62
136
  const requestData = await transformData(requestTransforms, config.data, flatHeaders, config);
63
137
 
@@ -69,8 +143,6 @@ export default async function dispatchRequest(
69
143
  removeContentType(flatHeaders);
70
144
  }
71
145
 
72
- setBasicAuth(config, flatHeaders);
73
-
74
146
  const fetchOptions: RequestInit = {
75
147
  method: (config.method || 'GET').toUpperCase(),
76
148
  headers: buildFetchHeaders(flatHeaders),
@@ -97,50 +169,55 @@ export default async function dispatchRequest(
97
169
  }
98
170
 
99
171
  const requestStartTime = Date.now();
100
-
101
172
  const response = await fetchAdapter(config, fullURL, fetchOptions, requestStartTime);
102
173
 
103
174
  const responseTransforms = buildTransformArray(config.transformResponse);
104
-
105
175
  response.data = await transformData(
106
176
  responseTransforms,
107
177
  response.data,
108
178
  response.headers,
109
179
  config,
180
+ 'response',
110
181
  );
111
182
 
112
- return new Promise<AccessioResponse>((resolve, reject) => {
113
- settle(
114
- resolve as (value: AccessioResponse) => void,
115
- reject as (reason: AccessioError) => void,
116
- response,
117
- config,
118
- );
119
- });
183
+ return response;
120
184
  };
121
185
 
122
186
  const promise = performRequest();
123
187
 
124
188
  if (isGet && config.dedupe) {
125
- activeRequests.set(cacheKey, promise);
126
- promise.finally(() => {
127
- activeRequests.delete(cacheKey);
128
- });
189
+ trackActiveRequest(cacheKey, promise);
190
+ const cleanup = () => {
191
+ if (activeRequests.get(cacheKey) === promise) {
192
+ activeRequests.delete(cacheKey);
193
+ }
194
+ };
195
+ promise.then(cleanup, cleanup);
129
196
  }
130
197
 
131
198
  try {
132
- const response = await promise;
199
+ const shared = await promise;
200
+ const response = finalizeResponse(shared, config);
133
201
 
134
202
  if (isGet && config.cache) {
135
203
  const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
136
- await cacheProvider.set(cacheKey, response, config.cacheTTL);
204
+ await cacheProvider.set(cacheKey, shared, config.cacheTTL);
137
205
  }
138
206
 
207
+ const settled = await new Promise<AccessioResponse>((resolve, reject) => {
208
+ settle(
209
+ resolve as (value: AccessioResponse) => void,
210
+ reject as (reason: AccessioError) => void,
211
+ response,
212
+ config,
213
+ );
214
+ });
215
+
139
216
  if (config.hooks?.onRequestResponse) {
140
- await config.hooks.onRequestResponse(response);
217
+ await config.hooks.onRequestResponse(settled);
141
218
  }
142
219
 
143
- return response;
220
+ return settled;
144
221
  } catch (error) {
145
222
  if (config.hooks?.onRequestError && error instanceof AccessioError) {
146
223
  await config.hooks.onRequestError(error);
@@ -148,3 +225,13 @@ export default async function dispatchRequest(
148
225
  throw error;
149
226
  }
150
227
  }
228
+
229
+ function finalizeResponse(
230
+ shared: AccessioResponse,
231
+ config: AccessioRequestConfig,
232
+ ): AccessioResponse {
233
+ return {
234
+ ...shared,
235
+ config: redactConfig(config) as typeof shared.config,
236
+ };
237
+ }
package/src/core/retry.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { ERR_CANCELED, ERR_NETWORK } from '../constants/errorCodes';
1
+ import { ERR_BAD_OPTION, ERR_CANCELED, ERR_NETWORK } from '../constants/errorCodes';
2
+ import AccessioErrorClass from './accessioError';
2
3
  import type {
3
4
  AccessioRequestConfig,
4
5
  AccessioError,
@@ -6,6 +7,12 @@ import type {
6
7
  OnRetryFunction,
7
8
  } from '../types';
8
9
 
10
+ function isUnretriableBody(data: unknown): boolean {
11
+ if (data == null) return false;
12
+ if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) return true;
13
+ return false;
14
+ }
15
+
9
16
  function defaultRetryCondition(error: any): boolean {
10
17
  if (error.code === ERR_CANCELED) {
11
18
  return false;
@@ -89,6 +96,17 @@ async function retryRequest(
89
96
  throw error;
90
97
  }
91
98
 
99
+ if (isUnretriableBody(config.data)) {
100
+ throw new AccessioErrorClass(
101
+ 'Request body is a ReadableStream and cannot be retried after consumption. ' +
102
+ 'Buffer the stream upstream or set retry: 0 for this call.',
103
+ ERR_BAD_OPTION,
104
+ config,
105
+ null,
106
+ (error as AccessioError).response ?? null,
107
+ );
108
+ }
109
+
92
110
  let delay = calculateDelay(attempt, retryDelay);
93
111
 
94
112
  if (config.retryOn429 && (error as any).response?.status === 429) {
@@ -1,5 +1,5 @@
1
1
  import type { AccessioRequestConfig, AccessioResponse } from '../types';
2
- import AccessioError from '../core/accessioError';
2
+ import AccessioError, { redactBody } from '../core/accessioError';
3
3
 
4
4
  function formatBytes(bytes: number): string {
5
5
  if (bytes === 0) return '0 B';
@@ -35,7 +35,12 @@ export function logRequest(config: AccessioRequestConfig, fullUrl: string): void
35
35
  }
36
36
 
37
37
  if (config.data && typeof config.data === 'object') {
38
- const preview = JSON.stringify(config.data);
38
+ let preview: string;
39
+ try {
40
+ preview = JSON.stringify(redactBody(config.data));
41
+ } catch {
42
+ preview = '[unserializable body]';
43
+ }
39
44
  const truncated = preview.length > 200 ? `${preview.substring(0, 200)}...` : preview;
40
45
  parts.push(` Body: ${truncated}`);
41
46
  }
@@ -1,3 +1,32 @@
1
+ import AccessioError from '../core/accessioError';
2
+ import { ERR_BAD_OPTION } from '../constants/errorCodes';
3
+
4
+ const HEADER_FORBIDDEN_CHAR = /[\r\n\0]/;
5
+
6
+ function assertSafeHeader(name: string, value: string | string[]): void {
7
+ if (typeof name !== 'string' || HEADER_FORBIDDEN_CHAR.test(name)) {
8
+ throw new AccessioError(
9
+ `Invalid header name "${String(name)}": CR, LF and NUL are not allowed`,
10
+ ERR_BAD_OPTION,
11
+ null,
12
+ null,
13
+ null,
14
+ );
15
+ }
16
+ const values = Array.isArray(value) ? value : [value];
17
+ for (const v of values) {
18
+ if (typeof v === 'string' && HEADER_FORBIDDEN_CHAR.test(v)) {
19
+ throw new AccessioError(
20
+ `Invalid value for header "${name}": CR, LF and NUL are not allowed`,
21
+ ERR_BAD_OPTION,
22
+ null,
23
+ null,
24
+ null,
25
+ );
26
+ }
27
+ }
28
+ }
29
+
1
30
  const METHOD_KEYS = new Set<string>([
2
31
  'common',
3
32
  'delete',
@@ -47,6 +76,7 @@ export function removeContentType(headers: Record<string, string | string[]>): v
47
76
  export function buildFetchHeaders(headers: Record<string, string | string[]>): Headers {
48
77
  const fetchHeaders = new Headers();
49
78
  for (const [key, value] of Object.entries(headers)) {
79
+ assertSafeHeader(key, value);
50
80
  if (Array.isArray(value)) {
51
81
  for (const v of value) {
52
82
  fetchHeaders.append(key, v);