@vertesia/api-fetch-client 1.2.0 → 1.4.0-dev.20260614.160504Z
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 +25 -0
- package/lib/base.d.ts +135 -0
- package/lib/base.d.ts.map +1 -0
- package/lib/base.js +374 -0
- package/lib/base.js.map +1 -0
- package/lib/{types/client.d.ts → client.d.ts} +7 -8
- package/lib/client.d.ts.map +1 -0
- package/lib/{esm/client.js → client.js} +24 -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 +21 -25
- package/src/base.ts +297 -79
- package/src/client.ts +46 -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,61 @@
|
|
|
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
|
+
* Retry network failures thrown by fetch. Defaults to true.
|
|
23
|
+
*/
|
|
24
|
+
retryOnConnectionError?: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Initial backoff delay in milliseconds. Defaults to 250.
|
|
27
|
+
*/
|
|
28
|
+
baseDelayMs?: number;
|
|
29
|
+
/**
|
|
30
|
+
* Maximum backoff delay in milliseconds. Defaults to 4000.
|
|
31
|
+
*/
|
|
32
|
+
maxDelayMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Use full jitter for backoff delays. Defaults to true.
|
|
35
|
+
*/
|
|
36
|
+
jitter?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type NormalizedRetryPolicy = {
|
|
40
|
+
attempts: number;
|
|
41
|
+
methods: Set<string>;
|
|
42
|
+
statuses: Set<number>;
|
|
43
|
+
retryOnConnectionError: boolean;
|
|
44
|
+
baseDelayMs: number;
|
|
45
|
+
maxDelayMs: number;
|
|
46
|
+
jitter: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const DEFAULT_RETRY_METHODS = ['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE'];
|
|
50
|
+
const DEFAULT_RETRY_STATUSES = [502, 503, 504];
|
|
51
|
+
const DEFAULT_RETRY_ATTEMPTS = 3;
|
|
52
|
+
const DEFAULT_RETRY_BASE_DELAY_MS = 250;
|
|
53
|
+
const DEFAULT_RETRY_MAX_DELAY_MS = 4000;
|
|
54
|
+
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return value !== null && typeof value === 'object';
|
|
57
|
+
}
|
|
58
|
+
|
|
8
59
|
export interface IRequestParams {
|
|
9
60
|
query?: Record<string, IPrimitives> | null;
|
|
10
61
|
headers?: Record<string, string> | null;
|
|
@@ -17,12 +68,17 @@ export interface IRequestParams {
|
|
|
17
68
|
* If set to 'sse' the response will be treated as a server-sent event stream
|
|
18
69
|
* and the request will return a Promise<ReadableStream<ServerSentEvent>> object
|
|
19
70
|
*/
|
|
20
|
-
reader?: 'sse' | ((response: Response) =>
|
|
71
|
+
reader?: 'sse' | ((response: Response) => unknown);
|
|
21
72
|
/**
|
|
22
73
|
* Set to false to disable automatic JSON payload serialization
|
|
23
74
|
* 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
75
|
*/
|
|
25
|
-
jsonPayload?: boolean
|
|
76
|
+
jsonPayload?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* Opt-in retry policy for this request. Retries are disabled by default.
|
|
79
|
+
* Set to false to disable a client-level retry policy for this request.
|
|
80
|
+
*/
|
|
81
|
+
retryPolicy?: IRequestRetryPolicy | false | null;
|
|
26
82
|
}
|
|
27
83
|
|
|
28
84
|
export interface IRequestParamsWithPayload extends IRequestParams {
|
|
@@ -37,21 +93,74 @@ export function fetchPromise(fetchImpl?: FETCH_FN | Promise<FETCH_FN>) {
|
|
|
37
93
|
} else {
|
|
38
94
|
// install an error impl
|
|
39
95
|
return Promise.resolve(() => {
|
|
40
|
-
throw new Error('No Fetch implementation found')
|
|
96
|
+
throw new Error('No Fetch implementation found');
|
|
41
97
|
});
|
|
42
98
|
}
|
|
43
99
|
}
|
|
44
100
|
|
|
45
|
-
function isInvalidJsonPayload(payload:
|
|
46
|
-
return payload
|
|
101
|
+
function isInvalidJsonPayload(payload: unknown) {
|
|
102
|
+
return isRecord(payload) && payload.error === 'Not a valid JSON payload' && typeof payload.text === 'string';
|
|
47
103
|
}
|
|
48
104
|
|
|
49
|
-
|
|
105
|
+
function isReplayableBody(body: BodyInit | undefined) {
|
|
106
|
+
return !body || typeof ReadableStream === 'undefined' || !(body instanceof ReadableStream);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeRetryPolicy(policy: IRequestRetryPolicy): NormalizedRetryPolicy {
|
|
110
|
+
const attempts = Math.max(1, Math.floor(policy.attempts ?? DEFAULT_RETRY_ATTEMPTS));
|
|
111
|
+
const baseDelayMs = Math.max(0, policy.baseDelayMs ?? DEFAULT_RETRY_BASE_DELAY_MS);
|
|
112
|
+
const maxDelayMs = Math.max(baseDelayMs, policy.maxDelayMs ?? DEFAULT_RETRY_MAX_DELAY_MS);
|
|
113
|
+
return {
|
|
114
|
+
attempts,
|
|
115
|
+
methods: new Set((policy.methods ?? DEFAULT_RETRY_METHODS).map((method) => method.toUpperCase())),
|
|
116
|
+
statuses: new Set(policy.statuses ?? DEFAULT_RETRY_STATUSES),
|
|
117
|
+
retryOnConnectionError: policy.retryOnConnectionError ?? true,
|
|
118
|
+
baseDelayMs,
|
|
119
|
+
maxDelayMs,
|
|
120
|
+
jitter: policy.jitter ?? true,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function retryAfterDelayMs(res: Response): number | undefined {
|
|
125
|
+
const retryAfter = res.headers.get('retry-after');
|
|
126
|
+
if (!retryAfter) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
const seconds = Number(retryAfter);
|
|
130
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
131
|
+
return seconds * 1000;
|
|
132
|
+
}
|
|
133
|
+
const retryAt = Date.parse(retryAfter);
|
|
134
|
+
if (!Number.isNaN(retryAt)) {
|
|
135
|
+
return Math.max(0, retryAt - Date.now());
|
|
136
|
+
}
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
50
139
|
|
|
140
|
+
function retryDelayMs(policy: NormalizedRetryPolicy, attempt: number, res?: Response): number {
|
|
141
|
+
const retryAfter = res ? retryAfterDelayMs(res) : undefined;
|
|
142
|
+
const delay = retryAfter ?? Math.min(policy.maxDelayMs, policy.baseDelayMs * 2 ** attempt);
|
|
143
|
+
return policy.jitter ? Math.floor(Math.random() * delay) : delay;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toError(err: unknown): Error {
|
|
147
|
+
return err instanceof Error ? err : new Error(String(err));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function discardBody(res: Response) {
|
|
151
|
+
try {
|
|
152
|
+
await res.body?.cancel();
|
|
153
|
+
} catch {
|
|
154
|
+
// Ignore body cleanup failures while retrying the original request.
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export abstract class ClientBase {
|
|
51
159
|
_fetch: Promise<FETCH_FN>;
|
|
52
160
|
baseUrl: string;
|
|
53
161
|
errorFactory: (err: RequestError) => Error = (err) => err;
|
|
54
162
|
verboseErrors = true;
|
|
163
|
+
retryPolicy?: IRequestRetryPolicy;
|
|
55
164
|
|
|
56
165
|
abstract get headers(): Record<string, string>;
|
|
57
166
|
|
|
@@ -68,6 +177,15 @@ export abstract class ClientBase {
|
|
|
68
177
|
throw this.errorFactory(err);
|
|
69
178
|
}
|
|
70
179
|
|
|
180
|
+
withRetryPolicy(policy?: IRequestRetryPolicy | null): this {
|
|
181
|
+
this.retryPolicy = policy || undefined;
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getRetryPolicy(): IRequestRetryPolicy | undefined {
|
|
186
|
+
return this.retryPolicy;
|
|
187
|
+
}
|
|
188
|
+
|
|
71
189
|
/**
|
|
72
190
|
* Resolve a path to a full URL. If the path is already an absolute URL
|
|
73
191
|
* (starts with http:// or https://), it is returned as-is.
|
|
@@ -79,24 +197,24 @@ export abstract class ClientBase {
|
|
|
79
197
|
return removeTrailingSlash(join(this.baseUrl, path));
|
|
80
198
|
}
|
|
81
199
|
|
|
82
|
-
get(path: string, params?: IRequestParams) {
|
|
83
|
-
return this.request('GET', path, params);
|
|
200
|
+
get<T = unknown>(path: string, params?: IRequestParams): Promise<T> {
|
|
201
|
+
return this.request<T>('GET', path, params);
|
|
84
202
|
}
|
|
85
203
|
|
|
86
|
-
del(path: string, params?: IRequestParams) {
|
|
87
|
-
return this.request('DELETE', path, params);
|
|
204
|
+
del<T = unknown>(path: string, params?: IRequestParams): Promise<T> {
|
|
205
|
+
return this.request<T>('DELETE', path, params);
|
|
88
206
|
}
|
|
89
207
|
|
|
90
|
-
delete(path: string, params?: IRequestParams) {
|
|
208
|
+
delete(path: string, params?: IRequestParams): Promise<unknown> {
|
|
91
209
|
return this.request('DELETE', path, params);
|
|
92
210
|
}
|
|
93
211
|
|
|
94
|
-
post(path: string, params?: IRequestParamsWithPayload) {
|
|
95
|
-
return this.request('POST', path, params);
|
|
212
|
+
post<T = unknown>(path: string, params?: IRequestParamsWithPayload): Promise<T> {
|
|
213
|
+
return this.request<T>('POST', path, params);
|
|
96
214
|
}
|
|
97
215
|
|
|
98
|
-
put(path: string, params?: IRequestParamsWithPayload) {
|
|
99
|
-
return this.request('PUT', path, params);
|
|
216
|
+
put<T = unknown>(path: string, params?: IRequestParamsWithPayload): Promise<T> {
|
|
217
|
+
return this.request<T>('PUT', path, params);
|
|
100
218
|
}
|
|
101
219
|
|
|
102
220
|
/**
|
|
@@ -104,82 +222,90 @@ export abstract class ClientBase {
|
|
|
104
222
|
* @param text
|
|
105
223
|
* @returns
|
|
106
224
|
*/
|
|
107
|
-
jsonParse(text: string) {
|
|
225
|
+
jsonParse(text: string): unknown {
|
|
108
226
|
return JSON.parse(text);
|
|
109
227
|
}
|
|
110
228
|
|
|
111
229
|
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
230
|
+
* Can be overridden to create the request
|
|
231
|
+
* @param fetch
|
|
232
|
+
* @param url
|
|
233
|
+
* @param init
|
|
234
|
+
* @returns
|
|
235
|
+
*/
|
|
118
236
|
createRequest(url: string, init: RequestInit): Promise<Request> {
|
|
119
237
|
return Promise.resolve(new Request(url, init));
|
|
120
238
|
}
|
|
121
239
|
|
|
122
|
-
|
|
240
|
+
handleFetchResponse(_req: Request, _res: Response): void {}
|
|
241
|
+
|
|
242
|
+
createServerError(req: Request, res: Response, payload: unknown): RequestError {
|
|
123
243
|
const status = res.status;
|
|
124
|
-
let message =
|
|
244
|
+
let message = `Server Error: ${status}`;
|
|
125
245
|
if (payload) {
|
|
126
246
|
if (isInvalidJsonPayload(payload)) {
|
|
127
|
-
message += res.statusText ?
|
|
247
|
+
message += res.statusText ? ` ${res.statusText}` : '';
|
|
128
248
|
message += ': non-JSON response';
|
|
129
|
-
} else if (payload.message) {
|
|
249
|
+
} else if (isRecord(payload) && payload.message) {
|
|
130
250
|
message = String(payload.message);
|
|
131
|
-
} else if (payload.error) {
|
|
251
|
+
} else if (isRecord(payload) && payload.error) {
|
|
132
252
|
if (typeof payload.error === 'string') {
|
|
133
253
|
message = String(payload.error);
|
|
134
|
-
} else if (typeof payload.error.message === 'string') {
|
|
135
|
-
message =
|
|
254
|
+
} else if (isRecord(payload.error) && typeof payload.error.message === 'string') {
|
|
255
|
+
message = payload.error.message;
|
|
136
256
|
}
|
|
137
257
|
}
|
|
138
258
|
}
|
|
139
259
|
return new ServerError(message, req, res.status, payload, this.verboseErrors);
|
|
140
260
|
}
|
|
141
261
|
|
|
142
|
-
|
|
143
262
|
async readJSONPayload(res: Response) {
|
|
144
|
-
return res
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
263
|
+
return res
|
|
264
|
+
.text()
|
|
265
|
+
.then((text) => {
|
|
266
|
+
if (!text) {
|
|
267
|
+
return undefined;
|
|
268
|
+
} else {
|
|
269
|
+
try {
|
|
270
|
+
return this.jsonParse(text);
|
|
271
|
+
} catch (err: unknown) {
|
|
272
|
+
return {
|
|
273
|
+
status: res.status,
|
|
274
|
+
error: 'Not a valid JSON payload',
|
|
275
|
+
message: err instanceof Error ? err.message : String(err),
|
|
276
|
+
text: text,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
157
279
|
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
280
|
+
})
|
|
281
|
+
.catch((err: unknown) => {
|
|
282
|
+
return {
|
|
283
|
+
status: res.status,
|
|
284
|
+
error: 'Unable to load response content',
|
|
285
|
+
message: err instanceof Error ? err.message : String(err),
|
|
286
|
+
};
|
|
287
|
+
});
|
|
166
288
|
}
|
|
167
289
|
|
|
168
290
|
/**
|
|
169
291
|
* Subclasses You can override this to do something with the response
|
|
170
292
|
* @param res
|
|
171
293
|
*/
|
|
172
|
-
handleResponse
|
|
173
|
-
|
|
294
|
+
handleResponse<T = unknown>(
|
|
295
|
+
req: Request,
|
|
296
|
+
res: Response,
|
|
297
|
+
params: IRequestParamsWithPayload | undefined,
|
|
298
|
+
): T | Promise<T> {
|
|
299
|
+
if (params?.reader) {
|
|
174
300
|
if (params.reader === 'sse') {
|
|
175
|
-
return sse(res);
|
|
301
|
+
return sse(res) as T;
|
|
176
302
|
} else {
|
|
177
|
-
return params.reader.call(this, res)
|
|
303
|
+
return params.reader.call(this, res) as T | Promise<T>;
|
|
178
304
|
}
|
|
179
305
|
} else {
|
|
180
306
|
return this.readJSONPayload(res).then((payload) => {
|
|
181
307
|
if (res.ok) {
|
|
182
|
-
return payload;
|
|
308
|
+
return payload as T;
|
|
183
309
|
} else {
|
|
184
310
|
this.throwError(this.createServerError(req, res, payload));
|
|
185
311
|
}
|
|
@@ -187,10 +313,10 @@ export abstract class ClientBase {
|
|
|
187
313
|
}
|
|
188
314
|
}
|
|
189
315
|
|
|
190
|
-
async request(method: string, path: string, params?: IRequestParamsWithPayload) {
|
|
316
|
+
async request<T = unknown>(method: string, path: string, params?: IRequestParamsWithPayload): Promise<T> {
|
|
191
317
|
let url = this.getUrl(path);
|
|
192
318
|
if (params?.query) {
|
|
193
|
-
url +=
|
|
319
|
+
url += `?${buildQueryString(params.query)}`;
|
|
194
320
|
}
|
|
195
321
|
const headers = this.headers ? Object.assign({}, this.headers) : {};
|
|
196
322
|
const paramsHeaders = params?.headers;
|
|
@@ -205,29 +331,122 @@ export abstract class ClientBase {
|
|
|
205
331
|
if (params && params.jsonPayload === false) {
|
|
206
332
|
body = payload as BodyInit;
|
|
207
333
|
} else {
|
|
208
|
-
body =
|
|
334
|
+
body = typeof payload !== 'string' ? JSON.stringify(payload) : payload;
|
|
209
335
|
if (!('content-type' in headers)) {
|
|
210
336
|
headers['content-type'] = 'application/json';
|
|
211
337
|
}
|
|
212
338
|
}
|
|
213
339
|
}
|
|
214
340
|
// When using SSE reader, ensure the Accept header requests event-stream
|
|
215
|
-
if (params?.reader === 'sse'
|
|
216
|
-
headers
|
|
341
|
+
if (params?.reader === 'sse') {
|
|
342
|
+
headers.accept = 'text/event-stream';
|
|
217
343
|
}
|
|
218
344
|
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
345
|
+
const normalizedMethod = method.toUpperCase();
|
|
346
|
+
const createRequestInit = (): RequestInit => {
|
|
347
|
+
return {
|
|
348
|
+
method: normalizedMethod,
|
|
349
|
+
headers: Object.assign({}, headers),
|
|
350
|
+
body: body,
|
|
351
|
+
};
|
|
352
|
+
};
|
|
353
|
+
const retryPolicy = this.resolveRetryPolicy(params);
|
|
354
|
+
const fetch = await this._fetch;
|
|
355
|
+
|
|
356
|
+
if (!retryPolicy) {
|
|
357
|
+
const req = await this.createRequest(url, createRequestInit());
|
|
358
|
+
let res: Response;
|
|
359
|
+
try {
|
|
360
|
+
res = await fetch(req);
|
|
361
|
+
} catch (err: unknown) {
|
|
362
|
+
console.error(`Failed to connect to ${url}`, err);
|
|
363
|
+
this.throwError(new ConnectionError(req, toError(err)));
|
|
364
|
+
}
|
|
365
|
+
this.handleFetchResponse(req, res);
|
|
366
|
+
return this.handleResponse<T>(req, res, params);
|
|
223
367
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
368
|
+
|
|
369
|
+
const replayableBody = isReplayableBody(body);
|
|
370
|
+
let lastReq: Request | undefined;
|
|
371
|
+
for (let attempt = 0; attempt < retryPolicy.attempts; attempt++) {
|
|
372
|
+
const req = await this.createRequest(url, createRequestInit());
|
|
373
|
+
lastReq = req;
|
|
374
|
+
let res: Response;
|
|
375
|
+
try {
|
|
376
|
+
res = await fetch(req);
|
|
377
|
+
} catch (err: unknown) {
|
|
378
|
+
if (!this.shouldRetryConnectionError(retryPolicy, normalizedMethod, attempt, replayableBody)) {
|
|
379
|
+
console.error(`Failed to connect to ${url}`, err);
|
|
380
|
+
this.throwError(new ConnectionError(req, toError(err)));
|
|
381
|
+
}
|
|
382
|
+
await this.waitBeforeRetry(retryPolicy, attempt);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
this.handleFetchResponse(req, res);
|
|
386
|
+
if (this.shouldRetryResponse(retryPolicy, normalizedMethod, attempt, replayableBody, res)) {
|
|
387
|
+
await discardBody(res);
|
|
388
|
+
await this.waitBeforeRetry(retryPolicy, attempt, res);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
return this.handleResponse<T>(req, res, params);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (lastReq) {
|
|
395
|
+
this.throwError(
|
|
396
|
+
new ConnectionError(lastReq, new Error(`Retry attempts exhausted for ${normalizedMethod} ${url}`)),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
throw new Error(`Retry attempts exhausted for ${normalizedMethod} ${url}`);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
protected resolveRetryPolicy(params: IRequestParamsWithPayload | undefined): NormalizedRetryPolicy | undefined {
|
|
403
|
+
if (params?.reader === 'sse' || params?.retryPolicy === false || params?.retryPolicy === null) {
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
const requestPolicy = params?.retryPolicy;
|
|
407
|
+
const clientPolicy = this.getRetryPolicy();
|
|
408
|
+
if (!requestPolicy && !clientPolicy) {
|
|
409
|
+
return undefined;
|
|
410
|
+
}
|
|
411
|
+
const policy = normalizeRetryPolicy({
|
|
412
|
+
...clientPolicy,
|
|
413
|
+
...requestPolicy,
|
|
414
|
+
});
|
|
415
|
+
return policy.attempts > 1 ? policy : undefined;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
private shouldRetryResponse(
|
|
419
|
+
policy: NormalizedRetryPolicy,
|
|
420
|
+
method: string,
|
|
421
|
+
attempt: number,
|
|
422
|
+
replayableBody: boolean,
|
|
423
|
+
res: Response,
|
|
424
|
+
) {
|
|
425
|
+
return (
|
|
426
|
+
attempt < policy.attempts - 1 &&
|
|
427
|
+
replayableBody &&
|
|
428
|
+
policy.methods.has(method) &&
|
|
429
|
+
policy.statuses.has(res.status)
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
private shouldRetryConnectionError(
|
|
434
|
+
policy: NormalizedRetryPolicy,
|
|
435
|
+
method: string,
|
|
436
|
+
attempt: number,
|
|
437
|
+
replayableBody: boolean,
|
|
438
|
+
) {
|
|
439
|
+
return (
|
|
440
|
+
attempt < policy.attempts - 1 &&
|
|
441
|
+
replayableBody &&
|
|
442
|
+
policy.retryOnConnectionError &&
|
|
443
|
+
policy.methods.has(method)
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private waitBeforeRetry(policy: NormalizedRetryPolicy, attempt: number, res?: Response) {
|
|
448
|
+
const delay = retryDelayMs(policy, attempt, res);
|
|
449
|
+
return new Promise<void>((resolve) => setTimeout(resolve, delay));
|
|
231
450
|
}
|
|
232
451
|
|
|
233
452
|
/**
|
|
@@ -246,10 +465,10 @@ export abstract class ClientBase {
|
|
|
246
465
|
params: IRequestParamsWithPayload | undefined,
|
|
247
466
|
onEvent: (event: ServerSentEvent) => void,
|
|
248
467
|
): Promise<ServerSentEvent | undefined> {
|
|
249
|
-
const stream = await this.request(method, path, {
|
|
468
|
+
const stream = (await this.request(method, path, {
|
|
250
469
|
...params,
|
|
251
470
|
reader: 'sse',
|
|
252
|
-
}) as ReadableStream<ServerSentEvent>;
|
|
471
|
+
})) as ReadableStream<ServerSentEvent>;
|
|
253
472
|
|
|
254
473
|
const reader = stream.getReader();
|
|
255
474
|
let lastEvent: ServerSentEvent | undefined;
|
|
@@ -275,7 +494,6 @@ export abstract class ClientBase {
|
|
|
275
494
|
* @returns
|
|
276
495
|
*/
|
|
277
496
|
fetch(input: RequestInfo, init?: RequestInit): Promise<Response> {
|
|
278
|
-
return this._fetch.then(fetch => fetch(input, init));
|
|
497
|
+
return this._fetch.then((fetch) => fetch(input, init));
|
|
279
498
|
}
|
|
280
|
-
|
|
281
499
|
}
|
package/src/client.ts
CHANGED
|
@@ -1,13 +1,26 @@
|
|
|
1
|
-
import { ClientBase, FETCH_FN, IRequestParamsWithPayload } from
|
|
2
|
-
import { RequestError } from
|
|
1
|
+
import { ClientBase, type FETCH_FN, type IRequestParamsWithPayload } from './base.js';
|
|
2
|
+
import type { RequestError } from './errors.js';
|
|
3
3
|
|
|
4
4
|
function isAuthorizationHeaderSet(headers: HeadersInit | undefined): boolean {
|
|
5
5
|
if (!headers) return false;
|
|
6
|
-
return
|
|
6
|
+
return 'authorization' in headers;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
function isServerFetchRuntime(): boolean {
|
|
10
|
+
const runtime = globalThis as typeof globalThis & {
|
|
11
|
+
Bun?: unknown;
|
|
12
|
+
process?: { versions?: { bun?: string; node?: string } };
|
|
13
|
+
window?: unknown;
|
|
14
|
+
};
|
|
15
|
+
return (
|
|
16
|
+
typeof runtime.window === 'undefined' &&
|
|
17
|
+
(typeof runtime.process?.versions?.node === 'string' ||
|
|
18
|
+
typeof runtime.process?.versions?.bun === 'string' ||
|
|
19
|
+
typeof runtime.Bun !== 'undefined')
|
|
20
|
+
);
|
|
21
|
+
}
|
|
10
22
|
|
|
23
|
+
export class AbstractFetchClient<T extends AbstractFetchClient<T>> extends ClientBase {
|
|
11
24
|
headers: Record<string, string>;
|
|
12
25
|
_auth?: () => Promise<string>;
|
|
13
26
|
// callbacks useful to log requests and responses
|
|
@@ -23,7 +36,11 @@ export class AbstractFetchClient<T extends AbstractFetchClient<T>> extends Clien
|
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
get initialHeaders() {
|
|
26
|
-
|
|
39
|
+
const headers: Record<string, string> = { accept: 'application/json' };
|
|
40
|
+
if (isServerFetchRuntime()) {
|
|
41
|
+
headers['accept-encoding'] = 'br, gzip, deflate';
|
|
42
|
+
}
|
|
43
|
+
return headers;
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
/**
|
|
@@ -75,38 +92,32 @@ export class AbstractFetchClient<T extends AbstractFetchClient<T>> extends Clien
|
|
|
75
92
|
init.headers = headers;
|
|
76
93
|
const auth = await this._auth();
|
|
77
94
|
if (auth) {
|
|
78
|
-
init.headers
|
|
95
|
+
init.headers.authorization = auth;
|
|
79
96
|
}
|
|
80
97
|
}
|
|
81
98
|
this.response = undefined;
|
|
82
99
|
const request = await super.createRequest(url, init);
|
|
83
|
-
this.onRequest
|
|
100
|
+
this.onRequest?.(request);
|
|
84
101
|
return request;
|
|
85
102
|
}
|
|
86
103
|
|
|
87
|
-
|
|
104
|
+
handleFetchResponse(req: Request, res: Response): void {
|
|
88
105
|
this.response = res; // store last response
|
|
89
|
-
this.onResponse
|
|
90
|
-
return super.handleResponse(req, res, params);
|
|
106
|
+
this.onResponse?.(res, req);
|
|
91
107
|
}
|
|
92
|
-
|
|
93
108
|
}
|
|
94
109
|
|
|
95
|
-
export class FetchClient extends AbstractFetchClient<FetchClient> {
|
|
96
|
-
|
|
97
|
-
constructor(baseUrl: string, fetchImpl?: FETCH_FN | Promise<FETCH_FN>) {
|
|
98
|
-
super(baseUrl, fetchImpl);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
}
|
|
110
|
+
export class FetchClient extends AbstractFetchClient<FetchClient> {}
|
|
102
111
|
|
|
103
112
|
export abstract class ApiTopic extends ClientBase {
|
|
104
|
-
|
|
105
|
-
|
|
113
|
+
constructor(
|
|
114
|
+
public client: ClientBase,
|
|
115
|
+
basePath: string,
|
|
116
|
+
) {
|
|
106
117
|
//TODO we should refactor the way ClientBase and ApiTopic is created
|
|
107
118
|
// to avoid cloning all customizations
|
|
108
119
|
super(client.getUrl(basePath), client._fetch);
|
|
109
|
-
this.createServerError = client.createServerError
|
|
120
|
+
this.createServerError = client.createServerError;
|
|
110
121
|
this.errorFactory = client.errorFactory;
|
|
111
122
|
this.verboseErrors = client.verboseErrors;
|
|
112
123
|
}
|
|
@@ -115,12 +126,23 @@ export abstract class ApiTopic extends ClientBase {
|
|
|
115
126
|
return this.client.createRequest(url, init);
|
|
116
127
|
}
|
|
117
128
|
|
|
118
|
-
handleResponse
|
|
119
|
-
|
|
129
|
+
handleResponse<T = unknown>(
|
|
130
|
+
req: Request,
|
|
131
|
+
res: Response,
|
|
132
|
+
params: IRequestParamsWithPayload | undefined,
|
|
133
|
+
): T | Promise<T> {
|
|
134
|
+
return this.client.handleResponse<T>(req, res, params);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
handleFetchResponse(req: Request, res: Response): void {
|
|
138
|
+
this.client.handleFetchResponse(req, res);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getRetryPolicy() {
|
|
142
|
+
return this.client.getRetryPolicy();
|
|
120
143
|
}
|
|
121
144
|
|
|
122
145
|
get headers() {
|
|
123
146
|
return this.client.headers;
|
|
124
147
|
}
|
|
125
|
-
|
|
126
148
|
}
|