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.
- package/cjs/accessio.cjs +98 -97
- package/cjs/accessio.cjs.map +1 -1
- package/cjs/core/accessioError.cjs +51 -1
- package/cjs/core/accessioError.cjs.map +1 -1
- package/cjs/core/buildURL.cjs +10 -4
- package/cjs/core/buildURL.cjs.map +1 -1
- package/cjs/core/fetchAdapter.cjs +125 -105
- package/cjs/core/fetchAdapter.cjs.map +1 -1
- package/cjs/core/request.cjs +73 -23
- package/cjs/core/request.cjs.map +1 -1
- package/cjs/core/retry.cjs +8 -5
- package/cjs/core/retry.cjs.map +1 -1
- package/cjs/helpers/debug.cjs +7 -1
- package/cjs/helpers/debug.cjs.map +1 -1
- package/cjs/helpers/flattenHeaders.cjs +4 -1
- package/cjs/helpers/flattenHeaders.cjs.map +1 -1
- package/cjs/helpers/rateLimiter.cjs +31 -3
- package/cjs/helpers/rateLimiter.cjs.map +1 -1
- package/cjs/helpers/settle.cjs +1 -1
- package/cjs/helpers/settle.cjs.map +1 -1
- package/cjs/helpers/toFormData.cjs +1 -1
- package/cjs/helpers/toFormData.cjs.map +1 -1
- package/cjs/interceptors/interceptorManager.cjs +25 -18
- package/cjs/interceptors/interceptorManager.cjs.map +1 -1
- package/index.d.ts +82 -21
- package/package.json +1 -1
- package/src/accessio.ts +148 -113
- package/src/core/accessioError.ts +57 -1
- package/src/core/buildURL.ts +14 -4
- package/src/core/fetchAdapter.ts +155 -125
- package/src/core/request.ts +85 -27
- package/src/core/retry.ts +8 -5
- package/src/helpers/debug.ts +7 -2
- package/src/helpers/flattenHeaders.ts +4 -1
- package/src/helpers/rateLimiter.ts +35 -3
- package/src/helpers/settle.ts +1 -1
- package/src/helpers/toFormData.ts +5 -1
- package/src/interceptors/interceptorManager.ts +26 -19
- package/src/types.ts +2 -1
package/src/core/fetchAdapter.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
88
|
-
abortController.abort(config.signal
|
|
89
|
-
}
|
|
90
|
-
|
|
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
|
|
105
|
-
fetchOptions.signal =
|
|
115
|
+
} else {
|
|
116
|
+
fetchOptions.signal = abortController.signal;
|
|
106
117
|
}
|
|
107
118
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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:
|
|
188
|
-
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
|
-
|
|
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
|
-
|
|
232
|
-
if (config.signal && onUserAbort) {
|
|
233
|
-
config.signal.removeEventListener('abort', onUserAbort);
|
|
234
|
-
}
|
|
264
|
+
abort.cleanup();
|
|
235
265
|
}
|
|
236
266
|
}
|
package/src/core/request.ts
CHANGED
|
@@ -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 ?
|
|
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(
|
|
120
|
+
await config.hooks.onRequestResponse(cachedView);
|
|
74
121
|
}
|
|
75
|
-
return
|
|
122
|
+
return cachedView;
|
|
76
123
|
}
|
|
77
124
|
}
|
|
78
125
|
|
|
79
126
|
if (isGet && config.dedupe) {
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
189
|
+
trackActiveRequest(cacheKey, promise);
|
|
154
190
|
const cleanup = () => {
|
|
155
|
-
activeRequests.
|
|
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
|
|
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,
|
|
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(
|
|
217
|
+
await config.hooks.onRequestResponse(settled);
|
|
170
218
|
}
|
|
171
219
|
|
|
172
|
-
return
|
|
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
|
-
|
|
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
|
|
93
|
-
const
|
|
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;
|
package/src/helpers/debug.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/helpers/settle.ts
CHANGED
|
@@ -9,7 +9,7 @@ export default function settle(
|
|
|
9
9
|
): void {
|
|
10
10
|
const validateStatus = config.validateStatus;
|
|
11
11
|
|
|
12
|
-
if (!
|
|
12
|
+
if (!validateStatus || validateStatus(response.status)) {
|
|
13
13
|
resolve(response);
|
|
14
14
|
} else {
|
|
15
15
|
const error = new AccessioError(
|