accessio 1.3.0 → 1.5.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 (39) hide show
  1. package/cjs/accessio.cjs +98 -97
  2. package/cjs/accessio.cjs.map +1 -1
  3. package/cjs/core/accessioError.cjs +51 -1
  4. package/cjs/core/accessioError.cjs.map +1 -1
  5. package/cjs/core/buildURL.cjs +10 -4
  6. package/cjs/core/buildURL.cjs.map +1 -1
  7. package/cjs/core/fetchAdapter.cjs +125 -105
  8. package/cjs/core/fetchAdapter.cjs.map +1 -1
  9. package/cjs/core/request.cjs +73 -23
  10. package/cjs/core/request.cjs.map +1 -1
  11. package/cjs/core/retry.cjs +8 -5
  12. package/cjs/core/retry.cjs.map +1 -1
  13. package/cjs/helpers/debug.cjs +7 -1
  14. package/cjs/helpers/debug.cjs.map +1 -1
  15. package/cjs/helpers/flattenHeaders.cjs +4 -1
  16. package/cjs/helpers/flattenHeaders.cjs.map +1 -1
  17. package/cjs/helpers/rateLimiter.cjs +31 -3
  18. package/cjs/helpers/rateLimiter.cjs.map +1 -1
  19. package/cjs/helpers/settle.cjs +1 -1
  20. package/cjs/helpers/settle.cjs.map +1 -1
  21. package/cjs/helpers/toFormData.cjs +1 -1
  22. package/cjs/helpers/toFormData.cjs.map +1 -1
  23. package/cjs/interceptors/interceptorManager.cjs +25 -18
  24. package/cjs/interceptors/interceptorManager.cjs.map +1 -1
  25. package/index.d.ts +82 -21
  26. package/package.json +1 -1
  27. package/src/accessio.ts +148 -113
  28. package/src/core/accessioError.ts +57 -1
  29. package/src/core/buildURL.ts +14 -4
  30. package/src/core/fetchAdapter.ts +155 -125
  31. package/src/core/request.ts +85 -27
  32. package/src/core/retry.ts +8 -5
  33. package/src/helpers/debug.ts +7 -2
  34. package/src/helpers/flattenHeaders.ts +4 -1
  35. package/src/helpers/rateLimiter.ts +35 -3
  36. package/src/helpers/settle.ts +1 -1
  37. package/src/helpers/toFormData.ts +5 -1
  38. package/src/interceptors/interceptorManager.ts +26 -19
  39. package/src/types.ts +2 -1
