@vertesia/api-fetch-client 1.1.1-dev.20260505.163000Z → 1.3.0-dev.20260620.061059Z

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 (80) hide show
  1. package/README.md +51 -0
  2. package/lib/base.d.ts +169 -0
  3. package/lib/base.d.ts.map +1 -0
  4. package/lib/base.js +481 -0
  5. package/lib/base.js.map +1 -0
  6. package/lib/{types/client.d.ts → client.d.ts} +8 -8
  7. package/lib/client.d.ts.map +1 -0
  8. package/lib/{esm/client.js → client.js} +27 -11
  9. package/lib/client.js.map +1 -0
  10. package/lib/{types/errors.d.ts → errors.d.ts} +5 -5
  11. package/lib/errors.d.ts.map +1 -0
  12. package/lib/{esm/errors.js → errors.js} +22 -9
  13. package/lib/errors.js.map +1 -0
  14. package/lib/index.d.ts +5 -0
  15. package/lib/index.d.ts.map +1 -0
  16. package/lib/index.js +5 -0
  17. package/lib/index.js.map +1 -0
  18. package/lib/{types/sse → sse}/EventSourceParserStream.d.ts +4 -1
  19. package/lib/sse/EventSourceParserStream.d.ts.map +1 -0
  20. package/lib/{esm/sse → sse}/EventSourceParserStream.js +5 -4
  21. package/lib/sse/EventSourceParserStream.js.map +1 -0
  22. package/lib/sse/TextDecoderStream.d.ts.map +1 -0
  23. package/lib/{esm/sse → sse}/TextDecoderStream.js +2 -2
  24. package/lib/sse/TextDecoderStream.js.map +1 -0
  25. package/lib/{types/sse → sse}/index.d.ts +7 -2
  26. package/lib/sse/index.d.ts.map +1 -0
  27. package/lib/{esm/sse → sse}/index.js +3 -3
  28. package/lib/sse/index.js.map +1 -0
  29. package/lib/{types/utils.d.ts → utils.d.ts} +1 -1
  30. package/lib/utils.d.ts.map +1 -0
  31. package/lib/{esm/utils.js → utils.js} +3 -3
  32. package/lib/utils.js.map +1 -0
  33. package/package.json +22 -25
  34. package/src/base.ts +432 -79
  35. package/src/client.ts +50 -24
  36. package/src/errors.ts +28 -15
  37. package/src/index.ts +4 -4
  38. package/src/sse/EventSourceParserStream.ts +13 -10
  39. package/src/sse/TextDecoderStream.ts +16 -11
  40. package/src/sse/index.ts +14 -8
  41. package/src/utils.ts +5 -6
  42. package/lib/cjs/base.js +0 -240
  43. package/lib/cjs/base.js.map +0 -1
  44. package/lib/cjs/client.js +0 -115
  45. package/lib/cjs/client.js.map +0 -1
  46. package/lib/cjs/errors.js +0 -63
  47. package/lib/cjs/errors.js.map +0 -1
  48. package/lib/cjs/index.js +0 -21
  49. package/lib/cjs/index.js.map +0 -1
  50. package/lib/cjs/package.json +0 -3
  51. package/lib/cjs/sse/EventSourceParserStream.js +0 -41
  52. package/lib/cjs/sse/EventSourceParserStream.js.map +0 -1
  53. package/lib/cjs/sse/TextDecoderStream.js +0 -53
  54. package/lib/cjs/sse/TextDecoderStream.js.map +0 -1
  55. package/lib/cjs/sse/index.js +0 -27
  56. package/lib/cjs/sse/index.js.map +0 -1
  57. package/lib/cjs/utils.js +0 -38
  58. package/lib/cjs/utils.js.map +0 -1
  59. package/lib/esm/base.js +0 -235
  60. package/lib/esm/base.js.map +0 -1
  61. package/lib/esm/client.js.map +0 -1
  62. package/lib/esm/errors.js.map +0 -1
  63. package/lib/esm/index.js +0 -5
  64. package/lib/esm/index.js.map +0 -1
  65. package/lib/esm/sse/EventSourceParserStream.js.map +0 -1
  66. package/lib/esm/sse/TextDecoderStream.js.map +0 -1
  67. package/lib/esm/sse/index.js.map +0 -1
  68. package/lib/esm/utils.js.map +0 -1
  69. package/lib/tsconfig.tsbuildinfo +0 -1
  70. package/lib/types/base.d.ts +0 -83
  71. package/lib/types/base.d.ts.map +0 -1
  72. package/lib/types/client.d.ts.map +0 -1
  73. package/lib/types/errors.d.ts.map +0 -1
  74. package/lib/types/index.d.ts +0 -5
  75. package/lib/types/index.d.ts.map +0 -1
  76. package/lib/types/sse/EventSourceParserStream.d.ts.map +0 -1
  77. package/lib/types/sse/TextDecoderStream.d.ts.map +0 -1
  78. package/lib/types/sse/index.d.ts.map +0 -1
  79. package/lib/types/utils.d.ts.map +0 -1
  80. /package/lib/{types/sse → sse}/TextDecoderStream.d.ts +0 -0
