@jskit-ai/http-runtime 0.1.212 → 0.1.214

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.212",
3
+ "version": "0.1.214",
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.214"
81
+ "@jskit-ai/kernel": "0.1.216"
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,
@@ -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
+ }