@foundatiofx/fetchclient 1.1.0 → 1.2.0
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/esm/mod.js +83 -0
- package/esm/src/DefaultHelpers.js +12 -5
- package/esm/src/FetchClient.js +66 -41
- package/esm/src/FetchClientError.js +73 -0
- package/esm/src/FetchClientProvider.js +24 -0
- package/esm/src/RateLimitMiddleware.js +35 -1
- package/esm/src/ResponsePromise.js +163 -0
- package/esm/src/RetryMiddleware.js +179 -0
- package/esm/src/mocks/MockHistory.js +8 -0
- package/esm/src/mocks/MockRegistry.js +7 -0
- package/package.json +1 -1
- package/readme.md +8 -15
- package/script/mod.js +94 -1
- package/script/src/DefaultHelpers.js +13 -5
- package/script/src/FetchClient.js +66 -41
- package/script/src/FetchClientError.js +77 -0
- package/script/src/FetchClientProvider.js +24 -0
- package/script/src/RateLimitMiddleware.js +36 -0
- package/script/src/ResponsePromise.js +167 -0
- package/script/src/RetryMiddleware.js +184 -0
- package/script/src/mocks/MockHistory.js +8 -0
- package/script/src/mocks/MockRegistry.js +7 -0
- package/types/mod.d.ts +96 -0
- package/types/mod.d.ts.map +1 -1
- package/types/src/DefaultHelpers.d.ts +11 -5
- package/types/src/DefaultHelpers.d.ts.map +1 -1
- package/types/src/FetchClient.d.ts +31 -15
- package/types/src/FetchClient.d.ts.map +1 -1
- package/types/src/FetchClientError.d.ts +29 -0
- package/types/src/FetchClientError.d.ts.map +1 -0
- package/types/src/FetchClientProvider.d.ts +11 -0
- package/types/src/FetchClientProvider.d.ts.map +1 -1
- package/types/src/RateLimitMiddleware.d.ts +27 -0
- package/types/src/RateLimitMiddleware.d.ts.map +1 -1
- package/types/src/ResponsePromise.d.ts +93 -0
- package/types/src/ResponsePromise.d.ts.map +1 -0
- package/types/src/RetryMiddleware.d.ts +88 -0
- package/types/src/RetryMiddleware.d.ts.map +1 -0
- package/types/src/mocks/MockHistory.d.ts +1 -0
- package/types/src/mocks/MockHistory.d.ts.map +1 -1
- package/types/src/mocks/MockRegistry.d.ts +5 -0
- package/types/src/mocks/MockRegistry.d.ts.map +1 -1
- package/types/src/mocks/types.d.ts +2 -0
- package/types/src/mocks/types.d.ts.map +1 -1
- package/types/src/tests/RetryMiddleware.test.d.ts.map +1 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A promise that resolves to a FetchClientResponse with additional helper methods
|
|
3
|
+
* for parsing the response body. This allows for a fluent API similar to ky:
|
|
4
|
+
*
|
|
5
|
+
* @example
|
|
6
|
+
* ```typescript
|
|
7
|
+
* // Await to get the full response
|
|
8
|
+
* const response = await client.get("/api/users");
|
|
9
|
+
*
|
|
10
|
+
* // Or use helper methods for direct access to parsed body
|
|
11
|
+
* const users = await client.get("/api/users").json<User[]>();
|
|
12
|
+
* const html = await client.get("/page").text();
|
|
13
|
+
* const file = await client.get("/file").blob();
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export class ResponsePromise {
|
|
17
|
+
#responsePromise;
|
|
18
|
+
#options;
|
|
19
|
+
constructor(responsePromise, options) {
|
|
20
|
+
this.#responsePromise = responsePromise;
|
|
21
|
+
this.#options = options;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Implements PromiseLike interface so the ResponsePromise can be awaited.
|
|
25
|
+
*/
|
|
26
|
+
then(onfulfilled, onrejected) {
|
|
27
|
+
return this.#responsePromise.then(onfulfilled, onrejected);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Catches any errors from the response promise.
|
|
31
|
+
*/
|
|
32
|
+
catch(onrejected) {
|
|
33
|
+
return this.#responsePromise.catch(onrejected);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Executes a callback when the promise settles (fulfilled or rejected).
|
|
37
|
+
*/
|
|
38
|
+
finally(onfinally) {
|
|
39
|
+
return this.#responsePromise.finally(onfinally);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Parses the response body as JSON.
|
|
43
|
+
*
|
|
44
|
+
* If the response was already parsed as JSON (via getJSON, postJSON, etc.),
|
|
45
|
+
* returns the parsed data directly. Otherwise, parses the response body.
|
|
46
|
+
*
|
|
47
|
+
* @template TJson - The expected type of the JSON response
|
|
48
|
+
* @returns A promise that resolves to the parsed JSON
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* const user = await client.get("/api/user/1").json<User>();
|
|
53
|
+
* ```
|
|
54
|
+
*/
|
|
55
|
+
async json() {
|
|
56
|
+
const response = await this.#responsePromise;
|
|
57
|
+
// If the response already has parsed data (from getJSON, etc.), return it
|
|
58
|
+
if (response.data !== null && response.data !== undefined) {
|
|
59
|
+
return response.data;
|
|
60
|
+
}
|
|
61
|
+
// Otherwise, parse the response body as JSON
|
|
62
|
+
const data = await response.json();
|
|
63
|
+
// Apply reviver and date parsing if options are set
|
|
64
|
+
if (this.#options?.reviver || this.#options?.shouldParseDates) {
|
|
65
|
+
return this.#reviveJson(data);
|
|
66
|
+
}
|
|
67
|
+
return data;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Returns the response body as text.
|
|
71
|
+
*
|
|
72
|
+
* @returns A promise that resolves to the response text
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* const html = await client.get("/page").text();
|
|
77
|
+
* ```
|
|
78
|
+
*/
|
|
79
|
+
async text() {
|
|
80
|
+
const response = await this.#responsePromise;
|
|
81
|
+
return response.text();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Returns the response body as a Blob.
|
|
85
|
+
*
|
|
86
|
+
* @returns A promise that resolves to the response as a Blob
|
|
87
|
+
*
|
|
88
|
+
* @example
|
|
89
|
+
* ```typescript
|
|
90
|
+
* const imageBlob = await client.get("/image.png").blob();
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
async blob() {
|
|
94
|
+
const response = await this.#responsePromise;
|
|
95
|
+
return response.blob();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Returns the response body as an ArrayBuffer.
|
|
99
|
+
*
|
|
100
|
+
* @returns A promise that resolves to the response as an ArrayBuffer
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```typescript
|
|
104
|
+
* const buffer = await client.get("/file").arrayBuffer();
|
|
105
|
+
* ```
|
|
106
|
+
*/
|
|
107
|
+
async arrayBuffer() {
|
|
108
|
+
const response = await this.#responsePromise;
|
|
109
|
+
return response.arrayBuffer();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Returns the response body as FormData.
|
|
113
|
+
*
|
|
114
|
+
* @returns A promise that resolves to the response as FormData
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```typescript
|
|
118
|
+
* const formData = await client.get("/form").formData();
|
|
119
|
+
* ```
|
|
120
|
+
*/
|
|
121
|
+
async formData() {
|
|
122
|
+
const response = await this.#responsePromise;
|
|
123
|
+
return response.formData();
|
|
124
|
+
}
|
|
125
|
+
#reviveJson(data) {
|
|
126
|
+
if (data === null || data === undefined) {
|
|
127
|
+
return data;
|
|
128
|
+
}
|
|
129
|
+
if (Array.isArray(data)) {
|
|
130
|
+
return data.map((item) => this.#reviveJson(item));
|
|
131
|
+
}
|
|
132
|
+
if (typeof data === "object") {
|
|
133
|
+
const result = {};
|
|
134
|
+
for (const [key, value] of Object.entries(data)) {
|
|
135
|
+
result[key] = this.#reviveValue(key, this.#reviveJson(value));
|
|
136
|
+
}
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
return this.#reviveValue("", data);
|
|
140
|
+
}
|
|
141
|
+
#reviveValue(key, value) {
|
|
142
|
+
let revivedValue = value;
|
|
143
|
+
if (this.#options?.reviver) {
|
|
144
|
+
revivedValue = this.#options.reviver.call(this, key, revivedValue);
|
|
145
|
+
}
|
|
146
|
+
if (this.#options?.shouldParseDates) {
|
|
147
|
+
revivedValue = this.#tryParseDate(revivedValue);
|
|
148
|
+
}
|
|
149
|
+
return revivedValue;
|
|
150
|
+
}
|
|
151
|
+
#tryParseDate(value) {
|
|
152
|
+
if (typeof value !== "string") {
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
if (/^\d{4}-\d{2}-\d{2}/.test(value)) {
|
|
156
|
+
const date = new Date(value);
|
|
157
|
+
if (!isNaN(date.getTime())) {
|
|
158
|
+
return date;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return value;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default HTTP methods that are eligible for retry.
|
|
3
|
+
* These are idempotent methods that can be safely retried without side effects.
|
|
4
|
+
*/
|
|
5
|
+
const DEFAULT_RETRY_METHODS = [
|
|
6
|
+
"GET",
|
|
7
|
+
"HEAD",
|
|
8
|
+
"PUT",
|
|
9
|
+
"DELETE",
|
|
10
|
+
"OPTIONS",
|
|
11
|
+
"TRACE",
|
|
12
|
+
];
|
|
13
|
+
/**
|
|
14
|
+
* Default HTTP status codes that trigger a retry.
|
|
15
|
+
*/
|
|
16
|
+
const DEFAULT_RETRY_STATUS_CODES = [
|
|
17
|
+
408, // Request Timeout
|
|
18
|
+
413, // Payload Too Large (rate limiting)
|
|
19
|
+
429, // Too Many Requests
|
|
20
|
+
500, // Internal Server Error
|
|
21
|
+
502, // Bad Gateway
|
|
22
|
+
503, // Service Unavailable
|
|
23
|
+
504, // Gateway Timeout
|
|
24
|
+
];
|
|
25
|
+
/**
|
|
26
|
+
* Retry middleware that automatically retries failed requests with exponential backoff.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const provider = new FetchClientProvider();
|
|
31
|
+
* provider.useRetry({
|
|
32
|
+
* limit: 3,
|
|
33
|
+
* statusCodes: [500, 502, 503, 504],
|
|
34
|
+
* jitter: 0.1,
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* const client = provider.getFetchClient();
|
|
38
|
+
* const response = await client.getJSON('/api/data');
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
export class RetryMiddleware {
|
|
42
|
+
#options;
|
|
43
|
+
constructor(options) {
|
|
44
|
+
this.#options = {
|
|
45
|
+
limit: options?.limit ?? 2,
|
|
46
|
+
methods: (options?.methods ?? DEFAULT_RETRY_METHODS).map((m) => m.toUpperCase()),
|
|
47
|
+
statusCodes: options?.statusCodes ?? DEFAULT_RETRY_STATUS_CODES,
|
|
48
|
+
maxRetryAfter: options?.maxRetryAfter ?? Infinity,
|
|
49
|
+
backoffLimit: options?.backoffLimit ?? 30000,
|
|
50
|
+
jitter: options?.jitter ?? 0.1,
|
|
51
|
+
delay: options?.delay,
|
|
52
|
+
shouldRetry: options?.shouldRetry,
|
|
53
|
+
onRetry: options?.onRetry,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Creates the middleware function.
|
|
58
|
+
* @returns The middleware function
|
|
59
|
+
*/
|
|
60
|
+
middleware() {
|
|
61
|
+
return async (context, next) => {
|
|
62
|
+
const method = context.request.method.toUpperCase();
|
|
63
|
+
// Check if method is eligible for retry
|
|
64
|
+
if (!this.#options.methods.includes(method)) {
|
|
65
|
+
await next();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
let attemptNumber = 0;
|
|
69
|
+
while (true) {
|
|
70
|
+
// Store retry metadata in context for observability
|
|
71
|
+
if (attemptNumber > 0) {
|
|
72
|
+
context.retryAttempt = attemptNumber;
|
|
73
|
+
}
|
|
74
|
+
await next();
|
|
75
|
+
// If no response or we've exhausted retries, stop
|
|
76
|
+
if (!context.response || attemptNumber >= this.#options.limit) {
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
const response = context.response;
|
|
80
|
+
// Check if status code is retryable
|
|
81
|
+
if (!this.#options.statusCodes.includes(response.status)) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
// Check custom shouldRetry predicate
|
|
85
|
+
if (this.#options.shouldRetry) {
|
|
86
|
+
const shouldRetry = await this.#options.shouldRetry(response, attemptNumber);
|
|
87
|
+
if (!shouldRetry) {
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Calculate base delay
|
|
92
|
+
let delay = this.#calculateDelay(attemptNumber, response);
|
|
93
|
+
// Check Retry-After header
|
|
94
|
+
const retryAfterDelay = this.#parseRetryAfter(response);
|
|
95
|
+
if (retryAfterDelay !== null) {
|
|
96
|
+
// If Retry-After exceeds maxRetryAfter, don't retry
|
|
97
|
+
if (retryAfterDelay > this.#options.maxRetryAfter) {
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
// Use the larger of computed delay or Retry-After
|
|
101
|
+
delay = Math.max(delay, retryAfterDelay);
|
|
102
|
+
}
|
|
103
|
+
// Invoke onRetry callback
|
|
104
|
+
this.#options.onRetry?.(attemptNumber, response, delay);
|
|
105
|
+
// Wait before retry
|
|
106
|
+
await this.#sleep(delay);
|
|
107
|
+
// Reset response for next attempt
|
|
108
|
+
context.response = null;
|
|
109
|
+
attemptNumber++;
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Calculates the delay for a given attempt with exponential backoff and jitter.
|
|
115
|
+
*/
|
|
116
|
+
#calculateDelay(attemptNumber, response) {
|
|
117
|
+
let baseDelay;
|
|
118
|
+
if (this.#options.delay) {
|
|
119
|
+
baseDelay = this.#options.delay(attemptNumber, response);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
// Default exponential backoff: 1s, 2s, 4s, 8s, ...
|
|
123
|
+
baseDelay = Math.min(1000 * Math.pow(2, attemptNumber), this.#options.backoffLimit);
|
|
124
|
+
}
|
|
125
|
+
// Apply jitter
|
|
126
|
+
return this.#applyJitter(baseDelay);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Applies jitter to a delay value.
|
|
130
|
+
*/
|
|
131
|
+
#applyJitter(delay) {
|
|
132
|
+
if (this.#options.jitter <= 0) {
|
|
133
|
+
return delay;
|
|
134
|
+
}
|
|
135
|
+
const jitterRange = delay * this.#options.jitter;
|
|
136
|
+
// Random value between -jitterRange and +jitterRange
|
|
137
|
+
const jitter = (Math.random() * 2 - 1) * jitterRange;
|
|
138
|
+
return Math.max(0, Math.round(delay + jitter));
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Parses the Retry-After header and returns the delay in milliseconds.
|
|
142
|
+
* Supports both delta-seconds and HTTP-date formats.
|
|
143
|
+
*/
|
|
144
|
+
#parseRetryAfter(response) {
|
|
145
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
146
|
+
if (!retryAfter) {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
// Try parsing as seconds (integer)
|
|
150
|
+
const seconds = parseInt(retryAfter, 10);
|
|
151
|
+
if (!isNaN(seconds)) {
|
|
152
|
+
return seconds * 1000;
|
|
153
|
+
}
|
|
154
|
+
// Try parsing as HTTP-date
|
|
155
|
+
const date = Date.parse(retryAfter);
|
|
156
|
+
if (!isNaN(date)) {
|
|
157
|
+
return Math.max(0, date - Date.now());
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Sleep for the specified number of milliseconds.
|
|
163
|
+
*/
|
|
164
|
+
#sleep(ms) {
|
|
165
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Creates a retry middleware with the given options.
|
|
170
|
+
*
|
|
171
|
+
* @example
|
|
172
|
+
* ```typescript
|
|
173
|
+
* const client = new FetchClient();
|
|
174
|
+
* client.use(createRetryMiddleware({ limit: 3 }));
|
|
175
|
+
* ```
|
|
176
|
+
*/
|
|
177
|
+
export function createRetryMiddleware(options) {
|
|
178
|
+
return new RetryMiddleware(options).middleware();
|
|
179
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export class MockHistoryImpl {
|
|
5
5
|
#get = [];
|
|
6
|
+
#head = [];
|
|
6
7
|
#post = [];
|
|
7
8
|
#put = [];
|
|
8
9
|
#patch = [];
|
|
@@ -11,6 +12,9 @@ export class MockHistoryImpl {
|
|
|
11
12
|
get get() {
|
|
12
13
|
return [...this.#get];
|
|
13
14
|
}
|
|
15
|
+
get head() {
|
|
16
|
+
return [...this.#head];
|
|
17
|
+
}
|
|
14
18
|
get post() {
|
|
15
19
|
return [...this.#post];
|
|
16
20
|
}
|
|
@@ -35,6 +39,9 @@ export class MockHistoryImpl {
|
|
|
35
39
|
case "GET":
|
|
36
40
|
this.#get.push(request);
|
|
37
41
|
break;
|
|
42
|
+
case "HEAD":
|
|
43
|
+
this.#head.push(request);
|
|
44
|
+
break;
|
|
38
45
|
case "POST":
|
|
39
46
|
this.#post.push(request);
|
|
40
47
|
break;
|
|
@@ -54,6 +61,7 @@ export class MockHistoryImpl {
|
|
|
54
61
|
*/
|
|
55
62
|
clear() {
|
|
56
63
|
this.#get = [];
|
|
64
|
+
this.#head = [];
|
|
57
65
|
this.#post = [];
|
|
58
66
|
this.#put = [];
|
|
59
67
|
this.#patch = [];
|
|
@@ -40,6 +40,13 @@ export class MockRegistry {
|
|
|
40
40
|
onGet(url) {
|
|
41
41
|
return this.#addMock("GET", url);
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Creates a mock for HEAD requests matching the given URL.
|
|
45
|
+
* @param url - URL string or RegExp to match
|
|
46
|
+
*/
|
|
47
|
+
onHead(url) {
|
|
48
|
+
return this.#addMock("HEAD", url);
|
|
49
|
+
}
|
|
43
50
|
/**
|
|
44
51
|
* Creates a mock for POST requests matching the given URL.
|
|
45
52
|
* @param url - URL string or RegExp to match
|
package/package.json
CHANGED
package/readme.md
CHANGED
|
@@ -14,7 +14,7 @@ handling.
|
|
|
14
14
|
|
|
15
15
|
- **Typed JSON helpers** - `getJSON`, `postJSON`, `putJSON`, `patchJSON`,
|
|
16
16
|
`deleteJSON`
|
|
17
|
-
- **Two API styles** - Functional
|
|
17
|
+
- **Two API styles** - Functional or class-based - your choice
|
|
18
18
|
- **Response caching** - TTL-based caching with tags for grouped invalidation
|
|
19
19
|
- **Middleware** - Intercept requests/responses for logging, auth, transforms
|
|
20
20
|
- **Rate limiting** - Per-domain rate limits with automatic header detection
|
|
@@ -33,15 +33,13 @@ npm install @foundatiofx/fetchclient
|
|
|
33
33
|
|
|
34
34
|
FetchClient works two ways - pick whichever style you prefer:
|
|
35
35
|
|
|
36
|
-
### Functional API
|
|
36
|
+
### Functional API
|
|
37
37
|
|
|
38
38
|
```ts
|
|
39
39
|
import { getJSON, postJSON, setBaseUrl } from "@foundatiofx/fetchclient";
|
|
40
40
|
|
|
41
|
-
// Optional: configure once at startup
|
|
42
41
|
setBaseUrl("https://api.example.com");
|
|
43
42
|
|
|
44
|
-
// Use simple functions anywhere
|
|
45
43
|
const { data: users } = await getJSON<User[]>("/users");
|
|
46
44
|
const { data: created } = await postJSON<User>("/users", { name: "Alice" });
|
|
47
45
|
```
|
|
@@ -69,9 +67,6 @@ const client = new FetchClient({ baseUrl: "https://api.example.com" });
|
|
|
69
67
|
const { data } = await client.getJSON<User[]>("/users");
|
|
70
68
|
```
|
|
71
69
|
|
|
72
|
-
All styles share the same configuration - the functional API wraps a
|
|
73
|
-
[default provider](https://fetchclient.foundatio.dev/guide/provider#default-provider).
|
|
74
|
-
|
|
75
70
|
## Caching
|
|
76
71
|
|
|
77
72
|
```ts
|
|
@@ -112,12 +107,11 @@ usePerDomainRateLimit({
|
|
|
112
107
|
## Circuit Breaker
|
|
113
108
|
|
|
114
109
|
```ts
|
|
115
|
-
import {
|
|
110
|
+
import { useCircuitBreaker } from "@foundatiofx/fetchclient";
|
|
116
111
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
openDurationMs: 30000, // Stay open for 30 seconds
|
|
112
|
+
useCircuitBreaker({
|
|
113
|
+
failureThreshold: 5,
|
|
114
|
+
openDurationMs: 30000,
|
|
121
115
|
});
|
|
122
116
|
|
|
123
117
|
// When API fails repeatedly, circuit opens
|
|
@@ -133,10 +127,9 @@ import { MockRegistry } from "@foundatiofx/fetchclient/mocks";
|
|
|
133
127
|
const mocks = new MockRegistry();
|
|
134
128
|
mocks.onGet("/api/users").reply(200, [{ id: 1, name: "Alice" }]);
|
|
135
129
|
|
|
136
|
-
const
|
|
137
|
-
mocks.install(
|
|
130
|
+
const client = new FetchClient();
|
|
131
|
+
mocks.install(client);
|
|
138
132
|
|
|
139
|
-
const client = provider.getFetchClient();
|
|
140
133
|
const { data } = await client.getJSON("/api/users");
|
|
141
134
|
// data = [{ id: 1, name: "Alice" }]
|
|
142
135
|
```
|
package/script/mod.js
CHANGED
|
@@ -14,9 +14,13 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.createPerDomainCircuitBreakerMiddleware = exports.createCircuitBreakerMiddleware = exports.CircuitOpenError = exports.CircuitBreakerMiddleware = exports.circuitBreakerGroupByDomain = exports.CircuitBreaker = exports.FetchClientProvider = exports.defaultProviderInstance = exports.FetchClientCache = exports.ProblemDetails = exports.FetchClient = void 0;
|
|
17
|
+
exports.middleware = exports.RateLimiter = exports.rateLimiterGroupByDomain = exports.RateLimitMiddleware = exports.RateLimitError = exports.createRateLimitMiddleware = exports.createPerDomainRateLimitMiddleware = exports.RetryMiddleware = exports.createRetryMiddleware = exports.createPerDomainCircuitBreakerMiddleware = exports.createCircuitBreakerMiddleware = exports.CircuitOpenError = exports.CircuitBreakerMiddleware = exports.circuitBreakerGroupByDomain = exports.CircuitBreaker = exports.FetchClientProvider = exports.defaultProviderInstance = exports.FetchClientCache = exports.ProblemDetails = exports.ResponsePromise = exports.FetchClientError = exports.FetchClient = void 0;
|
|
18
18
|
var FetchClient_js_1 = require("./src/FetchClient.js");
|
|
19
19
|
Object.defineProperty(exports, "FetchClient", { enumerable: true, get: function () { return FetchClient_js_1.FetchClient; } });
|
|
20
|
+
var FetchClientError_js_1 = require("./src/FetchClientError.js");
|
|
21
|
+
Object.defineProperty(exports, "FetchClientError", { enumerable: true, get: function () { return FetchClientError_js_1.FetchClientError; } });
|
|
22
|
+
var ResponsePromise_js_1 = require("./src/ResponsePromise.js");
|
|
23
|
+
Object.defineProperty(exports, "ResponsePromise", { enumerable: true, get: function () { return ResponsePromise_js_1.ResponsePromise; } });
|
|
20
24
|
var ProblemDetails_js_1 = require("./src/ProblemDetails.js");
|
|
21
25
|
Object.defineProperty(exports, "ProblemDetails", { enumerable: true, get: function () { return ProblemDetails_js_1.ProblemDetails; } });
|
|
22
26
|
var FetchClientCache_js_1 = require("./src/FetchClientCache.js");
|
|
@@ -33,3 +37,92 @@ Object.defineProperty(exports, "CircuitBreakerMiddleware", { enumerable: true, g
|
|
|
33
37
|
Object.defineProperty(exports, "CircuitOpenError", { enumerable: true, get: function () { return CircuitBreakerMiddleware_js_1.CircuitOpenError; } });
|
|
34
38
|
Object.defineProperty(exports, "createCircuitBreakerMiddleware", { enumerable: true, get: function () { return CircuitBreakerMiddleware_js_1.createCircuitBreakerMiddleware; } });
|
|
35
39
|
Object.defineProperty(exports, "createPerDomainCircuitBreakerMiddleware", { enumerable: true, get: function () { return CircuitBreakerMiddleware_js_1.createPerDomainCircuitBreakerMiddleware; } });
|
|
40
|
+
var RetryMiddleware_js_1 = require("./src/RetryMiddleware.js");
|
|
41
|
+
Object.defineProperty(exports, "createRetryMiddleware", { enumerable: true, get: function () { return RetryMiddleware_js_1.createRetryMiddleware; } });
|
|
42
|
+
Object.defineProperty(exports, "RetryMiddleware", { enumerable: true, get: function () { return RetryMiddleware_js_1.RetryMiddleware; } });
|
|
43
|
+
var RateLimitMiddleware_js_1 = require("./src/RateLimitMiddleware.js");
|
|
44
|
+
Object.defineProperty(exports, "createPerDomainRateLimitMiddleware", { enumerable: true, get: function () { return RateLimitMiddleware_js_1.createPerDomainRateLimitMiddleware; } });
|
|
45
|
+
Object.defineProperty(exports, "createRateLimitMiddleware", { enumerable: true, get: function () { return RateLimitMiddleware_js_1.createRateLimitMiddleware; } });
|
|
46
|
+
Object.defineProperty(exports, "RateLimitError", { enumerable: true, get: function () { return RateLimitMiddleware_js_1.RateLimitError; } });
|
|
47
|
+
Object.defineProperty(exports, "RateLimitMiddleware", { enumerable: true, get: function () { return RateLimitMiddleware_js_1.RateLimitMiddleware; } });
|
|
48
|
+
var RateLimiter_js_1 = require("./src/RateLimiter.js");
|
|
49
|
+
Object.defineProperty(exports, "rateLimiterGroupByDomain", { enumerable: true, get: function () { return RateLimiter_js_1.groupByDomain; } });
|
|
50
|
+
Object.defineProperty(exports, "RateLimiter", { enumerable: true, get: function () { return RateLimiter_js_1.RateLimiter; } });
|
|
51
|
+
const RetryMiddleware_js_2 = require("./src/RetryMiddleware.js");
|
|
52
|
+
const RateLimitMiddleware_js_2 = require("./src/RateLimitMiddleware.js");
|
|
53
|
+
const CircuitBreakerMiddleware_js_2 = require("./src/CircuitBreakerMiddleware.js");
|
|
54
|
+
const DefaultHelpers_js_1 = require("./src/DefaultHelpers.js");
|
|
55
|
+
/**
|
|
56
|
+
* Convenience middleware factory functions for use with FetchClient.use()
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* import { FetchClient, middleware } from "@foundatiofx/fetchclient";
|
|
61
|
+
*
|
|
62
|
+
* const client = new FetchClient();
|
|
63
|
+
* client.use(
|
|
64
|
+
* middleware.retry({ limit: 3 }),
|
|
65
|
+
* middleware.rateLimit({ maxRequests: 100, windowSeconds: 60 }),
|
|
66
|
+
* middleware.circuitBreaker({ failureThreshold: 5 })
|
|
67
|
+
* );
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
exports.middleware = {
|
|
71
|
+
/** Retry failed requests with exponential backoff and jitter */
|
|
72
|
+
retry: RetryMiddleware_js_2.createRetryMiddleware,
|
|
73
|
+
/** Rate limit requests to prevent overwhelming servers */
|
|
74
|
+
rateLimit: RateLimitMiddleware_js_2.createRateLimitMiddleware,
|
|
75
|
+
/** Per-domain rate limit (each domain tracked separately) */
|
|
76
|
+
perDomainRateLimit: RateLimitMiddleware_js_2.createPerDomainRateLimitMiddleware,
|
|
77
|
+
/** Circuit breaker for fault tolerance */
|
|
78
|
+
circuitBreaker: CircuitBreakerMiddleware_js_2.createCircuitBreakerMiddleware,
|
|
79
|
+
/** Per-domain circuit breaker (each domain tracked separately) */
|
|
80
|
+
perDomainCircuitBreaker: CircuitBreakerMiddleware_js_2.createPerDomainCircuitBreakerMiddleware,
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Default export for convenient access to all HTTP methods.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* ```typescript
|
|
87
|
+
* import fc from "@foundatiofx/fetchclient";
|
|
88
|
+
*
|
|
89
|
+
* // Configure middleware
|
|
90
|
+
* fc.use(fc.middleware.retry({ limit: 3 }));
|
|
91
|
+
*
|
|
92
|
+
* // Use JSON methods (recommended)
|
|
93
|
+
* const { data: user } = await fc.getJSON<User>("/api/user/1");
|
|
94
|
+
* const { data: created } = await fc.postJSON<User>("/api/users", { name: "Alice" });
|
|
95
|
+
*
|
|
96
|
+
* // Or use fluent API for other response types
|
|
97
|
+
* const html = await fc.get("/page").text();
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
const fetchClient = {
|
|
101
|
+
/** Sends a GET request. Use `.json<T>()` for typed JSON response. */
|
|
102
|
+
get: (url, options) => (0, DefaultHelpers_js_1.useFetchClient)().get(url, options),
|
|
103
|
+
/** Sends a POST request. Use `.json<T>()` for typed JSON response. */
|
|
104
|
+
post: (url, body, options) => (0, DefaultHelpers_js_1.useFetchClient)().post(url, body, options),
|
|
105
|
+
/** Sends a PUT request. Use `.json<T>()` for typed JSON response. */
|
|
106
|
+
put: (url, body, options) => (0, DefaultHelpers_js_1.useFetchClient)().put(url, body, options),
|
|
107
|
+
/** Sends a PATCH request. Use `.json<T>()` for typed JSON response. */
|
|
108
|
+
patch: (url, body, options) => (0, DefaultHelpers_js_1.useFetchClient)().patch(url, body, options),
|
|
109
|
+
/** Sends a DELETE request. Use `.json<T>()` for typed JSON response. */
|
|
110
|
+
delete: (url, options) => (0, DefaultHelpers_js_1.useFetchClient)().delete(url, options),
|
|
111
|
+
/** Sends a HEAD request. */
|
|
112
|
+
head: (url, options) => (0, DefaultHelpers_js_1.useFetchClient)().head(url, options),
|
|
113
|
+
/** Sends a GET request and returns parsed JSON in response.data */
|
|
114
|
+
getJSON: DefaultHelpers_js_1.getJSON,
|
|
115
|
+
/** Sends a POST request and returns parsed JSON in response.data */
|
|
116
|
+
postJSON: DefaultHelpers_js_1.postJSON,
|
|
117
|
+
/** Sends a PUT request and returns parsed JSON in response.data */
|
|
118
|
+
putJSON: DefaultHelpers_js_1.putJSON,
|
|
119
|
+
/** Sends a PATCH request and returns parsed JSON in response.data */
|
|
120
|
+
patchJSON: DefaultHelpers_js_1.patchJSON,
|
|
121
|
+
/** Sends a DELETE request and returns parsed JSON in response.data */
|
|
122
|
+
deleteJSON: DefaultHelpers_js_1.deleteJSON,
|
|
123
|
+
/** Adds middleware to the default provider */
|
|
124
|
+
use: DefaultHelpers_js_1.useMiddleware,
|
|
125
|
+
/** Middleware factory functions */
|
|
126
|
+
middleware: exports.middleware,
|
|
127
|
+
};
|
|
128
|
+
exports.default = fetchClient;
|
|
@@ -7,6 +7,7 @@ exports.putJSON = putJSON;
|
|
|
7
7
|
exports.patchJSON = patchJSON;
|
|
8
8
|
exports.deleteJSON = deleteJSON;
|
|
9
9
|
exports.getCurrentProvider = getCurrentProvider;
|
|
10
|
+
exports.getCache = getCache;
|
|
10
11
|
exports.setCurrentProviderFunc = setCurrentProviderFunc;
|
|
11
12
|
exports.setBaseUrl = setBaseUrl;
|
|
12
13
|
exports.setAccessTokenFunc = setAccessTokenFunc;
|
|
@@ -30,7 +31,7 @@ function useFetchClient(options) {
|
|
|
30
31
|
* Sends a GET request to the specified URL using the default client and provider and returns the response as JSON.
|
|
31
32
|
* @param url - The URL to send the GET request to.
|
|
32
33
|
* @param options - Optional request options.
|
|
33
|
-
* @returns A promise that resolves to the response
|
|
34
|
+
* @returns A promise that resolves to the response with parsed JSON in `data`.
|
|
34
35
|
*/
|
|
35
36
|
function getJSON(url, options) {
|
|
36
37
|
return useFetchClient().getJSON(url, options);
|
|
@@ -42,7 +43,7 @@ function getJSON(url, options) {
|
|
|
42
43
|
* @param {string} url - The URL to send the request to.
|
|
43
44
|
* @param {object | string | FormData} [body] - The JSON payload or form data to send with the request.
|
|
44
45
|
* @param {RequestOptions} [options] - Additional options for the request.
|
|
45
|
-
* @returns
|
|
46
|
+
* @returns A promise that resolves to the response with parsed JSON in `data`.
|
|
46
47
|
*/
|
|
47
48
|
function postJSON(url, body, options) {
|
|
48
49
|
return useFetchClient().postJSON(url, body, options);
|
|
@@ -54,7 +55,7 @@ function postJSON(url, body, options) {
|
|
|
54
55
|
* @param {string} url - The URL to send the request to.
|
|
55
56
|
* @param {object | string} [body] - The JSON payload to send with the request.
|
|
56
57
|
* @param {RequestOptions} [options] - Additional options for the request.
|
|
57
|
-
* @returns
|
|
58
|
+
* @returns A promise that resolves to the response with parsed JSON in `data`.
|
|
58
59
|
*/
|
|
59
60
|
function putJSON(url, body, options) {
|
|
60
61
|
return useFetchClient().putJSON(url, body, options);
|
|
@@ -66,7 +67,7 @@ function putJSON(url, body, options) {
|
|
|
66
67
|
* @param {string} url - The URL to send the request to.
|
|
67
68
|
* @param {object | string} [body] - The JSON payload to send with the request.
|
|
68
69
|
* @param {RequestOptions} [options] - Additional options for the request.
|
|
69
|
-
* @returns
|
|
70
|
+
* @returns A promise that resolves to the response with parsed JSON in `data`.
|
|
70
71
|
*/
|
|
71
72
|
function patchJSON(url, body, options) {
|
|
72
73
|
return useFetchClient().patchJSON(url, body, options);
|
|
@@ -77,7 +78,7 @@ function patchJSON(url, body, options) {
|
|
|
77
78
|
* @template T - The type of the response data.
|
|
78
79
|
* @param {string} url - The URL to send the request to.
|
|
79
80
|
* @param {RequestOptions} [options] - Additional options for the request.
|
|
80
|
-
* @returns
|
|
81
|
+
* @returns A promise that resolves to the response with parsed JSON in `data`.
|
|
81
82
|
*/
|
|
82
83
|
function deleteJSON(url, options) {
|
|
83
84
|
return useFetchClient().deleteJSON(url, options);
|
|
@@ -92,6 +93,13 @@ function getCurrentProvider() {
|
|
|
92
93
|
}
|
|
93
94
|
return getCurrentProviderFunc() ?? FetchClientProvider_js_1.defaultInstance;
|
|
94
95
|
}
|
|
96
|
+
/**
|
|
97
|
+
* Gets the cache from the current provider.
|
|
98
|
+
* @returns The FetchClientCache instance.
|
|
99
|
+
*/
|
|
100
|
+
function getCache() {
|
|
101
|
+
return getCurrentProvider().cache;
|
|
102
|
+
}
|
|
95
103
|
/**
|
|
96
104
|
* Sets the function that retrieves the current FetchClientProvider using whatever scoping mechanism is available.
|
|
97
105
|
* @param getProviderFunc - The function that retrieves the current FetchClientProvider.
|