@azure-net/kit 6.0.0-next.13 → 6.0.0-next.14
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/README.md
CHANGED
|
@@ -390,7 +390,7 @@ const http = new HttpServiceInstance({
|
|
|
390
390
|
retry: {
|
|
391
391
|
limit: 1,
|
|
392
392
|
statuses: [401],
|
|
393
|
-
async shouldRetry({ error }) {
|
|
393
|
+
async shouldRetry({ error, http }) {
|
|
394
394
|
if (!isExpiredAccessToken(error.data)) return false;
|
|
395
395
|
const refreshed = await http.post('/api/auth/refresh', {
|
|
396
396
|
json: { refreshToken: readRefreshToken() },
|
|
@@ -421,8 +421,8 @@ implement token rotation or cross-tab coordination.
|
|
|
421
421
|
- Exceptions from hooks (including `shouldRetry`) propagate without further
|
|
422
422
|
recovery. They are not treated as another failed transport attempt.
|
|
423
423
|
- Cancelled requests and streaming request bodies are never replayed. Cancellation
|
|
424
|
-
|
|
425
|
-
|
|
424
|
+
also stops waiting for recovery; the callback must manage cancellation of its
|
|
425
|
+
own underlying work.
|
|
426
426
|
- `limit` must be a finite non-negative safe integer. There is no implicit retry
|
|
427
427
|
delay/backoff. Timeout is applied separately to each HTTP attempt.
|
|
428
428
|
|
|
@@ -430,3 +430,39 @@ Retries are opt-in for all HTTP methods. Do not blindly retry writes after netwo
|
|
|
430
430
|
errors: the server may already have applied them. Use a narrow status/code policy
|
|
431
431
|
or backend-supported idempotency keys. Numerical `retry: 3` is no longer supported;
|
|
432
432
|
write `retry: { limit: 3 }` instead.
|
|
433
|
+
|
|
434
|
+
HTTP hooks use these signatures:
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
retry: {
|
|
438
|
+
limit: 1,
|
|
439
|
+
shouldRetry: async ({ error, http }) => {
|
|
440
|
+
// The same client instance. Nested refresh must specify retry: { limit: 0 }.
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
onError: ({ error, request }) => {
|
|
445
|
+
// Terminal failure notification / validation mapping. No http is exposed.
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
This is a breaking change from positional `onError(error, request, http)`.
|
|
450
|
+
Request-level hook overrides use the same signatures. Nested requests use instance
|
|
451
|
+
settings plus their own options, not the outer request's body or signal.
|
|
452
|
+
|
|
453
|
+
AbortSignal stops waiting for hooks as well as transport operations. Arbitrary
|
|
454
|
+
user promises cannot be forcibly terminated: hooks must cooperate to stop their
|
|
455
|
+
own side effects. Hooks have no implicit timeout; supply and abort a signal to
|
|
456
|
+
bound their waiting time. The per-attempt timeout covers transport and body
|
|
457
|
+
parsing, including custom `doFetch`. With `responseFormat: 'body'`, it ends when
|
|
458
|
+
the stream is returned; reading/cancelling that stream is then the caller's job.
|
|
459
|
+
Timers/listeners are removed on success, failure, timeout and cancellation.
|
|
460
|
+
|
|
461
|
+
JSON serialization errors now reject before sending rather than silently sending
|
|
462
|
+
an empty body. The default JSON serializer does not support cyclic values or
|
|
463
|
+
BigInt; provide `stringifyJson` if a custom wire format is required.
|
|
464
|
+
|
|
465
|
+
Removing `http` from `onError` does not prevent recursion through a client captured
|
|
466
|
+
in a closure. Do not initiate the same failing operation from its error handler.
|
|
467
|
+
Refresh via `shouldRetry` still requires an explicit `retry: { limit: 0 }` on the
|
|
468
|
+
nested refresh call. Limits apply per HTTP call, not to arbitrary nested user code.
|
|
@@ -66,7 +66,10 @@ export interface IHttpInstanceNormalizedRequest {
|
|
|
66
66
|
requestInit: Omit<RequestInit, 'method' | 'headers' | 'body'>;
|
|
67
67
|
}
|
|
68
68
|
export type IHttpInstanceOnRequest = (request: IHttpInstanceNormalizedRequest) => void | Promise<void>;
|
|
69
|
-
export type IHttpInstanceOnError = (
|
|
69
|
+
export type IHttpInstanceOnError = (context: {
|
|
70
|
+
error: HttpServiceError<unknown>;
|
|
71
|
+
request: IHttpInstanceNormalizedRequest;
|
|
72
|
+
}) => void | Promise<void>;
|
|
70
73
|
export type IHttpInstanceDoFetch = <T = unknown>(request: IHttpInstanceNormalizedRequest) => Promise<IHttpServiceResponse<T>>;
|
|
71
74
|
export interface IHttpInstanceRetryOptions {
|
|
72
75
|
/** Number of additional attempts. Zero disables retries, including inherited policies. */
|
|
@@ -74,6 +77,7 @@ export interface IHttpInstanceRetryOptions {
|
|
|
74
77
|
statuses?: readonly number[];
|
|
75
78
|
shouldRetry?: (context: {
|
|
76
79
|
error: HttpServiceError<unknown>;
|
|
80
|
+
http: IHttpServiceInstance;
|
|
77
81
|
}) => boolean | Promise<boolean>;
|
|
78
82
|
}
|
|
79
83
|
export interface IHttpInstanceOptions<T = unknown> extends Omit<RequestInit, 'headers' | 'method'> {
|
|
@@ -56,14 +56,7 @@ export class HttpServiceError extends Error {
|
|
|
56
56
|
return this.data;
|
|
57
57
|
}
|
|
58
58
|
}
|
|
59
|
-
const stringifyJsonFunc = (data) =>
|
|
60
|
-
try {
|
|
61
|
-
return JSON.stringify(data);
|
|
62
|
-
}
|
|
63
|
-
catch {
|
|
64
|
-
return undefined;
|
|
65
|
-
}
|
|
66
|
-
};
|
|
59
|
+
const stringifyJsonFunc = (data) => JSON.stringify(data);
|
|
67
60
|
const isAbsoluteUrl = (value) => /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value);
|
|
68
61
|
const normalizeHeaders = (headers) => {
|
|
69
62
|
if (!headers)
|
|
@@ -107,7 +100,7 @@ const mergeSearchParams = (base, extra) => {
|
|
|
107
100
|
return result;
|
|
108
101
|
};
|
|
109
102
|
const mergeOptions = (base = {}, extra = {}) => {
|
|
110
|
-
const mergedHeaders =
|
|
103
|
+
const mergedHeaders = normalizeHeaders(base.headers);
|
|
111
104
|
for (const [key, value] of normalizeHeaders(extra.headers).entries()) {
|
|
112
105
|
mergedHeaders.set(key, value);
|
|
113
106
|
}
|
|
@@ -238,28 +231,46 @@ const parseResponse = async (response, request) => {
|
|
|
238
231
|
return { data, headers: Object.fromEntries(response.headers.entries()), status: response.status, raw };
|
|
239
232
|
};
|
|
240
233
|
const defaultDoFetch = async (request) => {
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
234
|
+
const response = await request.fetch(request.url, {
|
|
235
|
+
...request.requestInit,
|
|
236
|
+
...(typeof ReadableStream !== 'undefined' && request.body instanceof ReadableStream ? { duplex: 'half' } : {}),
|
|
237
|
+
method: request.method,
|
|
238
|
+
headers: request.headers,
|
|
239
|
+
body: request.body
|
|
240
|
+
});
|
|
241
|
+
const { data, headers, status, raw } = await parseResponse(response, request);
|
|
242
|
+
return new HttpServiceResponse({ headers, status, success: true, data, message: 'Request completed', raw });
|
|
243
|
+
};
|
|
244
|
+
// Stop waiting even when a custom transport or hook ignores AbortSignal.
|
|
245
|
+
// The underlying operation must cooperate to actually stop its own work.
|
|
246
|
+
const waitForSignal = (value, signal) => {
|
|
247
|
+
if (!signal)
|
|
248
|
+
return Promise.resolve(value);
|
|
249
|
+
return new Promise((resolve, reject) => {
|
|
250
|
+
const abort = () => {
|
|
251
|
+
cleanup();
|
|
252
|
+
reject(signal.reason);
|
|
253
|
+
};
|
|
254
|
+
const cleanup = () => signal.removeEventListener('abort', abort);
|
|
255
|
+
if (signal.aborted)
|
|
256
|
+
abort();
|
|
257
|
+
else
|
|
258
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
259
|
+
Promise.resolve(value).then((result) => {
|
|
260
|
+
cleanup();
|
|
261
|
+
resolve(result);
|
|
262
|
+
}, (error) => {
|
|
263
|
+
cleanup();
|
|
264
|
+
reject(error);
|
|
249
265
|
});
|
|
250
|
-
|
|
251
|
-
return new HttpServiceResponse({ headers, status, success: true, data, message: 'Request completed', raw });
|
|
252
|
-
}
|
|
253
|
-
finally {
|
|
254
|
-
timeoutState.clear();
|
|
255
|
-
}
|
|
266
|
+
});
|
|
256
267
|
};
|
|
257
|
-
const makeMethod = (method, defaults) => {
|
|
268
|
+
const makeMethod = (method, defaults, http) => {
|
|
258
269
|
return async (url, options = {}) => {
|
|
259
270
|
const merged = mergeOptions(defaults, options);
|
|
260
271
|
const stringifyJson = merged.stringifyJson ?? stringifyJsonFunc;
|
|
261
272
|
const fetcher = !BROWSER ? (RequestContext.current()?.event?.fetch ?? fetch) : fetch;
|
|
262
|
-
const headers =
|
|
273
|
+
const headers = merged.headers;
|
|
263
274
|
let body = merged.body;
|
|
264
275
|
if (merged.json !== undefined && body === undefined) {
|
|
265
276
|
body = stringifyJson(merged.json);
|
|
@@ -297,6 +308,11 @@ const makeMethod = (method, defaults) => {
|
|
|
297
308
|
}
|
|
298
309
|
};
|
|
299
310
|
// Snapshot the policy per call: hooks and concurrent requests cannot extend its budget.
|
|
311
|
+
const sourceSignal = merged.signal;
|
|
312
|
+
const timeout = normalizedRequest.timeout;
|
|
313
|
+
if (timeout !== false && (!Number.isFinite(timeout) || timeout < 0 || timeout > 2147483647)) {
|
|
314
|
+
throw new RangeError('timeout must be false or a finite number between 0 and 2147483647');
|
|
315
|
+
}
|
|
300
316
|
const policy = merged.retry;
|
|
301
317
|
const limit = policy?.limit ?? 0;
|
|
302
318
|
if (!Number.isSafeInteger(limit) || limit < 0) {
|
|
@@ -307,13 +323,22 @@ const makeMethod = (method, defaults) => {
|
|
|
307
323
|
const doFetch = merged.doFetch ?? defaultDoFetch;
|
|
308
324
|
let fetched;
|
|
309
325
|
for (let attempt = 0;; attempt++) {
|
|
310
|
-
|
|
326
|
+
sourceSignal?.throwIfAborted();
|
|
311
327
|
// Re-run authentication/header hooks after recovery, without accumulating headers.
|
|
312
|
-
normalizedRequest.headers = new Headers(headers);
|
|
313
|
-
|
|
328
|
+
normalizedRequest.headers = merged.onRequest ? new Headers(headers) : headers;
|
|
329
|
+
if (merged.onRequest)
|
|
330
|
+
await waitForSignal(merged.onRequest(normalizedRequest), sourceSignal);
|
|
314
331
|
normalizedRequest.requestInit.signal?.throwIfAborted();
|
|
315
332
|
try {
|
|
316
|
-
|
|
333
|
+
const timeoutState = withTimeout(sourceSignal, timeout);
|
|
334
|
+
normalizedRequest.requestInit.signal = timeoutState.signal;
|
|
335
|
+
try {
|
|
336
|
+
fetched = await waitForSignal(doFetch(normalizedRequest), timeoutState.signal);
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
timeoutState.clear();
|
|
340
|
+
normalizedRequest.requestInit.signal = sourceSignal;
|
|
341
|
+
}
|
|
317
342
|
break;
|
|
318
343
|
}
|
|
319
344
|
catch (error) {
|
|
@@ -323,19 +348,22 @@ const makeMethod = (method, defaults) => {
|
|
|
323
348
|
message: normalizeUnknownError(error).message,
|
|
324
349
|
original: normalizeUnknownError(error)
|
|
325
350
|
});
|
|
326
|
-
const aborted =
|
|
351
|
+
const aborted = sourceSignal?.aborted || normalizedError.original?.name === 'AbortError';
|
|
327
352
|
const streaming = typeof ReadableStream !== 'undefined' && normalizedRequest.body instanceof ReadableStream;
|
|
328
353
|
const eligible = !aborted && !streaming && attempt < limit && (!statuses || statuses.includes(normalizedError.status));
|
|
329
|
-
if (eligible && (!shouldRetry || (await shouldRetry({ error: normalizedError })))) {
|
|
354
|
+
if (eligible && (!shouldRetry || (await waitForSignal(shouldRetry({ error: normalizedError, http }), sourceSignal)))) {
|
|
330
355
|
normalizedRequest.requestInit.signal?.throwIfAborted();
|
|
331
356
|
continue;
|
|
332
357
|
}
|
|
333
|
-
|
|
358
|
+
if (merged.onError)
|
|
359
|
+
await waitForSignal(merged.onError({ error: normalizedError, request: normalizedRequest }), sourceSignal);
|
|
334
360
|
throw normalizedError;
|
|
335
361
|
}
|
|
336
362
|
}
|
|
337
363
|
// Consumer callback failures must never replay a successful HTTP operation.
|
|
338
|
-
|
|
364
|
+
sourceSignal?.throwIfAborted();
|
|
365
|
+
if (merged.onResponse)
|
|
366
|
+
await waitForSignal(merged.onResponse(fetched), sourceSignal);
|
|
339
367
|
return fetched;
|
|
340
368
|
};
|
|
341
369
|
};
|
|
@@ -350,12 +378,12 @@ export class HttpServiceInstance {
|
|
|
350
378
|
options;
|
|
351
379
|
constructor(config = {}) {
|
|
352
380
|
this.defaults = { ...config };
|
|
353
|
-
this.get = makeMethod(HttpInstanceFetchMethods.GET, this.defaults);
|
|
354
|
-
this.post = makeMethod(HttpInstanceFetchMethods.POST, this.defaults);
|
|
355
|
-
this.put = makeMethod(HttpInstanceFetchMethods.PUT, this.defaults);
|
|
356
|
-
this.delete = makeMethod(HttpInstanceFetchMethods.DELETE, this.defaults);
|
|
357
|
-
this.patch = makeMethod(HttpInstanceFetchMethods.PATCH, this.defaults);
|
|
358
|
-
this.head = makeMethod(HttpInstanceFetchMethods.HEAD, this.defaults);
|
|
359
|
-
this.options = makeMethod(HttpInstanceFetchMethods.OPTIONS, this.defaults);
|
|
381
|
+
this.get = makeMethod(HttpInstanceFetchMethods.GET, this.defaults, this);
|
|
382
|
+
this.post = makeMethod(HttpInstanceFetchMethods.POST, this.defaults, this);
|
|
383
|
+
this.put = makeMethod(HttpInstanceFetchMethods.PUT, this.defaults, this);
|
|
384
|
+
this.delete = makeMethod(HttpInstanceFetchMethods.DELETE, this.defaults, this);
|
|
385
|
+
this.patch = makeMethod(HttpInstanceFetchMethods.PATCH, this.defaults, this);
|
|
386
|
+
this.head = makeMethod(HttpInstanceFetchMethods.HEAD, this.defaults, this);
|
|
387
|
+
this.options = makeMethod(HttpInstanceFetchMethods.OPTIONS, this.defaults, this);
|
|
360
388
|
}
|
|
361
389
|
}
|
package/package.json
CHANGED