package/src/base.ts CHANGED
@@ -1,10 +1,69 @@
1
- import { ConnectionError, RequestError, ServerError } from "./errors.js";
2
- import { sse, ServerSentEvent } from "./sse/index.js";
3
- import { buildQueryString, join, removeTrailingSlash } from "./utils.js";
1
+ import { ConnectionError, type RequestError, ServerError } from './errors.js';
2
+ import { type ServerSentEvent, sse } from './sse/index.js';
3
+ import { buildQueryString, join, removeTrailingSlash } from './utils.js';
4
4
 
5
5
  export type FETCH_FN = (input: RequestInfo, init?: RequestInit) => Promise<Response>;
6
6
  type IPrimitives = string | number | boolean | null | undefined | string[] | number[] | boolean[];
7
7
 
8
+ export interface IRequestRetryPolicy {
9
+ /**
10
+ * Total attempts, including the first request. Defaults to 3 when a retry policy is enabled.
11
+ */
12
+ attempts?: number;
13
+ /**
14
+ * HTTP methods that may be retried. Defaults to idempotent methods.
15
+ */
16
+ methods?: string[];
17
+ /**
18
+ * HTTP response statuses that should be retried. Defaults to 502, 503, and 504.
19
+ */
20
+ statuses?: number[];
21
+ /**
22
+ * Also retry any 5xx whose body is not JSON — e.g. an HTML error page from a load balancer,
23
+ * gateway, or Cloud Run ("no available instance" / "try again in 30 seconds"). These originate
24
+ * at the edge, not the application (which serializes its errors as JSON), so they are transient
25
+ * and safe to retry even when the exact status (often 500) is not in `statuses`. Defaults to true.
26
+ */
27
+ retryNonJsonServerErrors?: boolean;
28
+ /**
29
+ * Retry network failures thrown by fetch. Defaults to true.
30
+ */
31
+ retryOnConnectionError?: boolean;
32
+ /**
33
+ * Initial backoff delay in milliseconds. Defaults to 250.
34
+ */
35
+ baseDelayMs?: number;
36
+ /**
37
+ * Maximum backoff delay in milliseconds. Defaults to 4000.
38
+ */
39
+ maxDelayMs?: number;
40
+ /**
41
+ * Use full jitter for backoff delays. Defaults to true.
42
+ */
43
+ jitter?: boolean;
44
+ }
45
+
46
+ type NormalizedRetryPolicy = {
47
+ attempts: number;
48
+ methods: Set<string>;
49
+ statuses: Set<number>;
50
+ retryNonJsonServerErrors: boolean;
51
+ retryOnConnectionError: boolean;
52
+ baseDelayMs: number;
53
+ maxDelayMs: number;
54
+ jitter: boolean;
55
+ };
56
+
57
+ const DEFAULT_RETRY_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'];
58
+ const DEFAULT_RETRY_STATUSES = [502, 503, 504];
59
+ const DEFAULT_RETRY_ATTEMPTS = 3;
60
+ const DEFAULT_RETRY_BASE_DELAY_MS = 250;
61
+ const DEFAULT_RETRY_MAX_DELAY_MS = 4000;
62
+
63
+ function isRecord(value: unknown): value is Record<string, unknown> {
64
+ return value !== null && typeof value === 'object';
65
+ }
66
+
8
67
  export interface IRequestParams {
9
68
  query?: Record<string, IPrimitives> | null;
10
69
  headers?: Record<string, string> | null;
@@ -17,12 +76,29 @@ export interface IRequestParams {
17
76
  * If set to 'sse' the response will be treated as a server-sent event stream
18
77
  * and the request will return a Promise<ReadableStream<ServerSentEvent>> object
19
78
  */
20
- reader?: 'sse' | ((response: Response) => any);
79
+ reader?: 'sse' | ((response: Response) => unknown);
21
80
  /**
22
81
  * Set to false to disable automatic JSON payload serialization
23
82
  * If you need to post other data than a json payload, set this to false and use the `payload` property to set the desired payload
24
83
  */
25
- jsonPayload?: boolean
84
+ jsonPayload?: boolean;
85
+ /**
86
+ * Opt-in retry policy for this request. Retries are disabled by default.
87
+ * Set to false to disable a client-level retry policy for this request.
88
+ */
89
+ retryPolicy?: IRequestRetryPolicy | false | null;
90
+ /**
91
+ * Per-request timeout in milliseconds. Aborts the whole request — connection, response headers,
92
+ * AND body consumption (JSON parse) — via a browser-standard AbortSignal. A positive number sets
93
+ * the timeout; `false`/`null`/`0` disables it for this request (overriding any client default).
94
+ * When omitted, the client-level default (`withTimeout` / `defaultTimeoutMs`) applies.
95
+ * Not applied to SSE (`reader: 'sse'`) requests, which are long-lived by design.
96
+ */
97
+ timeoutMs?: number | false | null;
98
+ /**
99
+ * Caller-supplied AbortSignal. Merged with the timeout signal — whichever aborts first wins.
100
+ */
101
+ signal?: AbortSignal;
26
102
  }
27
103
 
28
104
  export interface IRequestParamsWithPayload extends IRequestParams {
@@ -37,21 +113,144 @@ export function fetchPromise(fetchImpl?: FETCH_FN | Promise<FETCH_FN>) {
37
113
  } else {
38
114
  // install an error impl
39
115
  return Promise.resolve(() => {
40
- throw new Error('No Fetch implementation found')
116
+ throw new Error('No Fetch implementation found');
41
117
  });
42
118
  }
43
119
  }
44
120
 
45
- function isInvalidJsonPayload(payload: any) {
46
- return payload?.error === "Not a valid JSON payload" && typeof payload.text === "string";
121
+ function isInvalidJsonPayload(payload: unknown) {
122
+ return isRecord(payload) && payload.error === 'Not a valid JSON payload' && typeof payload.text === 'string';
47
123
  }
48
124
 
49
- export abstract class ClientBase {
125
+ function isReplayableBody(body: BodyInit | undefined) {
126
+ return !body || typeof ReadableStream === 'undefined' || !(body instanceof ReadableStream);
127
+ }
128
+
129
+ /**
130
+ * True for a 5xx whose Content-Type is not JSON. The application serializes its errors as JSON, so a
131
+ * non-JSON 5xx (HTML/text, or no content-type) is an edge/LB/gateway failure — e.g. a Cloud Run
132
+ * "no available instance" or GFE "try again in 30 seconds" page — which is transient and retryable.
133
+ * Reads only headers (no body consumption), so it is safe to call before deciding to retry.
134
+ */
135
+ function isNonJsonServerError(res: Response): boolean {
136
+ if (res.status < 500) {
137
+ return false;
138
+ }
139
+ const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
140
+ return !contentType.includes('json');
141
+ }
142
+
143
+ function normalizeRetryPolicy(policy: IRequestRetryPolicy): NormalizedRetryPolicy {
144
+ const attempts = Math.max(1, Math.floor(policy.attempts ?? DEFAULT_RETRY_ATTEMPTS));
145
+ const baseDelayMs = Math.max(0, policy.baseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS);
146
+ const maxDelayMs = Math.max(baseDelayMs, policy.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS);
147
+ return {
148
+ attempts,
149
+ methods: new Set((policy.methods ?? DEFAULT_RETRY_METHODS).map((method) => method.toUpperCase())),
150
+ statuses: new Set(policy.statuses ?? DEFAULT_RETRY_STATUSES),
151
+ retryNonJsonServerErrors: policy.retryNonJsonServerErrors ?? true,
152
+ retryOnConnectionError: policy.retryOnConnectionError ?? true,
153
+ baseDelayMs,
154
+ maxDelayMs,
155
+ jitter: policy.jitter ?? true,
156
+ };
157
+ }
158
+
159
+ function retryAfterDelayMs(res: Response): number | undefined {
160
+ const retryAfter = res.headers.get('retry-after');
161
+ if (!retryAfter) {
162
+ return undefined;
163
+ }
164
+ const seconds = Number(retryAfter);
165
+ if (Number.isFinite(seconds) && seconds >= 0) {
166
+ return seconds * 1000;
167
+ }
168
+ const retryAt = Date.parse(retryAfter);
169
+ if (!Number.isNaN(retryAt)) {
170
+ return Math.max(0, retryAt - Date.now());
171
+ }
172
+ return undefined;
173
+ }
174
+
175
+ function retryDelayMs(policy: NormalizedRetryPolicy, attempt: number, res?: Response): number {
176
+ const retryAfter = res ? retryAfterDelayMs(res) : undefined;
177
+ const delay = retryAfter ?? Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);
178
+ return policy.jitter ? Math.floor(Math.random() * delay) : delay;
179
+ }
180
+
181
+ function toError(err: unknown): Error {
182
+ return err instanceof Error ? err : new Error(String(err));
183
+ }
184
+
185
+ /**
186
+ * True for an AbortSignal-driven failure (caller abort or our timeout). Checks `name` rather than
187
+ * `instanceof Error` because runtimes surface these as a `DOMException` (`TimeoutError`/`AbortError`),
188
+ * which is not always an `Error` subclass in browsers.
189
+ */
190
+ function isAbortError(err: unknown): boolean {
191
+ return (
192
+ typeof err === 'object' &&
193
+ err !== null &&
194
+ 'name' in err &&
195
+ ((err as { name: unknown }).name === 'TimeoutError' || (err as { name: unknown }).name === 'AbortError')
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Browser + Node safe `AbortSignal.timeout(ms)`, with a fallback for runtimes that lack it.
201
+ */
202
+ function timeoutSignal(ms: number): AbortSignal {
203
+ const timeoutFn = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout;
204
+ if (typeof timeoutFn === 'function') {
205
+ return timeoutFn(ms);
206
+ }
207
+ const controller = new AbortController();
208
+ setTimeout(() => controller.abort(new DOMException('The operation timed out', 'TimeoutError')), ms);
209
+ return controller.signal;
210
+ }
211
+
212
+ /**
213
+ * Merge abort signals (caller + timeout). Uses the standard `AbortSignal.any` when available, with a
214
+ * manual fallback for older runtimes. Returns undefined when there is nothing to combine.
215
+ */
216
+ function combineAbortSignals(signals: Array<AbortSignal | undefined>): AbortSignal | undefined {
217
+ const list = signals.filter((s): s is AbortSignal => !!s);
218
+ if (list.length === 0) {
219
+ return undefined;
220
+ }
221
+ if (list.length === 1) {
222
+ return list[0];
223
+ }
224
+ const anyFn = (AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }).any;
225
+ if (typeof anyFn === 'function') {
226
+ return anyFn(list);
227
+ }
228
+ const controller = new AbortController();
229
+ for (const s of list) {
230
+ if (s.aborted) {
231
+ controller.abort(s.reason);
232
+ break;
233
+ }
234
+ s.addEventListener('abort', () => controller.abort(s.reason), { once: true });
235
+ }
236
+ return controller.signal;
237
+ }
238
+
239
+ async function discardBody(res: Response) {
240
+ try {
241
+ await res.body?.cancel();
242
+ } catch {
243
+ // Ignore body cleanup failures while retrying the original request.
244
+ }
245
+ }
50
246
 
247
+ export abstract class ClientBase {
51
248
  _fetch: Promise<FETCH_FN>;
52
249
  baseUrl: string;
53
250
  errorFactory: (err: RequestError) => Error = (err) => err;
54
251
  verboseErrors = true;
252
+ retryPolicy?: IRequestRetryPolicy;
253
+ defaultTimeoutMs?: number;
55
254
 
56
255
  abstract get headers(): Record<string, string>;
57
256
 
@@ -68,6 +267,49 @@ export abstract class ClientBase {
68
267
  throw this.errorFactory(err);
69
268
  }
70
269
 
270
+ withRetryPolicy(policy?: IRequestRetryPolicy | null): this {
271
+ this.retryPolicy = policy || undefined;
272
+ return this;
273
+ }
274
+
275
+ getRetryPolicy(): IRequestRetryPolicy | undefined {
276
+ return this.retryPolicy;
277
+ }
278
+
279
+ /**
280
+ * Set a default request timeout (ms) applied to every request unless overridden per-request via
281
+ * `timeoutMs`. Pass `false`/`null`/`0` to clear it. The timeout aborts the whole request
282
+ * (connection + response headers + body consumption) via AbortSignal.
283
+ */
284
+ withTimeout(timeoutMs?: number | false | null): this {
285
+ this.defaultTimeoutMs = typeof timeoutMs === 'number' && timeoutMs > 0 ? timeoutMs : undefined;
286
+ return this;
287
+ }
288
+
289
+ getTimeout(): number | undefined {
290
+ return this.defaultTimeoutMs;
291
+ }
292
+
293
+ /**
294
+ * Resolve the effective timeout for a request: per-request `timeoutMs` wins (a positive number
295
+ * sets it; `false`/`null`/`0` disables it), otherwise the client default applies. SSE streams are
296
+ * never given a total-request timeout.
297
+ */
298
+ protected resolveTimeout(params: IRequestParams | undefined): number | undefined {
299
+ if (params?.reader === 'sse') {
300
+ return undefined;
301
+ }
302
+ const requestTimeout = params?.timeoutMs;
303
+ if (requestTimeout === false || requestTimeout === null) {
304
+ return undefined;
305
+ }
306
+ if (typeof requestTimeout === 'number') {
307
+ return requestTimeout > 0 ? requestTimeout : undefined;
308
+ }
309
+ const clientTimeout = this.getTimeout();
310
+ return clientTimeout && clientTimeout > 0 ? clientTimeout : undefined;
311
+ }
312
+
71
313
  /**
72
314
  * Resolve a path to a full URL. If the path is already an absolute URL
73
315
  * (starts with http:// or https://), it is returned as-is.
@@ -79,24 +321,24 @@ export abstract class ClientBase {
79
321
  return removeTrailingSlash(join(this.baseUrl, path));
80
322
  }
81
323
 
82
- get(path: string, params?: IRequestParams) {
83
- return this.request('GET', path, params);
324
+ get<T = unknown>(path: string, params?: IRequestParams): Promise<T> {
325
+ return this.request<T>('GET', path, params);
84
326
  }
85
327
 
86
- del(path: string, params?: IRequestParams) {
87
- return this.request('DELETE', path, params);
328
+ del<T = unknown>(path: string, params?: IRequestParams): Promise<T> {
329
+ return this.request<T>('DELETE', path, params);
88
330
  }
89
331
 
90
- delete(path: string, params?: IRequestParams) {
332
+ delete(path: string, params?: IRequestParams): Promise<unknown> {
91
333
  return this.request('DELETE', path, params);
92
334
  }
93
335
 
94
- post(path: string, params?: IRequestParamsWithPayload) {
95
- return this.request('POST', path, params);
336
+ post<T = unknown>(path: string, params?: IRequestParamsWithPayload): Promise<T> {
337
+ return this.request<T>('POST', path, params);
96
338
  }
97
339
 
98
- put(path: string, params?: IRequestParamsWithPayload) {
99
- return this.request('PUT', path, params);
340
+ put<T = unknown>(path: string, params?: IRequestParamsWithPayload): Promise<T> {
341
+ return this.request<T>('PUT', path, params);
100
342
  }
101
343
 
102
344
  /**
@@ -104,82 +346,95 @@ export abstract class ClientBase {
104
346
  * @param text
105
347
  * @returns
106
348
  */
107
- jsonParse(text: string) {
349
+ jsonParse(text: string): unknown {
108
350
  return JSON.parse(text);
109
351
  }
110
352
 
111
353
  /**
112
- * Can be overridden to create the request
113
- * @param fetch
114
- * @param url
115
- * @param init
116
- * @returns
117
- */
354
+ * Can be overridden to create the request
355
+ * @param fetch
356
+ * @param url
357
+ * @param init
358
+ * @returns
359
+ */
118
360
  createRequest(url: string, init: RequestInit): Promise<Request> {
119
361
  return Promise.resolve(new Request(url, init));
120
362
  }
121
363
 
122
- createServerError(req: Request, res: Response, payload: any): RequestError {
364
+ handleFetchResponse(_req: Request, _res: Response): void {}
365
+
366
+ createServerError(req: Request, res: Response, payload: unknown): RequestError {
123
367
  const status = res.status;
124
- let message = 'Server Error: ' + status;
368
+ let message = `Server Error: ${status}`;
125
369
  if (payload) {
126
370
  if (isInvalidJsonPayload(payload)) {
127
- message += res.statusText ? ' ' + res.statusText : '';
371
+ message += res.statusText ? ` ${res.statusText}` : '';
128
372
  message += ': non-JSON response';
129
- } else if (payload.message) {
373
+ } else if (isRecord(payload) && payload.message) {
130
374
  message = String(payload.message);
131
- } else if (payload.error) {
375
+ } else if (isRecord(payload) && payload.error) {
132
376
  if (typeof payload.error === 'string') {
133
377
  message = String(payload.error);
134
- } else if (typeof payload.error.message === 'string') {
135
- message = String(payload.error.message);
378
+ } else if (isRecord(payload.error) && typeof payload.error.message === 'string') {
379
+ message = payload.error.message;
136
380
  }
137
381
  }
138
382
  }
139
383
  return new ServerError(message, req, res.status, payload, this.verboseErrors);
140
384
  }
141
385
 
142
-
143
386
  async readJSONPayload(res: Response) {
144
- return res.text().then(text => {
145
- if (!text) {
146
- return undefined;
147
- } else {
148
- try {
149
- return this.jsonParse(text);
150
- } catch (err: any) {
151
- return {
152
- status: res.status,
153
- error: "Not a valid JSON payload",
154
- message: err.message,
155
- text: text,
156
- };
387
+ return res
388
+ .text()
389
+ .then((text) => {
390
+ if (!text) {
391
+ return undefined;
392
+ } else {
393
+ try {
394
+ return this.jsonParse(text);
395
+ } catch (err: unknown) {
396
+ return {
397
+ status: res.status,
398
+ error: 'Not a valid JSON payload',
399
+ message: err instanceof Error ? err.message : String(err),
400
+ text: text,
401
+ };
402
+ }
157
403
  }
158
- }
159
- }).catch((err) => {
160
- return {
161
- status: res.status,
162
- error: "Unable to load response content",
163
- message: err.message,
164
- };
165
- });
404
+ })
405
+ .catch((err: unknown) => {
406
+ // A timeout/abort during body consumption is a real failure — surface it instead of
407
+ // swallowing it into an error payload that would otherwise be returned as the result.
408
+ if (isAbortError(err)) {
409
+ throw err;
410
+ }
411
+ return {
412
+ status: res.status,
413
+ error: 'Unable to load response content',
414
+ message: err instanceof Error ? err.message : String(err),
415
+ };
416
+ });
166
417
  }
167
418
 
168
419
  /**
169
420
  * Subclasses You can override this to do something with the response
170
421
  * @param res
171
422
  */
172
- handleResponse(req: Request, res: Response, params: IRequestParamsWithPayload | undefined) {
173
- if (params && params.reader) {
423
+ handleResponse<T = unknown>(
424
+ req: Request,
425
+ res: Response,
426
+ params: IRequestParamsWithPayload | undefined,
427
+ ): T | Promise<T> {
428
+ if (params?.reader) {
174
429
  if (params.reader === 'sse') {
175
- return sse(res);
430
+ return sse(res) as T;
176
431
  } else {
177
- return params.reader.call(this, res);
432
+ return params.reader.call(this, res) as T | Promise<T>;
178
433
  }
179
434
  } else {
180
435
  return this.readJSONPayload(res).then((payload) => {
181
436
  if (res.ok) {
182
- return payload;
437
+ return payload as T;
183
438
  } else {
184
439
  this.throwError(this.createServerError(req, res, payload));
185
440
  }
@@ -187,10 +442,10 @@ export abstract class ClientBase {
187
442
  }
188
443
  }
189
444
 
190
- async request(method: string, path: string, params?: IRequestParamsWithPayload) {
445
+ async request<T = unknown>(method: string, path: string, params?: IRequestParamsWithPayload): Promise<T> {
191
446
  let url = this.getUrl(path);
192
447
  if (params?.query) {
193
- url += '?' + buildQueryString(params.query);
448
+ url += `?${buildQueryString(params.query)}`;
194
449
  }
195
450
  const headers = this.headers ? Object.assign({}, this.headers) : {};
196
451
  const paramsHeaders = params?.headers;
@@ -205,29 +460,128 @@ export abstract class ClientBase {
205
460
  if (params && params.jsonPayload === false) {
206
461
  body = payload as BodyInit;
207
462
  } else {
208
- body = (typeof payload !== 'string') ? JSON.stringify(payload) : payload;
463
+ body = typeof payload !== 'string' ? JSON.stringify(payload) : payload;
209
464
  if (!('content-type' in headers)) {
210
465
  headers['content-type'] = 'application/json';
211
466
  }
212
467
  }
213
468
  }
214
469
  // When using SSE reader, ensure the Accept header requests event-stream
215
- if (params?.reader === 'sse' && !('accept' in headers)) {
216
- headers['accept'] = 'text/event-stream';
470
+ if (params?.reader === 'sse') {
471
+ headers.accept = 'text/event-stream';
472
+ }
473
+
474
+ const normalizedMethod = method.toUpperCase();
475
+ // Resolved once; createRequestInit() then mints a FRESH timeout signal per attempt so each
476
+ // retry gets its own deadline. The signal is set on the Request, so it bounds the connection,
477
+ // the wait for response headers, AND body consumption (handleResponse reads res.text()).
478
+ const timeoutMs = this.resolveTimeout(params);
479
+ const createRequestInit = (): RequestInit => {
480
+ const signal = combineAbortSignals([params?.signal, timeoutMs ? timeoutSignal(timeoutMs) : undefined]);
481
+ const init: RequestInit = {
482
+ method: normalizedMethod,
483
+ headers: Object.assign({}, headers),
484
+ body: body,
485
+ };
486
+ if (signal) {
487
+ init.signal = signal;
488
+ }
489
+ return init;
490
+ };
491
+ const retryPolicy = this.resolveRetryPolicy(params);
492
+ const fetch = await this._fetch;
493
+
494
+ if (!retryPolicy) {
495
+ const req = await this.createRequest(url, createRequestInit());
496
+ let res: Response;
497
+ try {
498
+ res = await fetch(req);
499
+ } catch (err: unknown) {
500
+ console.error(`Failed to connect to ${url}`, err);
501
+ this.throwError(new ConnectionError(req, toError(err)));
502
+ }
503
+ this.handleFetchResponse(req, res);
504
+ return this.handleResponse<T>(req, res, params);
505
+ }
506
+
507
+ const replayableBody = isReplayableBody(body);
508
+ let lastReq: Request | undefined;
509
+ for (let attempt = 0; attempt < retryPolicy.attempts; attempt++) {
510
+ const req = await this.createRequest(url, createRequestInit());
511
+ lastReq = req;
512
+ let res: Response;
513
+ try {
514
+ res = await fetch(req);
515
+ } catch (err: unknown) {
516
+ if (!this.shouldRetryConnectionError(retryPolicy, normalizedMethod, attempt, replayableBody)) {
517
+ console.error(`Failed to connect to ${url}`, err);
518
+ this.throwError(new ConnectionError(req, toError(err)));
519
+ }
520
+ await this.waitBeforeRetry(retryPolicy, attempt);
521
+ continue;
522
+ }
523
+ this.handleFetchResponse(req, res);
524
+ if (this.shouldRetryResponse(retryPolicy, normalizedMethod, attempt, replayableBody, res)) {
525
+ await discardBody(res);
526
+ await this.waitBeforeRetry(retryPolicy, attempt, res);
527
+ continue;
528
+ }
529
+ return this.handleResponse<T>(req, res, params);
217
530
  }
218
531
 
219
- const init: RequestInit = {
220
- method: method,
221
- headers: headers,
222
- body: body,
532
+ if (lastReq) {
533
+ this.throwError(
534
+ new ConnectionError(lastReq, new Error(`Retry attempts exhausted for ${normalizedMethod} ${url}`)),
535
+ );
223
536
  }
224
- const req = await this.createRequest(url, init);
225
- return this._fetch.then(fetch => fetch(req).catch(err => {
226
- console.error(`Failed to connect to ${url}`, err);
227
- this.throwError(new ConnectionError(req, err));
228
- }).then(res => {
229
- return this.handleResponse(req, res, params);
230
- }));
537
+ throw new Error(`Retry attempts exhausted for ${normalizedMethod} ${url}`);
538
+ }
539
+
540
+ protected resolveRetryPolicy(params: IRequestParamsWithPayload | undefined): NormalizedRetryPolicy | undefined {
541
+ if (params?.reader === 'sse' || params?.retryPolicy === false || params?.retryPolicy === null) {
542
+ return undefined;
543
+ }
544
+ const requestPolicy = params?.retryPolicy;
545
+ const clientPolicy = this.getRetryPolicy();
546
+ if (!requestPolicy && !clientPolicy) {
547
+ return undefined;
548
+ }
549
+ const policy = normalizeRetryPolicy({
550
+ ...clientPolicy,
551
+ ...requestPolicy,
552
+ });
553
+ return policy.attempts > 1 ? policy : undefined;
554
+ }
555
+
556
+ private shouldRetryResponse(
557
+ policy: NormalizedRetryPolicy,
558
+ method: string,
559
+ attempt: number,
560
+ replayableBody: boolean,
561
+ res: Response,
562
+ ) {
563
+ const retryableStatus =
564
+ policy.statuses.has(res.status) || (policy.retryNonJsonServerErrors && isNonJsonServerError(res));
565
+ return attempt < policy.attempts - 1 && replayableBody && policy.methods.has(method) && retryableStatus;
566
+ }
567
+
568
+ private shouldRetryConnectionError(
569
+ policy: NormalizedRetryPolicy,
570
+ method: string,
571
+ attempt: number,
572
+ replayableBody: boolean,
573
+ ) {
574
+ return (
575
+ attempt < policy.attempts - 1 &&
576
+ replayableBody &&
577
+ policy.retryOnConnectionError &&
578
+ policy.methods.has(method)
579
+ );
580
+ }
581
+
582
+ private waitBeforeRetry(policy: NormalizedRetryPolicy, attempt: number, res?: Response) {
583
+ const delay = retryDelayMs(policy, attempt, res);
584
+ return new Promise<void>((resolve) => setTimeout(resolve, delay));
231
585
  }
232
586
 
233
587
  /**
@@ -246,10 +600,10 @@ export abstract class ClientBase {
246
600
  params: IRequestParamsWithPayload | undefined,
247
601
  onEvent: (event: ServerSentEvent) => void,
248
602
  ): Promise<ServerSentEvent | undefined> {
249
- const stream = await this.request(method, path, {
603
+ const stream = (await this.request(method, path, {
250
604
  ...params,
251
605
  reader: 'sse',
252
- }) as ReadableStream<ServerSentEvent>;
606
+ })) as ReadableStream<ServerSentEvent>;
253
607
 
254
608
  const reader = stream.getReader();
255
609
  let lastEvent: ServerSentEvent | undefined;
@@ -275,7 +629,6 @@ export abstract class ClientBase {
275
629
  * @returns
276
630
  */
277
631
  fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
278
- return this._fetch.then(fetch => fetch(input, init));
632
+ return this._fetch.then((fetch) => fetch(input, init));
279
633
  }
280
-
281
634
  }