@@ -25,7 +25,7 @@ async function readResponseData(
25
25
  } catch (err) {
26
26
  throw new AccessioError(
27
27
  `Failed to parse JSON response: ${(err as Error).message}. Raw body: ${
28
- text.length > 500 ? text.slice(0, 500) + '…' : text
28
+ text.length > 500 ? `${text.slice(0, 500)}…` : text
29
29
  }`,
30
30
  AccessioError.ERR_BAD_RESPONSE,
31
31
  config,
@@ -39,17 +39,27 @@ async function readResponseData(
39
39
  }
40
40
  }
41
41
 
42
- export default async function fetchAdapter(
43
- config: AccessioRequestConfig,
44
- fullURL: string,
45
- fetchOptions: RequestInit,
46
- requestStartTime: number,
47
- ): Promise<AccessioResponse> {
48
- let abortController: AbortController | null = null;
49
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
50
- let isTimedOut = false;
51
- 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
+ }
52
56
 
57
+ interface AbortWiring {
58
+ isTimedOut: () => boolean;
59
+ cleanup: () => void;
60
+ }
61
+
62
+ function setupAbort(config: AccessioRequestConfig, fetchOptions: RequestInit): AbortWiring {
53
63
  if (
54
64
  config.timeout !== undefined &&
55
65
  (typeof config.timeout !== 'number' || isNaN(config.timeout) || config.timeout < 0)
@@ -64,84 +74,144 @@ export default async function fetchAdapter(
64
74
  }
65
75
 
66
76
  const timeoutValue = Number(config.timeout);
67
- if (!isNaN(timeoutValue) && timeoutValue > 0) {
68
- abortController = new AbortController();
69
-
70
- timeoutId = setTimeout(() => {
71
- isTimedOut = true;
72
- abortController!.abort(
73
- new AccessioError(
74
- `timeout of ${timeoutValue}ms exceeded`,
75
- AccessioError.ETIMEDOUT,
76
- config,
77
- null,
78
- null,
79
- ),
80
- );
81
- }, timeoutValue);
77
+ const hasTimeout = !isNaN(timeoutValue) && timeoutValue > 0;
82
78
 
83
- if (config.signal) {
84
- if (typeof AbortSignal.any === 'function') {
85
- fetchOptions.signal = AbortSignal.any([config.signal, abortController.signal]);
79
+ if (!hasTimeout) {
80
+ if (config.signal) fetchOptions.signal = config.signal;
81
+ return { isTimedOut: () => false, cleanup: () => {} };
82
+ }
83
+
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);
86
107
  } else {
87
- if (config.signal.aborted) {
88
- abortController.abort(config.signal.reason);
89
- } else {
90
- onUserAbort = () => {
91
- if (!isTimedOut && abortController) {
92
- abortController.abort(config.signal!.reason);
93
- }
94
- };
95
- config.signal.addEventListener('abort', onUserAbort, {
96
- once: true,
97
- });
98
- }
99
- fetchOptions.signal = abortController.signal;
108
+ onUserAbort = () => {
109
+ if (!timedOut) abortController.abort(config.signal!.reason);
110
+ };
111
+ config.signal.addEventListener('abort', onUserAbort, { once: true });
100
112
  }
101
- } else {
102
113
  fetchOptions.signal = abortController.signal;
103
114
  }
104
- } else if (config.signal) {
105
- fetchOptions.signal = config.signal;
115
+ } else {
116
+ fetchOptions.signal = abortController.signal;
106
117
  }
107
118
 
108
- try {
109
- const fetchImpl = config.fetch || fetch;
110
- let fetchResponse = await fetchImpl(fullURL, fetchOptions);
111
-
112
- if (config.onDownloadProgress && fetchResponse.body && config.responseType !== 'stream') {
113
- const contentLength = fetchResponse.headers.get('content-length');
114
- const total = contentLength ? parseInt(contentLength, 10) : 0;
115
- let loaded = 0;
116
-
117
- const reader = fetchResponse.body.getReader();
118
- const stream = new ReadableStream({
119
- async start(controller) {
120
- try {
121
- while (true) {
122
- const { done, value } = await reader.read();
123
- if (done) {
124
- controller.close();
125
- break;
126
- }
127
- loaded += value.byteLength;
128
- config.onDownloadProgress!({ loaded, total });
129
- controller.enqueue(value);
130
- }
131
- } catch (e) {
132
- 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;
133
148
  }
134
- },
135
- });
136
-
137
- fetchResponse = new Response(stream, {
138
- headers: fetchResponse.headers,
139
- status: fetchResponse.status,
140
- statusText: fetchResponse.statusText,
141
- });
142
- }
149
+ loaded += value.byteLength;
150
+ config.onDownloadProgress!({ loaded, total });
151
+ controller.enqueue(value);
152
+ }
153
+ } catch (e) {
154
+ controller.error(e);
155
+ }
156
+ },
157
+ cancel(reason) {
158
+ reader.cancel(reason).catch(() => {});
159
+ },
160
+ });
143
161
 
