@jskit-ai/http-runtime 0.1.37 → 0.1.38

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.
@@ -1,7 +1,7 @@
1
1
  export default Object.freeze({
2
2
  "packageVersion": 1,
3
3
  "packageId": "@jskit-ai/http-runtime",
4
- "version": "0.1.37",
4
+ "version": "0.1.38",
5
5
  "kind": "runtime",
6
6
  "dependsOn": [],
7
7
  "capabilities": {
@@ -67,7 +67,7 @@ export default Object.freeze({
67
67
  "mutations": {
68
68
  "dependencies": {
69
69
  "runtime": {
70
- "@jskit-ai/kernel": "0.1.38",
70
+ "@jskit-ai/kernel": "0.1.39",
71
71
  "@fastify/type-provider-typebox": "^6.1.0",
72
72
  "typebox": "^1.0.81"
73
73
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/http-runtime",
3
- "version": "0.1.37",
3
+ "version": "0.1.38",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -17,7 +17,7 @@
17
17
  "./shared/validators/operationValidation": "./src/shared/validators/operationValidation.js"
18
18
  },
19
19
  "dependencies": {
20
- "@jskit-ai/kernel": "0.1.38",
20
+ "@jskit-ai/kernel": "0.1.39",
21
21
  "@fastify/type-provider-typebox": "^6.1.0",
22
22
  "typebox": "^1.0.81"
23
23
  }
@@ -1,4 +1,5 @@
1
1
  export { createHttpClient } from "../shared/clientRuntime/client.js";
2
+ export { createTransientRetryHttpClient } from "./transientRetryHttpClient.js";
2
3
  export {
3
4
  normalizeFieldErrors,
4
5
  resolveFieldErrors,
@@ -0,0 +1,64 @@
1
+ import { createHttpClient } from "../shared/clientRuntime/client.js";
2
+ import {
3
+ isTransientQueryError,
4
+ transientQueryRetryDelay
5
+ } from "@jskit-ai/kernel/shared/support";
6
+
7
+ const SAFE_RETRY_METHODS = Object.freeze(new Set(["GET", "HEAD"]));
8
+ const MAX_TRANSIENT_HTTP_RETRIES = 2;
9
+
10
+ function sleep(delayMs) {
11
+ return new Promise((resolve) => {
12
+ setTimeout(resolve, delayMs);
13
+ });
14
+ }
15
+
16
+ function shouldRetryTransientHttpFailure(error, method, attemptIndex) {
17
+ if (!SAFE_RETRY_METHODS.has(String(method || "GET").toUpperCase())) {
18
+ return false;
19
+ }
20
+ if (!isTransientQueryError(error)) {
21
+ return false;
22
+ }
23
+ return Number(attemptIndex) < MAX_TRANSIENT_HTTP_RETRIES;
24
+ }
25
+
26
+ async function requestWithTransientRetry(executor, method) {
27
+ let attemptIndex = 0;
28
+
29
+ while (true) {
30
+ try {
31
+ return await executor();
32
+ } catch (error) {
33
+ if (!shouldRetryTransientHttpFailure(error, method, attemptIndex)) {
34
+ throw error;
35
+ }
36
+ attemptIndex += 1;
37
+ await sleep(transientQueryRetryDelay(attemptIndex));
38
+ }
39
+ }
40
+ }
41
+
42
+ function createTransientRetryHttpClient(options = {}) {
43
+ const baseHttpClient = createHttpClient(options);
44
+
45
+ return Object.freeze({
46
+ ...baseHttpClient,
47
+ request(url, requestOptions = {}, state = null) {
48
+ const method = String(requestOptions?.method || "GET").toUpperCase();
49
+ return requestWithTransientRetry(
50
+ () => baseHttpClient.request(url, requestOptions, state),
51
+ method
52
+ );
53
+ },
54
+ requestStream(url, requestOptions = {}, handlers = {}, state = null) {
55
+ const method = String(requestOptions?.method || "GET").toUpperCase();
56
+ return requestWithTransientRetry(
57
+ () => baseHttpClient.requestStream(url, requestOptions, handlers, state),
58
+ method
59
+ );
60
+ }
61
+ });
62
+ }
63
+
64
+ export { createTransientRetryHttpClient };
@@ -14,8 +14,9 @@ test("package exports include explicit shared entrypoint and no server barrel",
14
14
  assert.equal(exportsMap["./shared"], "./src/shared/index.js");
15
15
  });
16
16
 
17
- test("client entrypoint exports client runtime and client providers only", () => {
17
+ test("client entrypoint exports client runtime helpers and client providers only", () => {
18
18
  assert.equal(typeof clientApi.createHttpClient, "function");
19
+ assert.equal(typeof clientApi.createTransientRetryHttpClient, "function");
19
20
  assert.equal(typeof clientApi.HttpValidatorsClientProvider, "function");
20
21
  assert.equal(typeof clientApi.HttpClientRuntimeClientProvider, "function");
21
22
  assert.equal(typeof clientApi.withStandardErrorResponses, "undefined");
@@ -0,0 +1,140 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ import { createTransientRetryHttpClient } from "../src/client/index.js";
5
+
6
+ function mockResponse({ status = 200, data = {}, contentType = "application/json; charset=utf-8", text = "" } = {}) {
7
+ return {
8
+ ok: status >= 200 && status < 300,
9
+ status,
10
+ headers: {
11
+ get(name) {
12
+ return String(name || "").toLowerCase() === "content-type" ? contentType : "";
13
+ }
14
+ },
15
+ async json() {
16
+ return data;
17
+ },
18
+ async text() {
19
+ return text;
20
+ }
21
+ };
22
+ }
23
+
24
+ async function withImmediateTimers(executor) {
25
+ const originalSetTimeout = globalThis.setTimeout;
26
+ globalThis.setTimeout = (handler, _delay, ...args) => {
27
+ if (typeof handler === "function") {
28
+ handler(...args);
29
+ }
30
+ return 0;
31
+ };
32
+
33
+ try {
34
+ return await executor();
35
+ } finally {
36
+ globalThis.setTimeout = originalSetTimeout;
37
+ }
38
+ }
39
+
40
+ test("createTransientRetryHttpClient retries transient GET request failures", async () => {
41
+ await withImmediateTimers(async () => {
42
+ let callCount = 0;
43
+ const client = createTransientRetryHttpClient({
44
+ fetchImpl: async () => {
45
+ callCount += 1;
46
+ if (callCount < 3) {
47
+ return mockResponse({
48
+ status: 503,
49
+ data: {
50
+ error: "temporarily unavailable"
51
+ }
52
+ });
53
+ }
54
+
55
+ return mockResponse({
56
+ data: {
57
+ ok: true
58
+ }
59
+ });
60
+ }
61
+ });
62
+
63
+ const payload = await client.request("/api/demo", {
64
+ method: "GET"
65
+ });
66
+
67
+ assert.deepEqual(payload, { ok: true });
68
+ assert.equal(callCount, 3);
69
+ });
70
+ });
71
+
72
+ test("createTransientRetryHttpClient does not retry unsafe transient failures", async () => {
73
+ await withImmediateTimers(async () => {
74
+ let callCount = 0;
75
+ const client = createTransientRetryHttpClient({
76
+ fetchImpl: async (url) => {
77
+ callCount += 1;
78
+ if (url === "/api/session") {
79
+ return mockResponse({
80
+ data: {
81
+ csrfToken: "csrf-1"
82
+ }
83
+ });
84
+ }
85
+
86
+ return mockResponse({
87
+ status: 503,
88
+ data: {
89
+ error: "temporarily unavailable"
90
+ }
91
+ });
92
+ }
93
+ });
94
+
95
+ await assert.rejects(
96
+ client.request("/api/demo", {
97
+ method: "POST",
98
+ body: {
99
+ ok: true
100
+ }
101
+ }),
102
+ (error) => {
103
+ assert.equal(error?.status, 503);
104
+ return true;
105
+ }
106
+ );
107
+
108
+ assert.equal(callCount, 2);
109
+ });
110
+ });
111
+
112
+ test("createTransientRetryHttpClient retries transient GET stream failures", async () => {
113
+ await withImmediateTimers(async () => {
114
+ let callCount = 0;
115
+ const client = createTransientRetryHttpClient({
116
+ fetchImpl: async () => {
117
+ callCount += 1;
118
+ if (callCount === 1) {
119
+ return mockResponse({
120
+ status: 503,
121
+ data: {
122
+ error: "temporarily unavailable"
123
+ }
124
+ });
125
+ }
126
+
127
+ return mockResponse({
128
+ contentType: "text/plain; charset=utf-8",
129
+ text: ""
130
+ });
131
+ }
132
+ });
133
+
134
+ await client.requestStream("/api/demo-stream", {
135
+ method: "GET"
136
+ });
137
+
138
+ assert.equal(callCount, 2);
139
+ });
140
+ });