@jskit-ai/http-runtime 0.1.212 → 0.1.213
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/http-runtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.213",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -78,6 +78,6 @@
|
|
|
78
78
|
}
|
|
79
79
|
},
|
|
80
80
|
"peerDependencies": {
|
|
81
|
-
"@jskit-ai/kernel": "0.1.
|
|
81
|
+
"@jskit-ai/kernel": "0.1.215"
|
|
82
82
|
}
|
|
83
83
|
}
|
|
@@ -14,6 +14,9 @@ function sleep(delayMs) {
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
function shouldRetryTransientHttpFailure(error, method, attemptIndex) {
|
|
17
|
+
if (error?.name === "AbortError" || error?.name === "TimeoutError") {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
17
20
|
if (!SAFE_RETRY_METHODS.has(String(method || "GET").toUpperCase())) {
|
|
18
21
|
return false;
|
|
19
22
|
}
|
|
@@ -23,13 +26,15 @@ function shouldRetryTransientHttpFailure(error, method, attemptIndex) {
|
|
|
23
26
|
return Number(attemptIndex) < MAX_TRANSIENT_HTTP_RETRIES;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
async function requestWithTransientRetry(executor, method) {
|
|
29
|
+
async function requestWithTransientRetry(executor, method, signal) {
|
|
27
30
|
let attemptIndex = 0;
|
|
28
31
|
|
|
29
32
|
while (true) {
|
|
33
|
+
signal?.throwIfAborted();
|
|
30
34
|
try {
|
|
31
35
|
return await executor();
|
|
32
36
|
} catch (error) {
|
|
37
|
+
signal?.throwIfAborted();
|
|
33
38
|
if (!shouldRetryTransientHttpFailure(error, method, attemptIndex)) {
|
|
34
39
|
throw error;
|
|
35
40
|
}
|
|
@@ -48,14 +53,16 @@ function createTransientRetryHttpClient(options = {}) {
|
|
|
48
53
|
const method = String(requestOptions?.method || "GET").toUpperCase();
|
|
49
54
|
return requestWithTransientRetry(
|
|
50
55
|
() => baseHttpClient.request(url, requestOptions, state),
|
|
51
|
-
method
|
|
56
|
+
method,
|
|
57
|
+
requestOptions?.signal
|
|
52
58
|
);
|
|
53
59
|
},
|
|
54
60
|
requestStream(url, requestOptions = {}, handlers = {}, state = null) {
|
|
55
61
|
const method = String(requestOptions?.method || "GET").toUpperCase();
|
|
56
62
|
return requestWithTransientRetry(
|
|
57
63
|
() => baseHttpClient.requestStream(url, requestOptions, handlers, state),
|
|
58
|
-
method
|
|
64
|
+
method,
|
|
65
|
+
requestOptions?.signal
|
|
59
66
|
);
|
|
60
67
|
}
|
|
61
68
|
});
|
|
@@ -13,6 +13,15 @@ import {
|
|
|
13
13
|
|
|
14
14
|
const DEFAULT_UNSAFE_METHODS = Object.freeze(["POST", "PUT", "PATCH", "DELETE"]);
|
|
15
15
|
const DEFAULT_NDJSON_CONTENT_TYPE = "application/x-ndjson";
|
|
16
|
+
const DEFAULT_READ_TIMEOUT_MS = 30_000;
|
|
17
|
+
|
|
18
|
+
function requestSignalWithDeadline(signal, timeoutMs) {
|
|
19
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
20
|
+
throw new TypeError("Request timeoutMs must be a positive finite integer.");
|
|
21
|
+
}
|
|
22
|
+
const deadline = AbortSignal.timeout(timeoutMs);
|
|
23
|
+
return signal ? AbortSignal.any([signal, deadline]) : deadline;
|
|
24
|
+
}
|
|
16
25
|
|
|
17
26
|
function normalizeMethod(method) {
|
|
18
27
|
return String(method || "GET")
|
|
@@ -94,7 +103,7 @@ function appendRequestQueryToUrl(url, query = null, transport = null) {
|
|
|
94
103
|
return appendQueryString(normalizedUrl, serializedQuery);
|
|
95
104
|
}
|
|
96
105
|
|
|
97
|
-
function parseJsonSafely(response) {
|
|
106
|
+
function parseJsonSafely(response, signal) {
|
|
98
107
|
const contentType = String(response?.headers?.get?.("content-type") || "");
|
|
99
108
|
const isJson = isJsonContentType(contentType);
|
|
100
109
|
if (!isJson) {
|
|
@@ -106,7 +115,13 @@ function parseJsonSafely(response) {
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
return Promise.resolve(response?.json?.())
|
|
109
|
-
.catch(() =>
|
|
118
|
+
.catch((error) => {
|
|
119
|
+
signal?.throwIfAborted();
|
|
120
|
+
if (error?.name === "AbortError") {
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
return {};
|
|
124
|
+
})
|
|
110
125
|
.then((data) => ({
|
|
111
126
|
contentType,
|
|
112
127
|
isJson,
|
|
@@ -199,6 +214,7 @@ function createHttpClient(options = {}) {
|
|
|
199
214
|
|
|
200
215
|
async function fetchSessionForCsrf() {
|
|
201
216
|
const activeFetch = configuredFetchImpl || resolveFetch();
|
|
217
|
+
const signal = requestSignalWithDeadline(null, options.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS);
|
|
202
218
|
const requestUrl = await resolveRequestUrl(csrf.sessionPath, {
|
|
203
219
|
originalUrl: csrf.sessionPath,
|
|
204
220
|
method: "GET",
|
|
@@ -213,13 +229,16 @@ function createHttpClient(options = {}) {
|
|
|
213
229
|
try {
|
|
214
230
|
response = await activeFetch(requestUrl, {
|
|
215
231
|
method: "GET",
|
|
216
|
-
credentials: String(options?.credentials || "same-origin")
|
|
232
|
+
credentials: String(options?.credentials || "same-origin"),
|
|
233
|
+
signal
|
|
217
234
|
});
|
|
218
235
|
} catch (cause) {
|
|
236
|
+
signal.throwIfAborted();
|
|
219
237
|
throw createNetworkError(cause);
|
|
220
238
|
}
|
|
221
239
|
|
|
222
|
-
const { data } = await parseJsonSafely(response);
|
|
240
|
+
const { data } = await parseJsonSafely(response, signal);
|
|
241
|
+
signal.throwIfAborted();
|
|
223
242
|
updateCsrfTokenFromPayload(data);
|
|
224
243
|
|
|
225
244
|
if (!response.ok) {
|
|
@@ -352,6 +371,7 @@ function createHttpClient(options = {}) {
|
|
|
352
371
|
transport: _transport,
|
|
353
372
|
query: requestQuery,
|
|
354
373
|
csrf: requestCsrf,
|
|
374
|
+
timeoutMs: requestTimeoutMs,
|
|
355
375
|
...forwardedRequestOptions
|
|
356
376
|
} = requestOptions && typeof requestOptions === "object" ? requestOptions : {};
|
|
357
377
|
const csrfEnabled = csrf.enabled && requestCsrf !== false;
|
|
@@ -385,6 +405,13 @@ function createHttpClient(options = {}) {
|
|
|
385
405
|
method,
|
|
386
406
|
headers
|
|
387
407
|
};
|
|
408
|
+
const ordinaryRead = !stream && ["GET", "HEAD"].includes(method);
|
|
409
|
+
const timeoutMs = requestTimeoutMs ?? (ordinaryRead
|
|
410
|
+
? options.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS
|
|
411
|
+
: undefined);
|
|
412
|
+
if (timeoutMs !== undefined) {
|
|
413
|
+
config.signal = requestSignalWithDeadline(config.signal, timeoutMs);
|
|
414
|
+
}
|
|
388
415
|
|
|
389
416
|
if (transport) {
|
|
390
417
|
setHeaderIfMissing(headers, "Accept", JSON_API_CONTENT_TYPE);
|
|
@@ -418,15 +445,18 @@ function createHttpClient(options = {}) {
|
|
|
418
445
|
}
|
|
419
446
|
|
|
420
447
|
async function executePreparedRequest(url, config, { method, state }, onNetworkFailure) {
|
|
448
|
+
config.signal?.throwIfAborted();
|
|
421
449
|
let response;
|
|
422
450
|
try {
|
|
423
451
|
const activeFetch = configuredFetchImpl || resolveFetch();
|
|
424
452
|
response = await activeFetch(url, config);
|
|
425
453
|
} catch (cause) {
|
|
454
|
+
config.signal?.throwIfAborted();
|
|
426
455
|
return onNetworkFailure(cause);
|
|
427
456
|
}
|
|
428
457
|
|
|
429
|
-
const { contentType, isJson, data } = await parseJsonSafely(response);
|
|
458
|
+
const { contentType, isJson, data } = await parseJsonSafely(response, config.signal);
|
|
459
|
+
config.signal?.throwIfAborted();
|
|
430
460
|
updateCsrfTokenFromPayload(data);
|
|
431
461
|
|
|
432
462
|
return {
|
|
@@ -556,6 +586,9 @@ function createHttpClient(options = {}) {
|
|
|
556
586
|
state,
|
|
557
587
|
stream: false,
|
|
558
588
|
async handleNetworkFailure({ cause, method, state: resolvedState }) {
|
|
589
|
+
if (cause?.name === "AbortError") {
|
|
590
|
+
throw cause;
|
|
591
|
+
}
|
|
559
592
|
const error = createNetworkError(cause);
|
|
560
593
|
await notifyFailure({
|
|
561
594
|
url,
|
package/test/client.test.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import test from "node:test";
|
|
3
|
+
import { createServer } from "node:http";
|
|
3
4
|
|
|
4
5
|
import { createHttpClient } from "../src/shared/clientRuntime/client.js";
|
|
5
6
|
|
|
@@ -21,6 +22,62 @@ function mockResponse({ status = 200, data = {}, contentType = "application/json
|
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
test("ordinary reads get a finite default deadline while streams keep their explicit lifetime", async (t) => {
|
|
26
|
+
const deadlines = [];
|
|
27
|
+
const calls = [];
|
|
28
|
+
t.mock.method(AbortSignal, "timeout", (milliseconds) => {
|
|
29
|
+
deadlines.push(milliseconds);
|
|
30
|
+
return new AbortController().signal;
|
|
31
|
+
});
|
|
32
|
+
const client = createHttpClient({
|
|
33
|
+
fetchImpl: async (_url, options) => {
|
|
34
|
+
calls.push(options);
|
|
35
|
+
return mockResponse();
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
await client.request("/api/list");
|
|
39
|
+
await client.request("/api/report", { timeoutMs: 90_000 });
|
|
40
|
+
await client.requestStream("/api/events");
|
|
41
|
+
assert.deepEqual(deadlines, [30_000, 90_000]);
|
|
42
|
+
assert.ok(calls[0].signal instanceof AbortSignal);
|
|
43
|
+
assert.equal(calls[1].timeoutMs, undefined);
|
|
44
|
+
assert.equal(calls[2].signal, undefined);
|
|
45
|
+
for (const timeoutMs of [0, -1, Infinity, NaN]) {
|
|
46
|
+
await assert.rejects(client.request("/api/list", { timeoutMs }), /positive finite integer/u);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
for (const phase of ["headers", "body", "CSRF session"]) {
|
|
51
|
+
test(`read deadline closes a real HTTP connection stalled during ${phase}`, async (t) => {
|
|
52
|
+
let received;
|
|
53
|
+
let closed;
|
|
54
|
+
const requestReceived = new Promise((resolve) => { received = resolve; });
|
|
55
|
+
const responseClosed = new Promise((resolve) => { closed = resolve; });
|
|
56
|
+
const server = createServer((_request, response) => {
|
|
57
|
+
response.on("close", closed);
|
|
58
|
+
if (phase !== "headers") {
|
|
59
|
+
response.writeHead(200, { "Content-Type": "application/json" });
|
|
60
|
+
response.write('{"pending":');
|
|
61
|
+
}
|
|
62
|
+
received();
|
|
63
|
+
});
|
|
64
|
+
t.after(() => {
|
|
65
|
+
server.closeAllConnections();
|
|
66
|
+
server.close();
|
|
67
|
+
});
|
|
68
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
69
|
+
const origin = `http://127.0.0.1:${server.address().port}`;
|
|
70
|
+
const client = createHttpClient({ readTimeoutMs: 100, csrf: { sessionPath: `${origin}/session` } });
|
|
71
|
+
const pending = client.request(`${origin}/read`, phase === "CSRF session"
|
|
72
|
+
? { method: "POST", body: { value: 1 } }
|
|
73
|
+
: {});
|
|
74
|
+
const rejected = assert.rejects(pending, (error) => error.name === "TimeoutError");
|
|
75
|
+
await requestReceived;
|
|
76
|
+
await rejected;
|
|
77
|
+
await responseClosed;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
24
81
|
test("request retains standard and JSON:API write outcomes without retrying", async () => {
|
|
25
82
|
for (const jsonapi of [false, true]) {
|
|
26
83
|
for (const outcome of ["committed", "rolledBack", "unknown", "invalid"]) {
|
|
@@ -165,3 +165,73 @@ test("createTransientRetryHttpClient retries transient GET stream failures", asy
|
|
|
165
165
|
assert.equal(callCount, 2);
|
|
166
166
|
});
|
|
167
167
|
});
|
|
168
|
+
|
|
169
|
+
for (const phase of ["before request", "waiting for headers", "reading body", "retry delay"]) {
|
|
170
|
+
test(`request cancellation during ${phase} rejects without starting another fetch`, async () => {
|
|
171
|
+
await withImmediateTimers(async () => {
|
|
172
|
+
const controller = new AbortController();
|
|
173
|
+
const reason = new DOMException("Read superseded", "AbortError");
|
|
174
|
+
let callCount = 0;
|
|
175
|
+
const client = createTransientRetryHttpClient({
|
|
176
|
+
fetchImpl: async (_url, { signal }) => {
|
|
177
|
+
callCount += 1;
|
|
178
|
+
if (phase === "before request") signal.throwIfAborted();
|
|
179
|
+
if (phase === "waiting for headers") {
|
|
180
|
+
controller.abort(reason);
|
|
181
|
+
signal.throwIfAborted();
|
|
182
|
+
}
|
|
183
|
+
if (phase === "reading body") {
|
|
184
|
+
return {
|
|
185
|
+
...mockResponse(),
|
|
186
|
+
async json() {
|
|
187
|
+
controller.abort(reason);
|
|
188
|
+
signal.throwIfAborted();
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
return mockResponse({ status: 503 });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
const immediateTimer = globalThis.setTimeout;
|
|
196
|
+
if (phase === "before request") controller.abort(reason);
|
|
197
|
+
if (phase === "retry delay") {
|
|
198
|
+
globalThis.setTimeout = (handler, delay, ...args) => {
|
|
199
|
+
controller.abort(reason);
|
|
200
|
+
return immediateTimer(handler, delay, ...args);
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
await assert.rejects(client.request("/api/cancellation", { signal: controller.signal }),
|
|
204
|
+
(error) => error === reason);
|
|
205
|
+
assert.equal(callCount, phase === "before request" ? 0 : 1);
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
for (const phase of ["headers", "body"]) {
|
|
211
|
+
test(`default read deadline during ${phase} rejects without a caller signal or another fetch`, async (t) => {
|
|
212
|
+
await withImmediateTimers(async () => {
|
|
213
|
+
const deadline = new AbortController();
|
|
214
|
+
const reason = new DOMException("Read timed out", "TimeoutError");
|
|
215
|
+
t.mock.method(AbortSignal, "timeout", () => deadline.signal);
|
|
216
|
+
let callCount = 0;
|
|
217
|
+
const client = createTransientRetryHttpClient({
|
|
218
|
+
fetchImpl: async () => {
|
|
219
|
+
callCount += 1;
|
|
220
|
+
if (phase === "headers") {
|
|
221
|
+
deadline.abort(reason);
|
|
222
|
+
throw reason;
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
...mockResponse(),
|
|
226
|
+
async json() {
|
|
227
|
+
deadline.abort(reason);
|
|
228
|
+
throw new DOMException("Body read aborted", "AbortError");
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
await assert.rejects(client.request("/api/forgotten-read"), (error) => error === reason);
|
|
234
|
+
assert.equal(callCount, 1);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
}
|