@kizunajs/fetch 2.0.0-beta.19 → 2.0.0-beta.20

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/dist/index.cjs CHANGED
@@ -57,6 +57,26 @@ export interface ClientConfig {
57
57
  credentials?: RequestCredentials;
58
58
  fetch?: typeof fetch;
59
59
  onRequest?: (request: OutgoingRequest) => void | Promise<void>;
60
+ retry?: RetryOptions;
61
+ }
62
+
63
+ /**
64
+ * Retries a GET, HEAD, OPTIONS, PUT or DELETE that failed on the network or
65
+ * was answered 429 or 503, waiting what Retry-After asks or backing off.
66
+ */
67
+ export interface RetryOptions {
68
+ /**
69
+ * Attempts in all, the first included.
70
+ */
71
+ maximumAttempts?: number;
72
+ /**
73
+ * Milliseconds the first backoff waits at most.
74
+ */
75
+ initialDelay?: number;
76
+ /**
77
+ * Milliseconds any wait may take.
78
+ */
79
+ maximumDelay?: number;
60
80
  }
61
81
 
62
82
  /**
@@ -281,6 +301,61 @@ const readStream = (body: ReadableStream<Uint8Array>, contentType: string): Asyn
281
301
  return readBytes(body);
282
302
  };
283
303
 
304
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
305
+
306
+ const RETRY_STATUSES = new Set([429, 503]);
307
+
308
+ const retryAfter = (header: string | null): number | undefined => {
309
+ if (header === null) return undefined;
310
+ if (/^\d+$/.test(header.trim())) return Number(header.trim()) * 1000;
311
+ const date = Date.parse(header);
312
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
313
+ };
314
+
315
+ const wait = (milliseconds: number, signal: AbortSignal | null | undefined): Promise<void> =>
316
+ new Promise((resolve, reject) => {
317
+ if (signal?.aborted) {
318
+ reject(signal.reason);
319
+ return;
320
+ }
321
+ const onAbort = () => {
322
+ clearTimeout(timer);
323
+ reject(signal?.reason);
324
+ };
325
+ const timer = setTimeout(() => {
326
+ signal?.removeEventListener('abort', onAbort);
327
+ resolve();
328
+ }, milliseconds);
329
+ signal?.addEventListener('abort', onAbort, {
330
+ once: true,
331
+ });
332
+ });
333
+
334
+ const send = async (url: string, init: RequestInit, route: GeneratedRoute, config: ClientConfig): Promise<Response> => {
335
+ const fetcher = config.fetch ?? fetch;
336
+ const retry = config.retry;
337
+ if (retry === undefined || !IDEMPOTENT_METHODS.has(route.method)) return fetcher(url, init);
338
+ const maximumAttempts = retry.maximumAttempts ?? 3;
339
+ const initialDelay = retry.initialDelay ?? 500;
340
+ const maximumDelay = retry.maximumDelay ?? 30_000;
341
+ for (let attempt = 1; ; attempt += 1) {
342
+ const backoff = Math.random() * Math.min(maximumDelay, initialDelay * 2 ** (attempt - 1));
343
+ let response: Response;
344
+ try {
345
+ response = await fetcher(url, init);
346
+ } catch (error) {
347
+ if (attempt >= maximumAttempts || init.signal?.aborted || !(error instanceof TypeError)) throw error;
348
+ await wait(backoff, init.signal);
349
+ continue;
350
+ }
351
+ if (attempt >= maximumAttempts || !RETRY_STATUSES.has(response.status)) return response;
352
+ const asked = retryAfter(response.headers.get('retry-after'));
353
+ if (asked !== undefined && asked > maximumDelay) return response;
354
+ await response.body?.cancel();
355
+ await wait(asked ?? backoff, init.signal);
356
+ }
357
+ };
358
+
284
359
  const buildMethod = (route: GeneratedRoute, config: ClientConfig) => {
285
360
  const call = async (args: CallArgs = {}) => {
286
361
  const query = args.query === undefined ? '' : buildSearchParams(args.query).toString();
@@ -302,10 +377,15 @@ const buildMethod = (route: GeneratedRoute, config: ClientConfig) => {
302
377
  route,
303
378
  });
304
379
 
305
- const response = await (config.fetch ?? fetch)(url, {
306
- ...init,
307
- ...args.fetchOptions,
308
- });
380
+ const response = await send(
381
+ url,
382
+ {
383
+ ...init,
384
+ ...args.fetchOptions,
385
+ },
386
+ route,
387
+ config
388
+ );
309
389
  const responseHeaders: Record<string, string> = {};
310
390
  response.headers.forEach((value, name) => {
311
391
  responseHeaders[name] = value;
package/dist/index.mjs CHANGED
@@ -56,6 +56,26 @@ export interface ClientConfig {
56
56
  credentials?: RequestCredentials;
57
57
  fetch?: typeof fetch;
58
58
  onRequest?: (request: OutgoingRequest) => void | Promise<void>;
59
+ retry?: RetryOptions;
60
+ }
61
+
62
+ /**
63
+ * Retries a GET, HEAD, OPTIONS, PUT or DELETE that failed on the network or
64
+ * was answered 429 or 503, waiting what Retry-After asks or backing off.
65
+ */
66
+ export interface RetryOptions {
67
+ /**
68
+ * Attempts in all, the first included.
69
+ */
70
+ maximumAttempts?: number;
71
+ /**
72
+ * Milliseconds the first backoff waits at most.
73
+ */
74
+ initialDelay?: number;
75
+ /**
76
+ * Milliseconds any wait may take.
77
+ */
78
+ maximumDelay?: number;
59
79
  }
