@mfeflow/http 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +58 -2
- package/dist/error.d.ts +31 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +250 -0
- package/dist/retry.d.ts +21 -0
- package/package.json +55 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sanjeev Sharma
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @mfeflow/http
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A small fetch client over [`@mfeflow/url-factory`](../url-factory) endpoints. Base URLs come from the runtime config, so one build runs in every environment. It adds an async bearer token, a timeout, retries with backoff for idempotent methods, typed JSON and an error that carries the status and body but no URL.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm add @mfeflow/http @mfeflow/url-factory
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
In a project made with mfeflow, `mfeflow add http` writes `src/api/client.ts` in every app.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { createUrlFactory, defineEndpoints } from '@mfeflow/url-factory';
|
|
17
|
+
import { createHttpClient, isHttpError } from '@mfeflow/http';
|
|
18
|
+
|
|
19
|
+
const endpoints = defineEndpoints({
|
|
20
|
+
listProducts: { service: 'api', path: '/products' },
|
|
21
|
+
getProduct: { service: 'api', path: '/products/:sku' },
|
|
22
|
+
addOrder: { service: 'api', path: '/orders' },
|
|
23
|
+
});
|
|
24
|
+
const apiUrl = createUrlFactory(endpoints, (service) => config.services[service]);
|
|
25
|
+
|
|
26
|
+
const api = createHttpClient<typeof endpoints, { listProducts: Product[]; getProduct: Product }>({
|
|
27
|
+
url: apiUrl,
|
|
28
|
+
getToken: () => session.accessToken(), // may be async; return undefined when signed out
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const mugs = await api.get('listProducts', { query: { q: 'mug' } }); // Product[]
|
|
32
|
+
await api.post('addOrder', { json: { sku: 'A1' } });
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
await api.get('getProduct', { params: { sku: 'nope' } });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (isHttpError(error) && error.status === 404) showNotFound(error.body);
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
A second type argument says what each endpoint answers with; `parse` on a call checks the body and sets the type instead.
|
|
42
|
+
|
|
43
|
+
## What it does
|
|
44
|
+
|
|
45
|
+
- **Retries** GET, HEAD, OPTIONS, PUT and DELETE (never POST or PATCH) on a network error, a timeout and 408, 425, 429, 502, 503 and 504: twice by default, with exponential backoff and jitter. A `Retry-After` longer than the longest wait is not waited for. `retry: false` turns it off.
|
|
46
|
+
- **Times out** each attempt after 10 seconds of waiting for the headers (`timeoutMs`); a slow body is not cut off. Your own `AbortSignal` is passed through; aborting it throws your reason and is not retried.
|
|
47
|
+
- **Errors** are `HttpError` with `kind` (`status`, `network`, `timeout`, `parse`, `auth`), `status`, `body` (parsed JSON, or text cut to 4096 characters), `headers` and `attempts`. The message holds the method, the endpoint key and what happened: no URL and no body, so ids and emails stay out of logs by default.
|
|
48
|
+
- **Does not add `traceparent`.** `@mfeflow/telemetry` instruments `fetch` for that, and this client calls the global `fetch` at request time, so the instrumentation applies however late it was installed.
|
|
49
|
+
|
|
50
|
+
## Module Federation sharing
|
|
51
|
+
|
|
52
|
+
Bucket: **stateless**. Nothing here holds state shared between micro-frontends, so each remote can bundle its own copy and different versions can run side by side. No sharing config is needed.
|
|
53
|
+
|
|
54
|
+
## Links
|
|
55
|
+
|
|
56
|
+
- [mfeflow repository](https://github.com/mfeflow/mfeflow) and its README
|
|
57
|
+
- [Documentation](https://mfeflow.github.io/mfeflow/)
|
|
58
|
+
|
|
59
|
+
MIT licensed.
|
package/dist/error.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type HttpErrorKind = 'status' | 'network' | 'timeout' | 'parse' | 'auth';
|
|
2
|
+
export interface HttpErrorInit {
|
|
3
|
+
kind: HttpErrorKind;
|
|
4
|
+
method: string;
|
|
5
|
+
/** The endpoint key, for example `getOrder`. */
|
|
6
|
+
endpoint: string;
|
|
7
|
+
status?: number | undefined;
|
|
8
|
+
body?: unknown;
|
|
9
|
+
headers?: Headers | undefined;
|
|
10
|
+
attempts: number;
|
|
11
|
+
cause?: unknown;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A request that failed. The message names the method, the endpoint key and what happened, and
|
|
15
|
+
* nothing else: no URL (it can hold ids or emails) and no body. Read `status` and `body` to
|
|
16
|
+
* handle a failure, and decide yourself what to log.
|
|
17
|
+
*/
|
|
18
|
+
export declare class HttpError extends Error {
|
|
19
|
+
readonly kind: HttpErrorKind;
|
|
20
|
+
readonly method: string;
|
|
21
|
+
readonly endpoint: string;
|
|
22
|
+
/** The HTTP status; undefined when no response arrived. */
|
|
23
|
+
readonly status: number | undefined;
|
|
24
|
+
/** The response body: parsed when it was JSON, otherwise text cut to 4096 characters. */
|
|
25
|
+
readonly body: unknown;
|
|
26
|
+
readonly headers: Headers | undefined;
|
|
27
|
+
/** How many times the request was sent. */
|
|
28
|
+
readonly attempts: number;
|
|
29
|
+
constructor(init: HttpErrorInit, statusText?: string);
|
|
30
|
+
}
|
|
31
|
+
export declare function isHttpError(value: unknown): value is HttpError;
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Endpoints, PathParams, Query, UrlFactory } from '@mfeflow/url-factory';
|
|
2
|
+
import { type RetryOptions } from './retry.js';
|
|
3
|
+
export { HttpError, isHttpError, type HttpErrorKind } from './error.js';
|
|
4
|
+
export type { RetryOptions } from './retry.js';
|
|
5
|
+
type Fetch = (input: string, init: RequestInit) => Promise<Response>;
|
|
6
|
+
export interface HttpClientOptions<E extends Endpoints> {
|
|
7
|
+
/** The URL factory from `src/api/endpoints.ts`: it knows each service's base URL from the runtime config. */
|
|
8
|
+
url: UrlFactory<E>;
|
|
9
|
+
/**
|
|
10
|
+
* The bearer token, asked for before every attempt so a refreshed token is picked up. Return
|
|
11
|
+
* undefined for a signed-out visitor and no header is sent.
|
|
12
|
+
*/
|
|
13
|
+
getToken?: () => string | undefined | Promise<string | undefined>;
|
|
14
|
+
/** Sent with every request; a request's own headers win. */
|
|
15
|
+
headers?: Record<string, string>;
|
|
16
|
+
/** Per attempt, in ms. Default 10 000. */
|
|
17
|
+
timeoutMs?: number;
|
|
18
|
+
/** Set false to never retry. */
|
|
19
|
+
retry?: RetryOptions | false;
|
|
20
|
+
credentials?: RequestCredentials;
|
|
21
|
+
/**
|
|
22
|
+
* Defaults to the global `fetch`, looked up on every request, so instrumentation that wraps it
|
|
23
|
+
* after this client was created (OpenTelemetry's trace headers) still applies.
|
|
24
|
+
*/
|
|
25
|
+
fetch?: Fetch;
|
|
26
|
+
}
|
|
27
|
+
interface Extras<T> {
|
|
28
|
+
headers?: Record<string, string>;
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
retry?: RetryOptions | false;
|
|
32
|
+
credentials?: RequestCredentials;
|
|
33
|
+
/** Checks or reshapes the parsed body; its return type becomes the result type. */
|
|
34
|
+
parse?: (raw: unknown) => T;
|
|
35
|
+
}
|
|
36
|
+
type ReadArgs<P extends string, T> = [PathParams<P>] extends [never] ? [options?: {
|
|
37
|
+
query?: Query;
|
|
38
|
+
} & Extras<T>] : [
|
|
39
|
+
options: {
|
|
40
|
+
params: Readonly<Record<PathParams<P>, string | number>>;
|
|
41
|
+
query?: Query;
|
|
42
|
+
} & Extras<T>
|
|
43
|
+
];
|
|
44
|
+
type WriteArgs<P extends string, T> = [PathParams<P>] extends [never] ? [options?: {
|
|
45
|
+
query?: Query;
|
|
46
|
+
json?: unknown;
|
|
47
|
+
} & Extras<T>] : [
|
|
48
|
+
options: {
|
|
49
|
+
params: Readonly<Record<PathParams<P>, string | number>>;
|
|
50
|
+
query?: Query;
|
|
51
|
+
json?: unknown;
|
|
52
|
+
} & Extras<T>
|
|
53
|
+
];
|
|
54
|
+
/** What each endpoint answers with, by endpoint key. Unlisted endpoints answer `unknown`. */
|
|
55
|
+
export type ResponseMap<E extends Endpoints> = Partial<Record<keyof E & string, unknown>>;
|
|
56
|
+
type ResponseOf<R, K> = K extends keyof R ? R[K] : unknown;
|
|
57
|
+
export interface HttpClient<E extends Endpoints, R extends ResponseMap<E> = ResponseMap<E>> {
|
|
58
|
+
get: <K extends keyof E & string, T = ResponseOf<R, K>>(key: K, ...args: ReadArgs<E[K]['path'], T>) => Promise<T>;
|
|
59
|
+
post: <K extends keyof E & string, T = ResponseOf<R, K>>(key: K, ...args: WriteArgs<E[K]['path'], T>) => Promise<T>;
|
|
60
|
+
put: <K extends keyof E & string, T = ResponseOf<R, K>>(key: K, ...args: WriteArgs<E[K]['path'], T>) => Promise<T>;
|
|
61
|
+
patch: <K extends keyof E & string, T = ResponseOf<R, K>>(key: K, ...args: WriteArgs<E[K]['path'], T>) => Promise<T>;
|
|
62
|
+
delete: <K extends keyof E & string, T = ResponseOf<R, K>>(key: K, ...args: ReadArgs<E[K]['path'], T>) => Promise<T>;
|
|
63
|
+
/** The raw `Response` of a successful request, for streams and files. Errors still throw `HttpError`. */
|
|
64
|
+
response: <K extends keyof E & string>(method: string, key: K, ...args: WriteArgs<E[K]['path'], unknown>) => Promise<Response>;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* A fetch client over your endpoints. Retries only idempotent methods (GET, HEAD, OPTIONS, PUT,
|
|
68
|
+
* DELETE) on network errors, timeouts and 408, 425, 429, 502, 503, 504, with backoff. Trace headers
|
|
69
|
+
* are not added here; `@mfeflow/telemetry` instruments `fetch` for that.
|
|
70
|
+
*
|
|
71
|
+
* ```ts
|
|
72
|
+
* const api = createHttpClient<typeof endpoints, { listProducts: Product[] }>({ url: apiUrl });
|
|
73
|
+
* const products = await api.get('listProducts', { query: { page: 1 } });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
export declare function createHttpClient<E extends Endpoints, R extends ResponseMap<E> = ResponseMap<E>>(options: HttpClientOptions<E>): HttpClient<E, R>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
const WHAT = {
|
|
2
|
+
status: (init, text)=>`answered ${String(init.status ?? 0)}${'' === text ? '' : ` ${text}`}`,
|
|
3
|
+
network: ()=>'could not reach the server',
|
|
4
|
+
timeout: ()=>'timed out',
|
|
5
|
+
auth: ()=>'could not get a token',
|
|
6
|
+
parse: (init)=>`answered ${String(init.status ?? 0)} with a body that is not valid JSON`
|
|
7
|
+
};
|
|
8
|
+
class HttpError extends Error {
|
|
9
|
+
kind;
|
|
10
|
+
method;
|
|
11
|
+
endpoint;
|
|
12
|
+
status;
|
|
13
|
+
body;
|
|
14
|
+
headers;
|
|
15
|
+
attempts;
|
|
16
|
+
constructor(init, statusText = ''){
|
|
17
|
+
super(`${init.method} ${init.endpoint} ${WHAT[init.kind](init, statusText)}`, void 0 === init.cause ? void 0 : {
|
|
18
|
+
cause: init.cause
|
|
19
|
+
});
|
|
20
|
+
this.name = 'HttpError';
|
|
21
|
+
this.kind = init.kind;
|
|
22
|
+
this.method = init.method;
|
|
23
|
+
this.endpoint = init.endpoint;
|
|
24
|
+
this.status = init.status;
|
|
25
|
+
this.body = init.body;
|
|
26
|
+
this.headers = init.headers;
|
|
27
|
+
this.attempts = init.attempts;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function isHttpError(value) {
|
|
31
|
+
return value instanceof HttpError;
|
|
32
|
+
}
|
|
33
|
+
const DEFAULT_RETRY = {
|
|
34
|
+
retries: 2,
|
|
35
|
+
baseDelayMs: 200,
|
|
36
|
+
maxDelayMs: 2000
|
|
37
|
+
};
|
|
38
|
+
const IDEMPOTENT = new Set([
|
|
39
|
+
'GET',
|
|
40
|
+
'HEAD',
|
|
41
|
+
'OPTIONS',
|
|
42
|
+
'PUT',
|
|
43
|
+
'DELETE'
|
|
44
|
+
]);
|
|
45
|
+
const RETRY_STATUS = new Set([
|
|
46
|
+
408,
|
|
47
|
+
425,
|
|
48
|
+
429,
|
|
49
|
+
502,
|
|
50
|
+
503,
|
|
51
|
+
504
|
|
52
|
+
]);
|
|
53
|
+
const canRetryMethod = (method)=>IDEMPOTENT.has(method);
|
|
54
|
+
const canRetryStatus = (status)=>RETRY_STATUS.has(status);
|
|
55
|
+
function retryAfterMs(headers, now = Date.now()) {
|
|
56
|
+
const value = headers.get('retry-after')?.trim();
|
|
57
|
+
if (!value) return;
|
|
58
|
+
if (/^\d+$/.test(value)) return 1000 * Number(value);
|
|
59
|
+
const at = Date.parse(value);
|
|
60
|
+
return Number.isNaN(at) ? void 0 : Math.max(0, at - now);
|
|
61
|
+
}
|
|
62
|
+
function backoffMs(attempt, options, random = Math.random) {
|
|
63
|
+
return Math.round(random() * Math.min(options.maxDelayMs, options.baseDelayMs * 2 ** attempt));
|
|
64
|
+
}
|
|
65
|
+
function sleep(ms, signal) {
|
|
66
|
+
return new Promise((resolve, reject)=>{
|
|
67
|
+
if (signal?.aborted) return void reject(signal.reason);
|
|
68
|
+
const onAbort = ()=>{
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
reject(signal?.reason);
|
|
71
|
+
};
|
|
72
|
+
const timer = setTimeout(()=>{
|
|
73
|
+
signal?.removeEventListener('abort', onAbort);
|
|
74
|
+
resolve();
|
|
75
|
+
}, ms);
|
|
76
|
+
signal?.addEventListener('abort', onAbort, {
|
|
77
|
+
once: true
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const BODY_LIMIT = 4096;
|
|
82
|
+
const DEFAULT_TIMEOUT_MS = 10000;
|
|
83
|
+
async function readBody(response) {
|
|
84
|
+
const text = await response.text().catch(()=>'');
|
|
85
|
+
if ('' === text) return;
|
|
86
|
+
if (response.headers.get('content-type')?.includes('json')) try {
|
|
87
|
+
return JSON.parse(text);
|
|
88
|
+
} catch {}
|
|
89
|
+
return text.slice(0, BODY_LIMIT);
|
|
90
|
+
}
|
|
91
|
+
function createHttpClient(options) {
|
|
92
|
+
const urlFor = options.url;
|
|
93
|
+
const doFetch = options.fetch ?? ((input, init)=>globalThis.fetch(input, init));
|
|
94
|
+
function headersFor(extra, hasBody) {
|
|
95
|
+
const headers = new Headers({
|
|
96
|
+
accept: 'application/json'
|
|
97
|
+
});
|
|
98
|
+
for (const source of [
|
|
99
|
+
options.headers,
|
|
100
|
+
extra
|
|
101
|
+
])for (const [name, value] of Object.entries(source ?? {}))headers.set(name, value);
|
|
102
|
+
if (hasBody && !headers.has('content-type')) headers.set('content-type', 'application/json');
|
|
103
|
+
if (headers.has('authorization') || !options.getToken) return headers;
|
|
104
|
+
const withToken = (token)=>{
|
|
105
|
+
if (token) headers.set('authorization', `Bearer ${token}`);
|
|
106
|
+
return headers;
|
|
107
|
+
};
|
|
108
|
+
const token = options.getToken();
|
|
109
|
+
return 'object' == typeof token ? token.then(withToken) : withToken(token);
|
|
110
|
+
}
|
|
111
|
+
async function attempt(method, key, url, request, count) {
|
|
112
|
+
const hasBody = void 0 !== request.json;
|
|
113
|
+
const fail = (kind, cause)=>new HttpError({
|
|
114
|
+
kind,
|
|
115
|
+
method,
|
|
116
|
+
endpoint: key,
|
|
117
|
+
attempts: count,
|
|
118
|
+
cause
|
|
119
|
+
});
|
|
120
|
+
let headers;
|
|
121
|
+
try {
|
|
122
|
+
const ready = headersFor(request.headers, hasBody);
|
|
123
|
+
headers = ready instanceof Headers ? ready : await ready;
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
throw fail('auth', cause);
|
|
126
|
+
}
|
|
127
|
+
const timer = new AbortController();
|
|
128
|
+
const wait = setTimeout(()=>{
|
|
129
|
+
timer.abort();
|
|
130
|
+
}, request.timeoutMs ?? options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
131
|
+
const signal = request.signal ? AbortSignal.any([
|
|
132
|
+
request.signal,
|
|
133
|
+
timer.signal
|
|
134
|
+
]) : timer.signal;
|
|
135
|
+
try {
|
|
136
|
+
const credentials = request.credentials ?? options.credentials;
|
|
137
|
+
return await doFetch(url, {
|
|
138
|
+
method,
|
|
139
|
+
headers,
|
|
140
|
+
signal,
|
|
141
|
+
...hasBody ? {
|
|
142
|
+
body: JSON.stringify(request.json)
|
|
143
|
+
} : {},
|
|
144
|
+
...credentials ? {
|
|
145
|
+
credentials
|
|
146
|
+
} : {}
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (request.signal?.aborted) throw request.signal.reason;
|
|
150
|
+
throw fail(timer.signal.aborted ? 'timeout' : 'network', error);
|
|
151
|
+
} finally{
|
|
152
|
+
clearTimeout(wait);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function once(method, key, url, request, count) {
|
|
156
|
+
let response;
|
|
157
|
+
try {
|
|
158
|
+
response = await attempt(method, key, url, request, count);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error instanceof HttpError) return {
|
|
161
|
+
failure: error,
|
|
162
|
+
retryable: 'auth' !== error.kind,
|
|
163
|
+
asked: void 0
|
|
164
|
+
};
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
if (response.ok) return {
|
|
168
|
+
ok: response
|
|
169
|
+
};
|
|
170
|
+
const failure = new HttpError({
|
|
171
|
+
kind: 'status',
|
|
172
|
+
method,
|
|
173
|
+
endpoint: key,
|
|
174
|
+
status: response.status,
|
|
175
|
+
body: await readBody(response),
|
|
176
|
+
headers: response.headers,
|
|
177
|
+
attempts: count
|
|
178
|
+
}, response.statusText);
|
|
179
|
+
return {
|
|
180
|
+
failure,
|
|
181
|
+
retryable: canRetryStatus(response.status),
|
|
182
|
+
asked: retryAfterMs(response.headers)
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
async function send(method, key, request) {
|
|
186
|
+
const url = urlFor(key, {
|
|
187
|
+
params: request.params,
|
|
188
|
+
query: request.query
|
|
189
|
+
});
|
|
190
|
+
const setting = request.retry ?? options.retry;
|
|
191
|
+
const policy = false !== setting && canRetryMethod(method) ? {
|
|
192
|
+
...DEFAULT_RETRY,
|
|
193
|
+
...setting
|
|
194
|
+
} : void 0;
|
|
195
|
+
for(let count = 1;; count++){
|
|
196
|
+
const outcome = await once(method, key, url, request, count);
|
|
197
|
+
if ('ok' in outcome) return {
|
|
198
|
+
response: outcome.ok,
|
|
199
|
+
attempts: count
|
|
200
|
+
};
|
|
201
|
+
if (!policy || count > policy.retries || !outcome.retryable) throw outcome.failure;
|
|
202
|
+
if (void 0 !== outcome.asked && outcome.asked > policy.maxDelayMs) throw outcome.failure;
|
|
203
|
+
await sleep(outcome.asked ?? backoffMs(count - 1, policy), request.signal);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function json(method, key, request) {
|
|
207
|
+
const { response, attempts } = await send(method, key, request);
|
|
208
|
+
let text = '';
|
|
209
|
+
if (204 !== response.status && 205 !== response.status) try {
|
|
210
|
+
text = await response.text();
|
|
211
|
+
} catch (cause) {
|
|
212
|
+
if (request.signal?.aborted) throw request.signal.reason;
|
|
213
|
+
throw new HttpError({
|
|
214
|
+
kind: 'network',
|
|
215
|
+
method,
|
|
216
|
+
endpoint: key,
|
|
217
|
+
status: response.status,
|
|
218
|
+
headers: response.headers,
|
|
219
|
+
attempts,
|
|
220
|
+
cause
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
if ('' === text) return request.parse ? request.parse(void 0) : void 0;
|
|
224
|
+
let raw;
|
|
225
|
+
try {
|
|
226
|
+
raw = JSON.parse(text);
|
|
227
|
+
} catch (cause) {
|
|
228
|
+
throw new HttpError({
|
|
229
|
+
kind: 'parse',
|
|
230
|
+
method,
|
|
231
|
+
endpoint: key,
|
|
232
|
+
status: response.status,
|
|
233
|
+
headers: response.headers,
|
|
234
|
+
attempts,
|
|
235
|
+
cause
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
return request.parse ? request.parse(raw) : raw;
|
|
239
|
+
}
|
|
240
|
+
const verb = (method)=>(key, request = {})=>json(method, key, request);
|
|
241
|
+
return {
|
|
242
|
+
get: verb('GET'),
|
|
243
|
+
post: verb('POST'),
|
|
244
|
+
put: verb('PUT'),
|
|
245
|
+
patch: verb('PATCH'),
|
|
246
|
+
delete: verb('DELETE'),
|
|
247
|
+
response: async (method, key, request = {})=>(await send(method.toUpperCase(), key, request)).response
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
export { HttpError, createHttpClient, isHttpError };
|
package/dist/retry.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
/** Extra tries after the first. Default 2. */
|
|
3
|
+
retries?: number;
|
|
4
|
+
/** First wait before a retry, in ms; each one doubles, with jitter. Default 200. */
|
|
5
|
+
baseDelayMs?: number;
|
|
6
|
+
/** Longest wait in ms. A server that asks for longer (Retry-After) is not waited for. Default 2000. */
|
|
7
|
+
maxDelayMs?: number;
|
|
8
|
+
}
|
|
9
|
+
export declare const DEFAULT_RETRY: {
|
|
10
|
+
retries: number;
|
|
11
|
+
baseDelayMs: number;
|
|
12
|
+
maxDelayMs: number;
|
|
13
|
+
};
|
|
14
|
+
export declare const canRetryMethod: (method: string) => boolean;
|
|
15
|
+
export declare const canRetryStatus: (status: number) => boolean;
|
|
16
|
+
/** The wait asked for by a `Retry-After` header (seconds or a date), in ms; undefined without one. */
|
|
17
|
+
export declare function retryAfterMs(headers: Headers, now?: number): number | undefined;
|
|
18
|
+
/** Exponential backoff with full jitter: somewhere between 0 and base * 2^attempt, capped. */
|
|
19
|
+
export declare function backoffMs(attempt: number, options: Required<RetryOptions>, random?: () => number): number;
|
|
20
|
+
/** Resolves after `ms`; rejects with the signal's reason if it aborts first. */
|
|
21
|
+
export declare function sleep(ms: number, signal: AbortSignal | undefined): Promise<void>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,58 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mfeflow/http",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A small fetch client over @mfeflow/url-factory endpoints: async bearer tokens, timeouts, retries with backoff for idempotent methods only, typed JSON, and errors that carry the status and body but no URL.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"micro-frontend",
|
|
7
|
+
"mfe",
|
|
8
|
+
"module-federation",
|
|
9
|
+
"fetch",
|
|
10
|
+
"http-client",
|
|
11
|
+
"retry",
|
|
12
|
+
"typescript"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://mfeflow.github.io/mfeflow/",
|
|
15
|
+
"bugs": "https://github.com/mfeflow/mfeflow/issues",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/mfeflow/mfeflow.git",
|
|
19
|
+
"directory": "packages/http"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"author": "Sanjeev Sharma <speedsharma.ai@gmail.com>",
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"exports": {
|
|
26
|
+
".": {
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"import": "./dist/index.js",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22.12"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public",
|
|
40
|
+
"provenance": true
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@mfeflow/url-factory": "0.3.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@rslib/core": "1.0.5",
|
|
47
|
+
"@types/node": "26.6.6",
|
|
48
|
+
"msw": "3.0.3",
|
|
49
|
+
"typescript": "7.0.2"
|
|
50
|
+
},
|
|
51
|
+
"mfeflow": {
|
|
52
|
+
"bucket": "stateless"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "rslib build",
|
|
56
|
+
"typecheck": "tsc --noEmit"
|
|
57
|
+
}
|
|
6
58
|
}
|