@azure-net/kit 6.0.0-next.12 → 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
- is checked again after awaiting recovery; the callback must manage its own async
425
- work and cancellation if needed.
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.
@@ -1,3 +1,4 @@
1
+ import { type RequestErrors, SchemaFail } from '../../delivery/schema/Schema.js';
1
2
  export type SearchParamsInit = string | string[][] | Record<string, string> | URLSearchParams | undefined;
2
3
  export type SearchParamsOption = SearchParamsInit | Record<string, string | number | boolean | undefined> | Array<Array<string | number | boolean>>;
3
4
  export declare enum HttpInstanceFetchMethods {
@@ -24,6 +25,7 @@ export interface IHttpServiceError<T = unknown> {
24
25
  message: string;
25
26
  status: number;
26
27
  data?: T;
28
+ validation?: unknown;
27
29
  }
28
30
  export declare class HttpServiceResponse<T> implements IHttpServiceResponse<T> {
29
31
  headers: Record<string, string>;
@@ -45,7 +47,10 @@ export declare class HttpServiceError<T> extends Error implements IHttpServiceEr
45
47
  type: HttpErrorTypes;
46
48
  original?: Error;
47
49
  response?: Response;
50
+ validation?: SchemaFail<Record<string, unknown>>;
48
51
  constructor({ status, data, message, original, type, response }: Partial<IHttpServiceError<T>>);
52
+ addAsValidation(validation: RequestErrors<Record<string, unknown>>): void;
53
+ getSelfTypedData<T = unknown>(): T;
49
54
  }
50
55
  export interface IHttpInstanceNormalizedRequest {
51
56
  url: string | URL;
@@ -61,7 +66,10 @@ export interface IHttpInstanceNormalizedRequest {
61
66
  requestInit: Omit<RequestInit, 'method' | 'headers' | 'body'>;
62
67
  }
63
68
  export type IHttpInstanceOnRequest = (request: IHttpInstanceNormalizedRequest) => void | Promise<void>;
64
- export type IHttpInstanceOnError = (error: HttpServiceError<unknown>, request: IHttpInstanceNormalizedRequest) => void | Promise<void>;
69
+ export type IHttpInstanceOnError = (context: {
70
+ error: HttpServiceError<unknown>;
71
+ request: IHttpInstanceNormalizedRequest;
72
+ }) => void | Promise<void>;
65
73
  export type IHttpInstanceDoFetch = <T = unknown>(request: IHttpInstanceNormalizedRequest) => Promise<IHttpServiceResponse<T>>;
66
74
  export interface IHttpInstanceRetryOptions {
67
75
  /** Number of additional attempts. Zero disables retries, including inherited policies. */
@@ -69,6 +77,7 @@ export interface IHttpInstanceRetryOptions {
69
77
  statuses?: readonly number[];
70
78
  shouldRetry?: (context: {
71
79
  error: HttpServiceError<unknown>;
80
+ http: IHttpServiceInstance;
72
81
  }) => boolean | Promise<boolean>;
73
82
  }
74
83
  export interface IHttpInstanceOptions<T = unknown> extends Omit<RequestInit, 'headers' | 'method'> {
@@ -1,5 +1,6 @@
1
1
  import { BROWSER } from '../../external/tools/index.js';
2
2
  import { RequestContext } from '../../shared/request-context/Internal.js';
3
+ import { SchemaFail } from '../../delivery/schema/Schema.js';
3
4
  export var HttpInstanceFetchMethods;
4
5
  (function (HttpInstanceFetchMethods) {
5
6
  HttpInstanceFetchMethods["GET"] = "GET";
@@ -38,6 +39,7 @@ export class HttpServiceError extends Error {
38
39
  type;
39
40
  original;
40
41
  response;
42
+ validation;
41
43
  constructor({ status = 500, data, message = 'unknown error', original, type = HttpErrorTypes.Internal, response }) {
42
44
  super(message);
43
45
  this.status = status;
@@ -47,15 +49,14 @@ export class HttpServiceError extends Error {
47
49
  this.response = response;
48
50
  this.type = type;
49
51
  }
50
- }
51
- const stringifyJsonFunc = (data) => {
52
- try {
53
- return JSON.stringify(data);
52
+ addAsValidation(validation) {
53
+ this.validation = new SchemaFail(validation);
54
54
  }
55
- catch {
56
- return undefined;
55
+ getSelfTypedData() {
56
+ return this.data;
57
57
  }
58
- };
58
+ }
59
+ const stringifyJsonFunc = (data) => JSON.stringify(data);
59
60
  const isAbsoluteUrl = (value) => /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value);
60
61
  const normalizeHeaders = (headers) => {
61
62
  if (!headers)
@@ -99,7 +100,7 @@ const mergeSearchParams = (base, extra) => {
99
100
  return result;
100
101
  };
101
102
  const mergeOptions = (base = {}, extra = {}) => {
102
- const mergedHeaders = new Headers(normalizeHeaders(base.headers));
103
+ const mergedHeaders = normalizeHeaders(base.headers);
103
104
  for (const [key, value] of normalizeHeaders(extra.headers).entries()) {
104
105
  mergedHeaders.set(key, value);
105
106
  }
@@ -230,28 +231,46 @@ const parseResponse = async (response, request) => {
230
231
  return { data, headers: Object.fromEntries(response.headers.entries()), status: response.status, raw };
231
232
  };
232
233
  const defaultDoFetch = async (request) => {
233
- const timeoutState = withTimeout(request.requestInit.signal, request.timeout);
234
- try {
235
- const response = await request.fetch(request.url, {
236
- ...request.requestInit,
237
- method: request.method,
238
- headers: request.headers,
239
- body: request.body,
240
- signal: timeoutState.signal ?? undefined
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);
241
265
  });
242
- const { data, headers, status, raw } = await parseResponse(response, request);
243
- return new HttpServiceResponse({ headers, status, success: true, data, message: 'Request completed', raw });
244
- }
245
- finally {
246
- timeoutState.clear();
247
- }
266
+ });
248
267
  };
249
- const makeMethod = (method, defaults) => {
268
+ const makeMethod = (method, defaults, http) => {
250
269
  return async (url, options = {}) => {
251
270
  const merged = mergeOptions(defaults, options);
252
271
  const stringifyJson = merged.stringifyJson ?? stringifyJsonFunc;
253
272
  const fetcher = !BROWSER ? (RequestContext.current()?.event?.fetch ?? fetch) : fetch;
254
- const headers = normalizeHeaders(merged.headers);
273
+ const headers = merged.headers;
255
274
  let body = merged.body;
256
275
  if (merged.json !== undefined && body === undefined) {
257
276
  body = stringifyJson(merged.json);
@@ -289,6 +308,11 @@ const makeMethod = (method, defaults) => {
289
308
  }
290
309
  };
291
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
+ }
292
316
  const policy = merged.retry;
293
317
  const limit = policy?.limit ?? 0;
294
318
  if (!Number.isSafeInteger(limit) || limit < 0) {
@@ -299,13 +323,22 @@ const makeMethod = (method, defaults) => {
299
323
  const doFetch = merged.doFetch ?? defaultDoFetch;
300
324
  let fetched;
301
325
  for (let attempt = 0;; attempt++) {
302
- normalizedRequest.requestInit.signal?.throwIfAborted();
326
+ sourceSignal?.throwIfAborted();
303
327
  // Re-run authentication/header hooks after recovery, without accumulating headers.
304
- normalizedRequest.headers = new Headers(headers);
305
- await merged.onRequest?.(normalizedRequest);
328
+ normalizedRequest.headers = merged.onRequest ? new Headers(headers) : headers;
329
+ if (merged.onRequest)
330
+ await waitForSignal(merged.onRequest(normalizedRequest), sourceSignal);
306
331
  normalizedRequest.requestInit.signal?.throwIfAborted();
307
332
  try {
308
- fetched = await doFetch(normalizedRequest);
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
+ }
309
342
  break;
310
343
  }
311
344
  catch (error) {
@@ -315,19 +348,22 @@ const makeMethod = (method, defaults) => {
315
348
  message: normalizeUnknownError(error).message,
316
349
  original: normalizeUnknownError(error)
317
350
  });
318
- const aborted = normalizedRequest.requestInit.signal?.aborted || normalizedError.original?.name === 'AbortError';
351
+ const aborted = sourceSignal?.aborted || normalizedError.original?.name === 'AbortError';
319
352
  const streaming = typeof ReadableStream !== 'undefined' && normalizedRequest.body instanceof ReadableStream;
320
353
  const eligible = !aborted && !streaming && attempt < limit && (!statuses || statuses.includes(normalizedError.status));
321
- if (eligible && (!shouldRetry || (await shouldRetry({ error: normalizedError })))) {
354
+ if (eligible && (!shouldRetry || (await waitForSignal(shouldRetry({ error: normalizedError, http }), sourceSignal)))) {
322
355
  normalizedRequest.requestInit.signal?.throwIfAborted();
323
356
  continue;
324
357
  }
325
- await merged.onError?.(normalizedError, normalizedRequest);
358
+ if (merged.onError)
359
+ await waitForSignal(merged.onError({ error: normalizedError, request: normalizedRequest }), sourceSignal);
326
360
  throw normalizedError;
327
361
  }
328
362
  }
329
363
  // Consumer callback failures must never replay a successful HTTP operation.
330
- await merged.onResponse?.(fetched);
364
+ sourceSignal?.throwIfAborted();
365
+ if (merged.onResponse)
366
+ await waitForSignal(merged.onResponse(fetched), sourceSignal);
331
367
  return fetched;
332
368
  };
333
369
  };
@@ -342,12 +378,12 @@ export class HttpServiceInstance {
342
378
  options;
343
379
  constructor(config = {}) {
344
380
  this.defaults = { ...config };
345
- this.get = makeMethod(HttpInstanceFetchMethods.GET, this.defaults);
346
- this.post = makeMethod(HttpInstanceFetchMethods.POST, this.defaults);
347
- this.put = makeMethod(HttpInstanceFetchMethods.PUT, this.defaults);
348
- this.delete = makeMethod(HttpInstanceFetchMethods.DELETE, this.defaults);
349
- this.patch = makeMethod(HttpInstanceFetchMethods.PATCH, this.defaults);
350
- this.head = makeMethod(HttpInstanceFetchMethods.HEAD, this.defaults);
351
- 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);
352
388
  }
353
389
  }
@@ -52,6 +52,7 @@ export class AppError extends Error {
52
52
  this.type = ErrorTypes.Http;
53
53
  this.external = this.error.type === HttpErrorTypes.External;
54
54
  this.statusCode = this.error.status;
55
+ this.validation = this.error?.validation?.getSelfTypedData();
55
56
  break;
56
57
  case this.error instanceof SchemaFail:
57
58
  this.type = ErrorTypes.Schema;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@azure-net/kit",
3
- "version": "6.0.0-next.12",
3
+ "version": "6.0.0-next.14",
4
4
  "license": "MIT",
5
5
  "author": "Pixel1917",
6
6
  "description": "Universal toolkit and library collection, developed as part of the Azure-net ecosystem",