144
- let responseData: unknown;
162
+ return new Response(stream, {
163
+ headers: fetchResponse.headers,
164
+ status: fetchResponse.status,
165
+ statusText: fetchResponse.statusText,
166
+ });
167
+ }
168
+
169
+ function classifyFetchError(
170
+ error: unknown,
171
+ config: AccessioRequestConfig,
172
+ isTimedOut: boolean,
173
+ ): AccessioError {
174
+ if (error instanceof AccessioError) return error;
175
+
176
+ if (isTimedOut) {
177
+ return new AccessioError(
178
+ `timeout of ${config.timeout}ms exceeded`,
179
+ AccessioError.ETIMEDOUT,
180
+ config,
181
+ null,
182
+ null,
183
+ );
184
+ }
185
+
186
+ const isAbort =
187
+ (error instanceof Error && error.name === 'AbortError') || !!config.signal?.aborted;
188
+ if (isAbort) {
189
+ return new AccessioError('Request aborted', AccessioError.ERR_CANCELED, config, null, null);
190
+ }
191
+
192
+ return AccessioError.from(
193
+ error instanceof Error ? error : new Error(String(error)),
194
+ AccessioError.ERR_NETWORK,
195
+ config,
196
+ null,
197
+ null,
198
+ );
199
+ }
200
+
201
+ export default async function fetchAdapter(
202
+ config: AccessioRequestConfig,
203
+ fullURL: string,
204
+ fetchOptions: RequestInit,
205
+ requestStartTime: number,
206
+ ): Promise<AccessioResponse> {
207
+ assertValidURL(fullURL, config);
208
+
209
+ const abort = setupAbort(config, fetchOptions);
210
+
211
+ try {
212
+ const fetchImpl = config.fetch || fetch;
213
+ const rawResponse = await fetchImpl(fullURL, fetchOptions);
214
+ const fetchResponse = wrapDownloadProgress(rawResponse, config);
145
215
 
146
216
  const contentLength = fetchResponse.headers.get('content-length');
147
217
  if (
@@ -158,6 +228,7 @@ export default async function fetchAdapter(
158
228
  );
159
229
  }
160
230
 
231
+ let responseData: unknown;
161
232
  try {
162
233
  responseData = await readResponseData(fetchResponse, config);
163
234
  if (config.schema) {
@@ -178,59 +249,18 @@ export default async function fetchAdapter(
178
249
  );
179
250
  }
180
251
 
181
- const responseHeaders = parseHeaders(fetchResponse.headers);
182
-
183
252
  return {
184
253
  data: responseData,
185
254
  status: fetchResponse.status,
186
255
  statusText: fetchResponse.statusText,
187
- headers: responseHeaders,
188
- config: config,
256
+ headers: parseHeaders(fetchResponse.headers),
257
+ config,
189
258
  request: fetchResponse,
190
259
  duration: Date.now() - requestStartTime,
191
260
  };
192
261
  } catch (error) {
193
- if (error instanceof AccessioError) {
194
- throw error;
195
- }
196
-
197
- if (error instanceof Error && error.name === 'AbortError') {
198
- if (isTimedOut) {
199
- throw new AccessioError(
200
- `timeout of ${config.timeout}ms exceeded`,
201
- AccessioError.ETIMEDOUT,
202
- config,
203
- null,
204
- null,
205
- );
206
- }
207
- throw new AccessioError('Request aborted', AccessioError.ERR_CANCELED, config, null, null);
208
- }
209
-
210
- if (
211
- error instanceof TypeError &&
212
- (error.message.toLowerCase().includes('url') || error.message.toLowerCase().includes('fetch'))
213
- ) {
214
- throw new AccessioError(
215
- `Invalid URL: ${fullURL}`,
216
- AccessioError.ERR_INVALID_URL,
217
- config,
218
- null,
219
- null,
220
- );
221
- }
222
-
223
- throw AccessioError.from(
224
- error instanceof Error ? error : new Error(String(error)),
225
- AccessioError.ERR_NETWORK,
226
- config,
227
- null,
228
- null,
229
- );
262
+ throw classifyFetchError(error, config, abort.isTimedOut());
230
263
  } finally {
231
- if (timeoutId) clearTimeout(timeoutId);
232
- if (config.signal && onUserAbort) {
233
- config.signal.removeEventListener('abort', onUserAbort);
234
- }
264
+ abort.cleanup();
235
265
  }
236
266
  }
@@ -10,6 +10,31 @@ import { defaultMemoryCache } from '../helpers/memoryCache';
10
10
  import type { AccessioRequestConfig, AccessioResponse, TransformFunction } from '../types';
11
11
 
12
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
+ }
13
38
 