60
80
 
61
81
  /**
@@ -280,6 +300,61 @@ const readStream = (body: ReadableStream<Uint8Array>, contentType: string): Asyn
280
300
  return readBytes(body);
281
301
  };
282
302
 
303
+ const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
304
+
305
+ const RETRY_STATUSES = new Set([429, 503]);
306
+
307
+ const retryAfter = (header: string | null): number | undefined => {
308
+ if (header === null) return undefined;
309
+ if (/^\d+$/.test(header.trim())) return Number(header.trim()) * 1000;
310
+ const date = Date.parse(header);
311
+ return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now());
312
+ };
313
+
314
+ const wait = (milliseconds: number, signal: AbortSignal | null | undefined): Promise<void> =>
315
+ new Promise((resolve, reject) => {
316
+ if (signal?.aborted) {
317
+ reject(signal.reason);
318
+ return;
319
+ }
320
+ const onAbort = () => {
321
+ clearTimeout(timer);
322
+ reject(signal?.reason);
323
+ };
324
+ const timer = setTimeout(() => {
325
+ signal?.removeEventListener('abort', onAbort);
326
+ resolve();
327
+ }, milliseconds);
328
+ signal?.addEventListener('abort', onAbort, {
329
+ once: true,
330
+ });
331
+ });
332
+
333
+ const send = async (url: string, init: RequestInit, route: GeneratedRoute, config: ClientConfig): Promise<Response> => {
334
+ const fetcher = config.fetch ?? fetch;
335
+ const retry = config.retry;
336
+ if (retry === undefined || !IDEMPOTENT_METHODS.has(route.method)) return fetcher(url, init);
337
+ const maximumAttempts = retry.maximumAttempts ?? 3;
338
+ const initialDelay = retry.initialDelay ?? 500;
339
+ const maximumDelay = retry.maximumDelay ?? 30_000;
340
+ for (let attempt = 1; ; attempt += 1) {
341
+ const backoff = Math.random() * Math.min(maximumDelay, initialDelay * 2 ** (attempt - 1));
342
+ let response: Response;
343
+ try {
344
+ response = await fetcher(url, init);
345
+ } catch (error) {
346
+ if (attempt >= maximumAttempts || init.signal?.aborted || !(error instanceof TypeError)) throw error;
347
+ await wait(backoff, init.signal);
348
+ continue;
349
+ }
350
+ if (attempt >= maximumAttempts || !RETRY_STATUSES.has(response.status)) return response;
351
+ const asked = retryAfter(response.headers.get('retry-after'));
352
+ if (asked !== undefined && asked > maximumDelay) return response;
353
+ await response.body?.cancel();
354
+ await wait(asked ?? backoff, init.signal);
355
+ }
356
+ };
357
+
283
358
  const buildMethod = (route: GeneratedRoute, config: ClientConfig) => {
284
359
  const call = async (args: CallArgs = {}) => {
285
360
  const query = args.query === undefined ? '' : buildSearchParams(args.query).toString();
@@ -301,10 +376,15 @@ const buildMethod = (route: GeneratedRoute, config: ClientConfig) => {
301
376
  route,
302
377
  });
303
378
 
304
- const response = await (config.fetch ?? fetch)(url, {
305
- ...init,
306
- ...args.fetchOptions,
307
- });
379
+ const response = await send(
380
+ url,
381
+ {
382
+ ...init,
383
+ ...args.fetchOptions,
384
+ },
385
+ route,
386
+ config
387
+ );
308
388
  const responseHeaders: Record<string, string> = {};
309
389
  response.headers.forEach((value, name) => {
310
390
  responseHeaders[name] = value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kizunajs/fetch",
3
- "version": "2.0.0-beta.19",
3
+ "version": "2.0.0-beta.20",
4
4
  "description": "Fetch client generation for Kizuna.js APIs",
5
5
  "keywords": [
6
6
  "kizunajs",
@@ -56,12 +56,12 @@
56
56
  "access": "public"
57
57
  },
58
58
  "peerDependencies": {
59
- "kizunajs": "2.0.0-beta.19",
59
+ "kizunajs": "2.0.0-beta.20",
60
60
  "zod": "^4.0.0"
61
61
  },
62
62
  "devDependencies": {
63
- "kizunajs": "2.0.0-beta.19",
64
- "@kizunajs/express": "2.0.0-beta.19",
63
+ "kizunajs": "2.0.0-beta.20",
64
+ "@kizunajs/express": "2.0.0-beta.20",
65
65
  "@types/express": "^5.0.0",
66
66
  "express": "^5.0.0",
67
67
  "tsdown": "^0.21.0",