@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.
- package/README.md +51 -0
- package/lib/base.d.ts +169 -0
- package/lib/base.d.ts.map +1 -0
- package/lib/base.js +481 -0
- package/lib/base.js.map +1 -0
- package/lib/{types/client.d.ts → client.d.ts} +8 -8
- package/lib/client.d.ts.map +1 -0
- package/lib/{esm/client.js → client.js} +27 -11
- package/lib/client.js.map +1 -0
- package/lib/{types/errors.d.ts → errors.d.ts} +5 -5
- package/lib/errors.d.ts.map +1 -0
- package/lib/{esm/errors.js → errors.js} +22 -9
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +5 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +5 -0
- package/lib/index.js.map +1 -0
- package/lib/{types/sse → sse}/EventSourceParserStream.d.ts +4 -1
- package/lib/sse/EventSourceParserStream.d.ts.map +1 -0
- package/lib/{esm/sse → sse}/EventSourceParserStream.js +5 -4
- package/lib/sse/EventSourceParserStream.js.map +1 -0
- package/lib/sse/TextDecoderStream.d.ts.map +1 -0
- package/lib/{esm/sse → sse}/TextDecoderStream.js +2 -2
- package/lib/sse/TextDecoderStream.js.map +1 -0
- package/lib/{types/sse → sse}/index.d.ts +7 -2
- package/lib/sse/index.d.ts.map +1 -0
- package/lib/{esm/sse → sse}/index.js +3 -3
- package/lib/sse/index.js.map +1 -0
- package/lib/{types/utils.d.ts → utils.d.ts} +1 -1
- package/lib/utils.d.ts.map +1 -0
- package/lib/{esm/utils.js → utils.js} +3 -3
- package/lib/utils.js.map +1 -0
- package/package.json +22 -25
- package/src/base.ts +432 -79
- package/src/client.ts +50 -24
- package/src/errors.ts +28 -15
- package/src/index.ts +4 -4
- package/src/sse/EventSourceParserStream.ts +13 -10
- package/src/sse/TextDecoderStream.ts +16 -11
- package/src/sse/index.ts +14 -8
- package/src/utils.ts +5 -6
- package/lib/cjs/base.js +0 -240
- package/lib/cjs/base.js.map +0 -1
- package/lib/cjs/client.js +0 -115
- package/lib/cjs/client.js.map +0 -1
- package/lib/cjs/errors.js +0 -63
- package/lib/cjs/errors.js.map +0 -1
- package/lib/cjs/index.js +0 -21
- package/lib/cjs/index.js.map +0 -1
- package/lib/cjs/package.json +0 -3
- package/lib/cjs/sse/EventSourceParserStream.js +0 -41
- package/lib/cjs/sse/EventSourceParserStream.js.map +0 -1
- package/lib/cjs/sse/TextDecoderStream.js +0 -53
- package/lib/cjs/sse/TextDecoderStream.js.map +0 -1
- package/lib/cjs/sse/index.js +0 -27
- package/lib/cjs/sse/index.js.map +0 -1
- package/lib/cjs/utils.js +0 -38
- package/lib/cjs/utils.js.map +0 -1
- package/lib/esm/base.js +0 -235
- package/lib/esm/base.js.map +0 -1
- package/lib/esm/client.js.map +0 -1
- package/lib/esm/errors.js.map +0 -1
- package/lib/esm/index.js +0 -5
- package/lib/esm/index.js.map +0 -1
- package/lib/esm/sse/EventSourceParserStream.js.map +0 -1
- package/lib/esm/sse/TextDecoderStream.js.map +0 -1
- package/lib/esm/sse/index.js.map +0 -1
- package/lib/esm/utils.js.map +0 -1
- package/lib/tsconfig.tsbuildinfo +0 -1
- package/lib/types/base.d.ts +0 -83
- package/lib/types/base.d.ts.map +0 -1
- package/lib/types/client.d.ts.map +0 -1
- package/lib/types/errors.d.ts.map +0 -1
- package/lib/types/index.d.ts +0 -5
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/sse/EventSourceParserStream.d.ts.map +0 -1
- package/lib/types/sse/TextDecoderStream.d.ts.map +0 -1
- package/lib/types/sse/index.d.ts.map +0 -1
- package/lib/types/utils.d.ts.map +0 -1
- /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
|
|
2
|
-
import {
|
|
3
|
-
import { buildQueryString, join, removeTrailingSlash } from
|
|
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) =>
|
|
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:
|
|
46
|
-
return payload
|
|
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
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 =
|
|
368
|
+
let message = `Server Error: ${status}`;
|
|
125
369
|
if (payload) {
|
|
126
370
|
if (isInvalidJsonPayload(payload)) {
|
|
127
|
-
message += 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 =
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
173
|
-
|
|
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 +=
|
|
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 =
|
|
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'
|
|
216
|
-
headers
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
532
|
+
if (lastReq) {
|
|
533
|
+
this.throwError(
|
|
534
|
+
new ConnectionError(lastReq, new Error(`Retry attempts exhausted for ${normalizedMethod} ${url}`)),
|
|
535
|
+
);
|
|
223
536
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
return
|
|
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
|
}
|