14
39
  function buildTransformArray(
15
40
  transform: TransformFunction | TransformFunction[] | undefined,
@@ -27,7 +52,7 @@ function assertAllowedProtocol(fullURL: string, config: AccessioRequestConfig):
27
52
 
28
53
  let scheme: string | null = null;
29
54
  const match = /^([a-z][a-z\d+\-.]*):/i.exec(fullURL);
30
- if (match) scheme = match[1].toLowerCase() + ':';
55
+ if (match) scheme = `${match[1].toLowerCase()}:`;
31
56
  if (!scheme) return;
32
57
 
33
58
  if (!allowed.includes(scheme)) {
@@ -43,6 +68,21 @@ function assertAllowedProtocol(fullURL: string, config: AccessioRequestConfig):
43
68
  }
44
69
 
45
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
+ }
46
86
 
47
87
  export default async function dispatchRequest(
48
88
  config: AccessioRequestConfig,
@@ -62,28 +102,36 @@ export default async function dispatchRequest(
62
102
  await config.hooks.onBeforeRequest(config);
63
103
  }
64
104
 
105
+ const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
106
+ setBasicAuth(config, flatHeaders);
107
+
65
108
  const isGet = (config.method || 'GET').toUpperCase() === 'GET';
66
- const cacheKey = isGet ? `GET:${fullURL}` : '';
109
+ const cacheKey = isGet ? buildCacheKey(config, fullURL, flatHeaders) : '';
67
110
 
68
111
  if (isGet && config.cache) {
69
112
  const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
70
113
  const cached = await cacheProvider.get(cacheKey);
71
114
  if (cached) {
115
+ const cachedView: AccessioResponse = {
116
+ ...cached,
117
+ config: redactConfig(config) as typeof cached.config,
118
+ };
72
119
  if (config.hooks?.onRequestResponse) {
73
- await config.hooks.onRequestResponse(cached);
120
+ await config.hooks.onRequestResponse(cachedView);
74
121
  }
75
- return cached;
122
+ return cachedView;
76
123
  }
77
124
  }
78
125
 
79
126
  if (isGet && config.dedupe) {
80
- if (activeRequests.has(cacheKey)) {
81
- return activeRequests.get(cacheKey)!;
127
+ const inflight = activeRequests.get(cacheKey);
128
+ if (inflight) {
129
+ const shared = await inflight;
130
+ return finalizeResponse(shared, config);
82
131
  }
83
132
  }
84
133
 
85
- const performRequest = async () => {
86
- const flatHeaders = flattenHeaders(config.headers as HeadersConfig | undefined, config.method);
134
+ const performRequest = async (): Promise<AccessioResponse> => {
87
135
  const requestTransforms = buildTransformArray(config.transformRequest);
88
136
  const requestData = await transformData(requestTransforms, config.data, flatHeaders, config);
89
137
 
@@ -95,8 +143,6 @@ export default async function dispatchRequest(
95
143
  removeContentType(flatHeaders);
96
144
  }
97
145
 
98
- setBasicAuth(config, flatHeaders);
99
-
100
146
  const fetchOptions: RequestInit = {
101
147
  method: (config.method || 'GET').toUpperCase(),
102
148
  headers: buildFetchHeaders(flatHeaders),
@@ -123,12 +169,9 @@ export default async function dispatchRequest(
123
169
  }
124
170
 
125
171
  const requestStartTime = Date.now();
126
-
127
172
  const response = await fetchAdapter(config, fullURL, fetchOptions, requestStartTime);
128
- response.config = redactConfig(response.config) as typeof response.config;
129
173
 
130
174
  const responseTransforms = buildTransformArray(config.transformResponse);
131
-
132
175
  response.data = await transformData(
133
176
  responseTransforms,
134
177
  response.data,
@@ -137,39 +180,44 @@ export default async function dispatchRequest(
137
180
  'response',
138
181
  );
139
182
 
140
- return new Promise<AccessioResponse>((resolve, reject) => {
141
- settle(
142
- resolve as (value: AccessioResponse) => void,
143
- reject as (reason: AccessioError) => void,
144
- response,
145
- config,
146
- );
147
- });
183
+ return response;
148
184
  };
149
185
 
150
186
  const promise = performRequest();
151
187
 
152
188
  if (isGet && config.dedupe) {
153
- activeRequests.set(cacheKey, promise);
189
+ trackActiveRequest(cacheKey, promise);
154
190
  const cleanup = () => {
155
- activeRequests.delete(cacheKey);
191
+ if (activeRequests.get(cacheKey) === promise) {
192
+ activeRequests.delete(cacheKey);
193
+ }
156
194
  };
157
195
  promise.then(cleanup, cleanup);
158
196
  }
159
197
 
160
198
  try {
161
- const response = await promise;
199
+ const shared = await promise;
200
+ const response = finalizeResponse(shared, config);
162
201
 
163
202
  if (isGet && config.cache) {
164
203
  const cacheProvider = typeof config.cache === 'object' ? config.cache : defaultMemoryCache;
165
- await cacheProvider.set(cacheKey, response, config.cacheTTL);
204
+ await cacheProvider.set(cacheKey, shared, config.cacheTTL);
166
205
  }
167
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
+
168
216
  if (config.hooks?.onRequestResponse) {
169
- await config.hooks.onRequestResponse(response);
217
+ await config.hooks.onRequestResponse(settled);
170
218
  }
171
219
 
172
- return response;
220
+ return settled;
173
221
  } catch (error) {
174
222
  if (config.hooks?.onRequestError && error instanceof AccessioError) {
175
223
  await config.hooks.onRequestError(error);
@@ -177,3 +225,13 @@ export default async function dispatchRequest(
177
225
  throw error;
178
226
  }
179
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
@@ -10,6 +10,7 @@ import type {
10
10
  function isUnretriableBody(data: unknown): boolean {
11
11
  if (data == null) return false;
12
12
  if (typeof ReadableStream !== 'undefined' && data instanceof ReadableStream) return true;
13
+ if (data && typeof (data as any).pipe === 'function') return true;
13
14
  return false;
14
15
  }
15
16
 
@@ -33,10 +34,11 @@ function defaultRetryCondition(error: any): boolean {
33
34
  return false;
34
35
  }
35
36
 
36
- function calculateDelay(attempt: number, baseDelay: number): number {
37
+ function calculateDelay(attempt: number, baseDelay: number, maxDelay: number = 30000): number {
37
38
  const exponentialDelay = baseDelay * Math.pow(2, attempt);
38
39
  const jitter = exponentialDelay * 0.25 * (Math.random() * 2 - 1);
39
- return Math.round(exponentialDelay + jitter);
40
+ const calculated = Math.round(exponentialDelay + jitter);
41
+ return Math.min(calculated, maxDelay);
40
42
  }
41
43
 
42
44
  function sleep(ms: number, options?: { signal?: AbortSignal }): Promise<void> {
@@ -89,8 +91,9 @@ async function retryRequest(
89
91
  } catch (error) {
90
92
  lastError = error;
91
93
 
92
- const isLastAttempt = attempt >= actualMaxRetries;
93
- const shouldRetry = !isLastAttempt && retryCondition(error as AccessioError);
94
+ const is429 = (error as any).response?.status === 429;
95
+ const attemptLimit = is429 && config.retryOn429 ? Math.max(maxRetries, 3) : maxRetries;
96
+ const shouldRetry = attempt < attemptLimit && retryCondition(error as AccessioError);
94
97
 
95
98
  if (!shouldRetry) {
96
99
  throw error;
@@ -107,7 +110,7 @@ async function retryRequest(
107
110
  );
108
111
  }
109
112
 
110
- let delay = calculateDelay(attempt, retryDelay);
113
+ let delay = calculateDelay(attempt, retryDelay, config.maxRetryDelay ?? 30000);
111
114
 
112
115
  if (config.retryOn429 && (error as any).response?.status === 429) {
113
116
  const headers = (error as any).response?.headers;
@@ -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
  }
@@ -76,10 +76,13 @@ export function removeContentType(headers: Record<string, string | string[]>): v
76
76
  export function buildFetchHeaders(headers: Record<string, string | string[]>): Headers {
77
77
  const fetchHeaders = new Headers();
78
78
  for (const [key, value] of Object.entries(headers)) {
79
+ if (value === undefined || value === null) continue;
79
80
  assertSafeHeader(key, value);
80
81
  if (Array.isArray(value)) {
81
82
  for (const v of value) {
82
- fetchHeaders.append(key, v);
83
+ if (v !== undefined && v !== null) {
84
+ fetchHeaders.append(key, v);
85
+ }
83
86
  }
84
87
  } else {
85
88
  fetchHeaders.set(key, value);
@@ -23,11 +23,15 @@ export function createRateLimiter(
23
23
  let destroyed = false;
24
24
  const queue: QueueItem[] = [];
25
25
 
26
- function acquire(): Promise<void> {
26
+ function acquire(signal?: AbortSignal): Promise<void> {
27
27
  if (destroyed) {
28
28
  return Promise.reject(new Error('[Accessio] Rate limiter has been destroyed'));
29
29
  }
30
30
 
31
+ if (signal?.aborted) {
32
+ return Promise.reject(signal.reason || new Error('Request aborted'));
33
+ }
34
+
31
35
  if (active < maxConcurrent) {
32
36
  active++;
33
37
  return Promise.resolve();
@@ -40,7 +44,35 @@ export function createRateLimiter(
40
44
  }
41
45
 
42
46
  return new Promise((resolve, reject) => {
43
- queue.push({ resolve, reject });
47
+ let onAbort: (() => void) | undefined;
48
+
49
+ const item = {
50
+ resolve: () => {
51
+ if (signal && onAbort) {
52
+ signal.removeEventListener('abort', onAbort);
53
+ }
54
+ resolve();
55
+ },
56
+ reject: (err: Error) => {
57
+ if (signal && onAbort) {
58
+ signal.removeEventListener('abort', onAbort);
59
+ }
60
+ reject(err);
61
+ },
62
+ };
63
+
64
+ queue.push(item);
65
+
66
+ if (signal) {
67
+ onAbort = () => {
68
+ const index = queue.indexOf(item);
69
+ if (index !== -1) {
70
+ queue.splice(index, 1);
71
+ }
72
+ reject(signal.reason || new Error('Request aborted'));
73
+ };
74
+ signal.addEventListener('abort', onAbort, { once: true });
75
+ }
44
76
  });
45
77
  }
46
78
 
@@ -85,7 +117,7 @@ export async function rateLimitedRequest<T = unknown>(
85
117
  limiter: RateLimiter,
86
118
  config: AccessioRequestConfig,
87
119
  ): Promise<AccessioResponse<T>> {
88
- await limiter.acquire();
120
+ await limiter.acquire(config.signal);
89
121
  try {
90
122
  return await dispatchFn(config);
91
123
  } finally {
@@ -9,7 +9,7 @@ export default function settle(
9
9
  ): void {
10
10
  const validateStatus = config.validateStatus;
11
11
 
12
- if (!response.status || !validateStatus || validateStatus(response.status)) {
12
+ if (!validateStatus || validateStatus(response.status)) {
13
13
  resolve(response);
14
14
  } else {
15
15
  const error = new AccessioError(