@reventlessdev/reventless-seed 1.0.0-alpha.17 → 1.0.0-alpha.19

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.
Files changed (61) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/lib/bs/.compiler.log +2 -2
  3. package/lib/bs/ReventlessSeed.cmi +0 -0
  4. package/lib/bs/ReventlessSeed.cmt +0 -0
  5. package/lib/bs/ReventlessSeed.mlmap +1 -0
  6. package/lib/bs/compiler-info.json +1 -1
  7. package/lib/bs/src/Seed-ReventlessSeed.cmi +0 -0
  8. package/lib/bs/src/Seed-ReventlessSeed.cmt +0 -0
  9. package/lib/bs/src/Seed_Client-ReventlessSeed.cmi +0 -0
  10. package/lib/bs/src/Seed_Client-ReventlessSeed.cmj +0 -0
  11. package/lib/bs/src/Seed_Client-ReventlessSeed.cmt +0 -0
  12. package/lib/bs/src/Seed_Client.ast +0 -0
  13. package/lib/bs/src/Seed_Client.res +67 -11
  14. package/lib/bs/src/Seed_Client.res.mjs +60 -20
  15. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmi +0 -0
  16. package/lib/bs/src/Seed_Connect-ReventlessSeed.cmt +0 -0
  17. package/lib/bs/src/Seed_Prompt-ReventlessSeed.cmt +0 -0
  18. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmi +0 -0
  19. package/lib/bs/src/Seed_Runner-ReventlessSeed.cmt +0 -0
  20. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmi +0 -0
  21. package/lib/bs/src/Seed_Upload-ReventlessSeed.cmt +0 -0
  22. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  23. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  24. package/lib/bs/tests/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  25. package/lib/bs/tests/Seed_ClientTest.ast +0 -0
  26. package/lib/bs/tests/Seed_ClientTest.res +59 -0
  27. package/lib/bs/tests/Seed_ClientTest.res.mjs +64 -0
  28. package/lib/bs/tests/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  29. package/lib/bs/tests/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  30. package/lib/bs/tests/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  31. package/lib/ocaml/.compiler.log +2 -2
  32. package/lib/ocaml/ReventlessSeed.cmi +0 -0
  33. package/lib/ocaml/ReventlessSeed.cmt +0 -0
  34. package/lib/ocaml/ReventlessSeed.mlmap +1 -0
  35. package/lib/ocaml/Seed-ReventlessSeed.cmi +0 -0
  36. package/lib/ocaml/Seed-ReventlessSeed.cmt +0 -0
  37. package/lib/ocaml/Seed_Client-ReventlessSeed.cmi +0 -0
  38. package/lib/ocaml/Seed_Client-ReventlessSeed.cmj +0 -0
  39. package/lib/ocaml/Seed_Client-ReventlessSeed.cmt +0 -0
  40. package/lib/ocaml/Seed_Client.ast +0 -0
  41. package/lib/ocaml/Seed_Client.res +67 -11
  42. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmi +0 -0
  43. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmj +0 -0
  44. package/lib/ocaml/Seed_ClientTest-ReventlessSeed.cmt +0 -0
  45. package/lib/ocaml/Seed_ClientTest.ast +0 -0
  46. package/lib/ocaml/Seed_ClientTest.res +59 -0
  47. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmi +0 -0
  48. package/lib/ocaml/Seed_Connect-ReventlessSeed.cmt +0 -0
  49. package/lib/ocaml/Seed_Prompt-ReventlessSeed.cmt +0 -0
  50. package/lib/ocaml/Seed_PromptTest-ReventlessSeed.cmt +0 -0
  51. package/lib/ocaml/Seed_RandomTest-ReventlessSeed.cmt +0 -0
  52. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmi +0 -0
  53. package/lib/ocaml/Seed_Runner-ReventlessSeed.cmt +0 -0
  54. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmi +0 -0
  55. package/lib/ocaml/Seed_Upload-ReventlessSeed.cmt +0 -0
  56. package/lib/ocaml/Seed_UsersTest-ReventlessSeed.cmt +0 -0
  57. package/package.json +2 -2
  58. package/src/Seed_Client.res +67 -11
  59. package/src/Seed_Client.res.mjs +60 -20
  60. package/tests/Seed_ClientTest.res +59 -0
  61. package/tests/Seed_ClientTest.res.mjs +64 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 1.0.0-alpha.19 (2026-09-04)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/reventless-seed
9
+
10
+
11
+
12
+
13
+
14
+ # 1.0.0-alpha.18 (2026-08-27)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **seed:** one endpoint-side fault throws away every command that landed ([6126dca](https://github.com/ReventlessDev/reventless-core/commit/6126dca7c62d193cf3a4efe379160af8207faa86))
19
+
20
+
6
21
  # 1.0.0-alpha.17 (2026-08-20)
7
22
 
8
23
  ### Features
@@ -1,2 +1,2 @@
1
- #Start(1787252810629)
2
- #Done(1787252810671)
1
+ #Start(1788547299414)
2
+ #Done(1788547299452)
Binary file
Binary file
@@ -1,6 +1,7 @@
1
1
  randjbuildsystem
2
2
  Seed
3
3
  Seed_Client
4
+ Seed_ClientTest
4
5
  Seed_Connect
5
6
  Seed_Prompt
6
7
  Seed_PromptTest
@@ -4,5 +4,5 @@
4
4
  "bsc_hash": "75e3a59c95cc953608fd3dd6ea6ed68141bba4414a20a52f114261b8a48385fa",
5
5
  "rescript_config_hash": "25cfff836d200084949fdfdfec2f6875901bfee1f04d84634eca252ef1ca1ef1",
6
6
  "runtime_path": "/home/runner/work/reventless-core/reventless-core/node_modules/@rescript/runtime",
7
- "generated_at": "1787252810674"
7
+ "generated_at": "1788547299455"
8
8
  }
Binary file
Binary file
Binary file
@@ -106,10 +106,57 @@ let login = async (t: t): unit => {
106
106
  t.token = json->field("token")->Option.flatMap(asString)
107
107
  }
108
108
 
109
+ /** Error types the endpoint returns for a fault on its own side rather than a
110
+ problem with the document. They say nothing about the request, so the same
111
+ request sent again can succeed — and across the few hundred commands a seed
112
+ issues, one of these is ordinary rather than exceptional.
113
+
114
+ Everything else — a validation error, an unknown field, a refused token — is
115
+ an answer, and the same answer comes back however many times it is asked. */
116
+ let transientErrorTypes = [
117
+ "InternalFailure",
118
+ "ServiceUnavailable",
119
+ "ServiceUnavailableError",
120
+ "Throttling",
121
+ "ThrottlingException",
122
+ "TooManyRequestsException",
123
+ "RequestTimeout",
124
+ ]
125
+
126
+ /** Every entry must be transient for the document to be worth resending: one
127
+ real error alongside a transient one still means the request was wrong. An
128
+ empty `errors` array is not a reason to retry either. */
129
+ let isTransient = (errors: JSON.t): bool =>
130
+ switch errors->JSON.Decode.array {
131
+ | None | Some([]) => false
132
+ | Some(entries) =>
133
+ entries->Array.every(e =>
134
+ switch e->nodeString("errorType") {
135
+ | Some(errorType) => transientErrorTypes->Array.includes(errorType)
136
+ | None => false
137
+ }
138
+ )
139
+ }
140
+
141
+ let attempts = 4
142
+
109
143
  /**
110
144
  * Sends one GraphQL document and returns its `data`. A GraphQL-level error
111
145
  * aborts the run: a half-seeded store is worse than an empty one, because it
112
146
  * looks like a working dataset.
147
+ *
148
+ * Which is exactly why a fault on the endpoint's own side must not abort it. A
149
+ * seed issues hundreds of commands, many of them concurrently and all of them
150
+ * against runtimes that a preceding reset left cold, so a single
151
+ * `InternalFailure` somewhere in the batch is a normal event. Treating it as
152
+ * fatal threw away every command that had already landed and demanded a full
153
+ * wipe and re-run — for a request that would have succeeded on being asked
154
+ * again. So resend those, backing off between tries, and keep failing fast on
155
+ * everything that is an answer rather than a fault.
156
+ *
157
+ * The backoff is jittered because the commands go out in concurrent batches: a
158
+ * fixed delay would have the whole batch retry in lockstep and rebuild the same
159
+ * burst that provoked the fault.
113
160
  */
114
161
  let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
115
162
  let headers = Dict.fromArray([("content-type", "application/json")])
@@ -120,18 +167,27 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
120
167
  let body = JSON.stringify(
121
168
  JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
122
169
  )
123
- let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
124
- | _ =>
125
- throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
126
- }
127
- let json = await res->responseJson
128
- switch json->field("errors") {
129
- | Some(errors) =>
130
- throw(
131
- Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}`),
132
- )
133
- | None => json->field("data")->Option.getOr(JSON.Encode.null)
170
+ let rec attempt = async (n: int): JSON.t => {
171
+ let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
172
+ | _ =>
173
+ throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
174
+ }
175
+ let json = await res->responseJson
176
+ switch json->field("errors") {
177
+ | Some(errors) =>
178
+ if errors->isTransient && n < attempts {
179
+ await sleep(250 * Int.shiftLeft(1, n - 1) + Int.fromFloat(Math.random() *. 250.))
180
+ await attempt(n + 1)
181
+ } else {
182
+ let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
183
+ throw(
184
+ Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}${tried}`),
185
+ )
186
+ }
187
+ | None => json->field("data")->Option.getOr(JSON.Encode.null)
188
+ }
134
189
  }
190
+ await attempt(1)
135
191
  }
136
192
 
137
193
  // ── Commands ────────────────────────────────────────────────────────────────
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
3
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Seed_Types$ReventlessSeed from "./Seed_Types.res.mjs";
@@ -98,6 +99,32 @@ async function login(t) {
98
99
  t.token = Stdlib_Option.flatMap(field(json, "token"), asString);
99
100
  }
100
101
 
102
+ let transientErrorTypes = [
103
+ "InternalFailure",
104
+ "ServiceUnavailable",
105
+ "ServiceUnavailableError",
106
+ "Throttling",
107
+ "ThrottlingException",
108
+ "TooManyRequestsException",
109
+ "RequestTimeout"
110
+ ];
111
+
112
+ function isTransient(errors) {
113
+ let entries = Stdlib_JSON.Decode.array(errors);
114
+ if (entries !== undefined && entries.length !== 0) {
115
+ return entries.every(e => {
116
+ let errorType = nodeString(e, "errorType");
117
+ if (errorType !== undefined) {
118
+ return transientErrorTypes.includes(errorType);
119
+ } else {
120
+ return false;
121
+ }
122
+ });
123
+ } else {
124
+ return false;
125
+ }
126
+ }
127
+
101
128
  async function gql(t, query, label) {
102
129
  let headers = Object.fromEntries([[
103
130
  "content-type",
@@ -111,30 +138,38 @@ async function gql(t, query, label) {
111
138
  "query",
112
139
  query
113
140
  ]]));
114
- let res;
115
- try {
116
- res = await fetch(t.config.endpoint, {
117
- method: "POST",
118
- headers: headers,
119
- body: body
120
- });
121
- } catch (exn) {
122
- throw {
123
- RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
124
- _1: label + `: cannot reach ` + t.config.endpoint + ` — is the platform running?`,
125
- Error: new Error()
126
- };
127
- }
128
- let json = await res.json();
129
- let errors = field(json, "errors");
130
- if (errors !== undefined) {
141
+ let attempt = async n => {
142
+ let res;
143
+ try {
144
+ res = await fetch(t.config.endpoint, {
145
+ method: "POST",
146
+ headers: headers,
147
+ body: body
148
+ });
149
+ } catch (exn) {
150
+ throw {
151
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
152
+ _1: label + `: cannot reach ` + t.config.endpoint + ` — is the platform running?`,
153
+ Error: new Error()
154
+ };
155
+ }
156
+ let json = await res.json();
157
+ let errors = field(json, "errors");
158
+ if (errors === undefined) {
159
+ return Stdlib_Option.getOr(field(json, "data"), null);
160
+ }
161
+ if (isTransient(errors) && n < 4) {
162
+ await sleep((250 * (1 << (n - 1 | 0)) | 0) + (Math.random() * 250 | 0) | 0);
163
+ return await attempt(n + 1 | 0);
164
+ }
165
+ let tried = n > 1 ? `\n gave up after ` + n.toString() + ` attempts` : "";
131
166
  throw {
132
167
  RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
133
- _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors),
168
+ _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + tried,
134
169
  Error: new Error()
135
170
  };
136
- }
137
- return Stdlib_Option.getOr(field(json, "data"), null);
171
+ };
172
+ return await attempt(1);
138
173
  }
139
174
 
140
175
  let commandResultSelection = `__typename
@@ -269,6 +304,8 @@ async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
269
304
  return Stdlib_Option.getOr(result, 0);
270
305
  }
271
306
 
307
+ let attempts = 4;
308
+
272
309
  export {
273
310
  sleep,
274
311
  make,
@@ -279,6 +316,9 @@ export {
279
316
  asString,
280
317
  nodeString,
281
318
  login,
319
+ transientErrorTypes,
320
+ isTransient,
321
+ attempts,
282
322
  gql,
283
323
  commandResultSelection,
284
324
  send,
Binary file
@@ -0,0 +1,59 @@
1
+ open JestGlobals
2
+
3
+ // A seed issues hundreds of commands against runtimes a preceding reset left
4
+ // cold, so the endpoint faulting on one of them is a normal event rather than an
5
+ // exceptional one. Before the retry, a single `InternalFailure` in that batch
6
+ // aborted the run and left a half-seeded store that could only be recovered by a
7
+ // full wipe and a re-run — for a request that would have succeeded on being
8
+ // asked again.
9
+ //
10
+ // What makes the retry safe is the classifier, so that is what these pin. A
11
+ // fault on the endpoint's side carries no information about the document and is
12
+ // worth resending; anything that is an *answer* — a validation error, an unknown
13
+ // field, a refused token — comes back identical however many times it is asked,
14
+ // and resending it only delays the failure the operator needs to see.
15
+
16
+ let errors = entries => JSON.Encode.array(entries->Array.map(JSON.Encode.object))
17
+
18
+ let errorOfType = errorType =>
19
+ Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
20
+
21
+ describe("Seed_Client.isTransient:", () => {
22
+ testSync("resends a fault on the endpoint's own side", () =>
23
+ ["InternalFailure", "ServiceUnavailable", "Throttling", "TooManyRequestsException"]
24
+ ->Array.forEach(t =>
25
+ expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true)
26
+ )
27
+ )
28
+
29
+ // The exact payload the seed aborted on.
30
+ testSync("resends the shape AppSync actually returned", () => {
31
+ let payload = JSON.parseOrThrow(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`)
32
+ expect(Seed_Client.isTransient(payload))->toBe(true)
33
+ })
34
+
35
+ testSync("fails fast on an error that is an answer", () =>
36
+ ["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(t =>
37
+ expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false)
38
+ )
39
+ )
40
+
41
+ // A real error sitting alongside a transient one still means the request was
42
+ // wrong, and resending it cannot make it right.
43
+ testSync("refuses to resend a batch that mixes a real error in", () =>
44
+ expect(
45
+ Seed_Client.isTransient(
46
+ errors([errorOfType("InternalFailure"), errorOfType("ValidationException")]),
47
+ ),
48
+ )->toBe(false)
49
+ )
50
+
51
+ // `Array.every` is vacuously true on an empty array, which would turn "the
52
+ // endpoint said errors but named none" into an endless resend.
53
+ testSync("treats an empty or untyped error list as final", () => {
54
+ expect(Seed_Client.isTransient(errors([])))->toBe(false)
55
+ expect(Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])))
56
+ ->toBe(false)
57
+ expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
58
+ })
59
+ })
@@ -0,0 +1,64 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Seed_Client$ReventlessSeed from "../src/Seed_Client.res.mjs";
4
+
5
+ function errors(entries) {
6
+ return entries.map(prim => prim);
7
+ }
8
+
9
+ function errorOfType(errorType) {
10
+ return Object.fromEntries([[
11
+ "errorType",
12
+ errorType
13
+ ]]);
14
+ }
15
+
16
+ globalThis.describe("Seed_Client.isTransient:", () => {
17
+ globalThis.test("resends a fault on the endpoint's own side", () => {
18
+ [
19
+ "InternalFailure",
20
+ "ServiceUnavailable",
21
+ "Throttling",
22
+ "TooManyRequestsException"
23
+ ].forEach(t => {
24
+ let entries = [errorOfType(t)];
25
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(true);
26
+ });
27
+ });
28
+ globalThis.test("resends the shape AppSync actually returned", () => {
29
+ let payload = JSON.parse(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`);
30
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(payload)).toBe(true);
31
+ });
32
+ globalThis.test("fails fast on an error that is an answer", () => {
33
+ [
34
+ "ValidationException",
35
+ "Unauthorized",
36
+ "FieldUndefined"
37
+ ].forEach(t => {
38
+ let entries = [errorOfType(t)];
39
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
40
+ });
41
+ });
42
+ globalThis.test("refuses to resend a batch that mixes a real error in", () => {
43
+ let entries = [
44
+ errorOfType("InternalFailure"),
45
+ errorOfType("ValidationException")
46
+ ];
47
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
48
+ });
49
+ globalThis.test("treats an empty or untyped error list as final", () => {
50
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient([].map(prim => prim))).toBe(false);
51
+ let entries = [Object.fromEntries([[
52
+ "message",
53
+ "boom"
54
+ ]])];
55
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
56
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(null)).toBe(false);
57
+ });
58
+ });
59
+
60
+ export {
61
+ errors,
62
+ errorOfType,
63
+ }
64
+ /* Not a pure module */
@@ -1,2 +1,2 @@
1
- #Start(1787252810629)
2
- #Done(1787252810671)
1
+ #Start(1788547299414)
2
+ #Done(1788547299452)
Binary file
Binary file
@@ -1,6 +1,7 @@
1
1
  randjbuildsystem
2
2
  Seed
3
3
  Seed_Client
4
+ Seed_ClientTest
4
5
  Seed_Connect
5
6
  Seed_Prompt
6
7
  Seed_PromptTest
Binary file
Binary file
Binary file
@@ -106,10 +106,57 @@ let login = async (t: t): unit => {
106
106
  t.token = json->field("token")->Option.flatMap(asString)
107
107
  }
108
108
 
109
+ /** Error types the endpoint returns for a fault on its own side rather than a
110
+ problem with the document. They say nothing about the request, so the same
111
+ request sent again can succeed — and across the few hundred commands a seed
112
+ issues, one of these is ordinary rather than exceptional.
113
+
114
+ Everything else — a validation error, an unknown field, a refused token — is
115
+ an answer, and the same answer comes back however many times it is asked. */
116
+ let transientErrorTypes = [
117
+ "InternalFailure",
118
+ "ServiceUnavailable",
119
+ "ServiceUnavailableError",
120
+ "Throttling",
121
+ "ThrottlingException",
122
+ "TooManyRequestsException",
123
+ "RequestTimeout",
124
+ ]
125
+
126
+ /** Every entry must be transient for the document to be worth resending: one
127
+ real error alongside a transient one still means the request was wrong. An
128
+ empty `errors` array is not a reason to retry either. */
129
+ let isTransient = (errors: JSON.t): bool =>
130
+ switch errors->JSON.Decode.array {
131
+ | None | Some([]) => false
132
+ | Some(entries) =>
133
+ entries->Array.every(e =>
134
+ switch e->nodeString("errorType") {
135
+ | Some(errorType) => transientErrorTypes->Array.includes(errorType)
136
+ | None => false
137
+ }
138
+ )
139
+ }
140
+
141
+ let attempts = 4
142
+
109
143
  /**
110
144
  * Sends one GraphQL document and returns its `data`. A GraphQL-level error
111
145
  * aborts the run: a half-seeded store is worse than an empty one, because it
112
146
  * looks like a working dataset.
147
+ *
148
+ * Which is exactly why a fault on the endpoint's own side must not abort it. A
149
+ * seed issues hundreds of commands, many of them concurrently and all of them
150
+ * against runtimes that a preceding reset left cold, so a single
151
+ * `InternalFailure` somewhere in the batch is a normal event. Treating it as
152
+ * fatal threw away every command that had already landed and demanded a full
153
+ * wipe and re-run — for a request that would have succeeded on being asked
154
+ * again. So resend those, backing off between tries, and keep failing fast on
155
+ * everything that is an answer rather than a fault.
156
+ *
157
+ * The backoff is jittered because the commands go out in concurrent batches: a
158
+ * fixed delay would have the whole batch retry in lockstep and rebuild the same
159
+ * burst that provoked the fault.
113
160
  */
114
161
  let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
115
162
  let headers = Dict.fromArray([("content-type", "application/json")])
@@ -120,18 +167,27 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
120
167
  let body = JSON.stringify(
121
168
  JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
122
169
  )
123
- let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
124
- | _ =>
125
- throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
126
- }
127
- let json = await res->responseJson
128
- switch json->field("errors") {
129
- | Some(errors) =>
130
- throw(
131
- Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}`),
132
- )
133
- | None => json->field("data")->Option.getOr(JSON.Encode.null)
170
+ let rec attempt = async (n: int): JSON.t => {
171
+ let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
172
+ | _ =>
173
+ throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
174
+ }
175
+ let json = await res->responseJson
176
+ switch json->field("errors") {
177
+ | Some(errors) =>
178
+ if errors->isTransient && n < attempts {
179
+ await sleep(250 * Int.shiftLeft(1, n - 1) + Int.fromFloat(Math.random() *. 250.))
180
+ await attempt(n + 1)
181
+ } else {
182
+ let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
183
+ throw(
184
+ Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}${tried}`),
185
+ )
186
+ }
187
+ | None => json->field("data")->Option.getOr(JSON.Encode.null)
188
+ }
134
189
  }
190
+ await attempt(1)
135
191
  }
136
192
 
137
193
  // ── Commands ────────────────────────────────────────────────────────────────
Binary file
@@ -0,0 +1,59 @@
1
+ open JestGlobals
2
+
3
+ // A seed issues hundreds of commands against runtimes a preceding reset left
4
+ // cold, so the endpoint faulting on one of them is a normal event rather than an
5
+ // exceptional one. Before the retry, a single `InternalFailure` in that batch
6
+ // aborted the run and left a half-seeded store that could only be recovered by a
7
+ // full wipe and a re-run — for a request that would have succeeded on being
8
+ // asked again.
9
+ //
10
+ // What makes the retry safe is the classifier, so that is what these pin. A
11
+ // fault on the endpoint's side carries no information about the document and is
12
+ // worth resending; anything that is an *answer* — a validation error, an unknown
13
+ // field, a refused token — comes back identical however many times it is asked,
14
+ // and resending it only delays the failure the operator needs to see.
15
+
16
+ let errors = entries => JSON.Encode.array(entries->Array.map(JSON.Encode.object))
17
+
18
+ let errorOfType = errorType =>
19
+ Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
20
+
21
+ describe("Seed_Client.isTransient:", () => {
22
+ testSync("resends a fault on the endpoint's own side", () =>
23
+ ["InternalFailure", "ServiceUnavailable", "Throttling", "TooManyRequestsException"]
24
+ ->Array.forEach(t =>
25
+ expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true)
26
+ )
27
+ )
28
+
29
+ // The exact payload the seed aborted on.
30
+ testSync("resends the shape AppSync actually returned", () => {
31
+ let payload = JSON.parseOrThrow(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`)
32
+ expect(Seed_Client.isTransient(payload))->toBe(true)
33
+ })
34
+
35
+ testSync("fails fast on an error that is an answer", () =>
36
+ ["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(t =>
37
+ expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false)
38
+ )
39
+ )
40
+
41
+ // A real error sitting alongside a transient one still means the request was
42
+ // wrong, and resending it cannot make it right.
43
+ testSync("refuses to resend a batch that mixes a real error in", () =>
44
+ expect(
45
+ Seed_Client.isTransient(
46
+ errors([errorOfType("InternalFailure"), errorOfType("ValidationException")]),
47
+ ),
48
+ )->toBe(false)
49
+ )
50
+
51
+ // `Array.every` is vacuously true on an empty array, which would turn "the
52
+ // endpoint said errors but named none" into an endless resend.
53
+ testSync("treats an empty or untyped error list as final", () => {
54
+ expect(Seed_Client.isTransient(errors([])))->toBe(false)
55
+ expect(Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])))
56
+ ->toBe(false)
57
+ expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
58
+ })
59
+ })
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-seed",
3
- "version": "1.0.0-alpha.17",
3
+ "version": "1.0.0-alpha.19",
4
4
  "description": "Reusable harness for seeding a Reventless platform through its public GraphQL command API",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
7
7
  "yaml": "^2.8.3",
8
- "@reventlessdev/rescript-node": "2.0.0-alpha.8"
8
+ "@reventlessdev/rescript-node": "2.0.0-alpha.9"
9
9
  },
10
10
  "devDependencies": {
11
11
  "rescript": "12.3.0",
@@ -106,10 +106,57 @@ let login = async (t: t): unit => {
106
106
  t.token = json->field("token")->Option.flatMap(asString)
107
107
  }
108
108
 
109
+ /** Error types the endpoint returns for a fault on its own side rather than a
110
+ problem with the document. They say nothing about the request, so the same
111
+ request sent again can succeed — and across the few hundred commands a seed
112
+ issues, one of these is ordinary rather than exceptional.
113
+
114
+ Everything else — a validation error, an unknown field, a refused token — is
115
+ an answer, and the same answer comes back however many times it is asked. */
116
+ let transientErrorTypes = [
117
+ "InternalFailure",
118
+ "ServiceUnavailable",
119
+ "ServiceUnavailableError",
120
+ "Throttling",
121
+ "ThrottlingException",
122
+ "TooManyRequestsException",
123
+ "RequestTimeout",
124
+ ]
125
+
126
+ /** Every entry must be transient for the document to be worth resending: one
127
+ real error alongside a transient one still means the request was wrong. An
128
+ empty `errors` array is not a reason to retry either. */
129
+ let isTransient = (errors: JSON.t): bool =>
130
+ switch errors->JSON.Decode.array {
131
+ | None | Some([]) => false
132
+ | Some(entries) =>
133
+ entries->Array.every(e =>
134
+ switch e->nodeString("errorType") {
135
+ | Some(errorType) => transientErrorTypes->Array.includes(errorType)
136
+ | None => false
137
+ }
138
+ )
139
+ }
140
+
141
+ let attempts = 4
142
+
109
143
  /**
110
144
  * Sends one GraphQL document and returns its `data`. A GraphQL-level error
111
145
  * aborts the run: a half-seeded store is worse than an empty one, because it
112
146
  * looks like a working dataset.
147
+ *
148
+ * Which is exactly why a fault on the endpoint's own side must not abort it. A
149
+ * seed issues hundreds of commands, many of them concurrently and all of them
150
+ * against runtimes that a preceding reset left cold, so a single
151
+ * `InternalFailure` somewhere in the batch is a normal event. Treating it as
152
+ * fatal threw away every command that had already landed and demanded a full
153
+ * wipe and re-run — for a request that would have succeeded on being asked
154
+ * again. So resend those, backing off between tries, and keep failing fast on
155
+ * everything that is an answer rather than a fault.
156
+ *
157
+ * The backoff is jittered because the commands go out in concurrent batches: a
158
+ * fixed delay would have the whole batch retry in lockstep and rebuild the same
159
+ * burst that provoked the fault.
113
160
  */
114
161
  let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
115
162
  let headers = Dict.fromArray([("content-type", "application/json")])
@@ -120,18 +167,27 @@ let gql = async (t: t, ~query: string, ~label: string): JSON.t => {
120
167
  let body = JSON.stringify(
121
168
  JSON.Encode.object(Dict.fromArray([("query", JSON.Encode.string(query))])),
122
169
  )
123
- let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
124
- | _ =>
125
- throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
126
- }
127
- let json = await res->responseJson
128
- switch json->field("errors") {
129
- | Some(errors) =>
130
- throw(
131
- Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}`),
132
- )
133
- | None => json->field("data")->Option.getOr(JSON.Encode.null)
170
+ let rec attempt = async (n: int): JSON.t => {
171
+ let res = try await fetch(t.config.endpoint, {method: "POST", headers, body}) catch {
172
+ | _ =>
173
+ throw(Failed(`${label}: cannot reach ${t.config.endpoint} — is the platform running?`))
174
+ }
175
+ let json = await res->responseJson
176
+ switch json->field("errors") {
177
+ | Some(errors) =>
178
+ if errors->isTransient && n < attempts {
179
+ await sleep(250 * Int.shiftLeft(1, n - 1) + Int.fromFloat(Math.random() *. 250.))
180
+ await attempt(n + 1)
181
+ } else {
182
+ let tried = n > 1 ? `\n gave up after ${n->Int.toString} attempts` : ""
183
+ throw(
184
+ Failed(`${label} failed\n query: ${query}\n response: ${JSON.stringify(errors)}${tried}`),
185
+ )
186
+ }
187
+ | None => json->field("data")->Option.getOr(JSON.Encode.null)
188
+ }
134
189
  }
190
+ await attempt(1)
135
191
  }
136
192
 
137
193
  // ── Commands ────────────────────────────────────────────────────────────────
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
3
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
6
  import * as Seed_Types$ReventlessSeed from "./Seed_Types.res.mjs";
@@ -98,6 +99,32 @@ async function login(t) {
98
99
  t.token = Stdlib_Option.flatMap(field(json, "token"), asString);
99
100
  }
100
101
 
102
+ let transientErrorTypes = [
103
+ "InternalFailure",
104
+ "ServiceUnavailable",
105
+ "ServiceUnavailableError",
106
+ "Throttling",
107
+ "ThrottlingException",
108
+ "TooManyRequestsException",
109
+ "RequestTimeout"
110
+ ];
111
+
112
+ function isTransient(errors) {
113
+ let entries = Stdlib_JSON.Decode.array(errors);
114
+ if (entries !== undefined && entries.length !== 0) {
115
+ return entries.every(e => {
116
+ let errorType = nodeString(e, "errorType");
117
+ if (errorType !== undefined) {
118
+ return transientErrorTypes.includes(errorType);
119
+ } else {
120
+ return false;
121
+ }
122
+ });
123
+ } else {
124
+ return false;
125
+ }
126
+ }
127
+
101
128
  async function gql(t, query, label) {
102
129
  let headers = Object.fromEntries([[
103
130
  "content-type",
@@ -111,30 +138,38 @@ async function gql(t, query, label) {
111
138
  "query",
112
139
  query
113
140
  ]]));
114
- let res;
115
- try {
116
- res = await fetch(t.config.endpoint, {
117
- method: "POST",
118
- headers: headers,
119
- body: body
120
- });
121
- } catch (exn) {
122
- throw {
123
- RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
124
- _1: label + `: cannot reach ` + t.config.endpoint + ` — is the platform running?`,
125
- Error: new Error()
126
- };
127
- }
128
- let json = await res.json();
129
- let errors = field(json, "errors");
130
- if (errors !== undefined) {
141
+ let attempt = async n => {
142
+ let res;
143
+ try {
144
+ res = await fetch(t.config.endpoint, {
145
+ method: "POST",
146
+ headers: headers,
147
+ body: body
148
+ });
149
+ } catch (exn) {
150
+ throw {
151
+ RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
152
+ _1: label + `: cannot reach ` + t.config.endpoint + ` — is the platform running?`,
153
+ Error: new Error()
154
+ };
155
+ }
156
+ let json = await res.json();
157
+ let errors = field(json, "errors");
158
+ if (errors === undefined) {
159
+ return Stdlib_Option.getOr(field(json, "data"), null);
160
+ }
161
+ if (isTransient(errors) && n < 4) {
162
+ await sleep((250 * (1 << (n - 1 | 0)) | 0) + (Math.random() * 250 | 0) | 0);
163
+ return await attempt(n + 1 | 0);
164
+ }
165
+ let tried = n > 1 ? `\n gave up after ` + n.toString() + ` attempts` : "";
131
166
  throw {
132
167
  RE_EXN_ID: Seed_Types$ReventlessSeed.Failed,
133
- _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors),
168
+ _1: label + ` failed\n query: ` + query + `\n response: ` + JSON.stringify(errors) + tried,
134
169
  Error: new Error()
135
170
  };
136
- }
137
- return Stdlib_Option.getOr(field(json, "data"), null);
171
+ };
172
+ return await attempt(1);
138
173
  }
139
174
 
140
175
  let commandResultSelection = `__typename
@@ -269,6 +304,8 @@ async function waitForIds(t, fieldName, ids, timeoutMsOpt) {
269
304
  return Stdlib_Option.getOr(result, 0);
270
305
  }
271
306
 
307
+ let attempts = 4;
308
+
272
309
  export {
273
310
  sleep,
274
311
  make,
@@ -279,6 +316,9 @@ export {
279
316
  asString,
280
317
  nodeString,
281
318
  login,
319
+ transientErrorTypes,
320
+ isTransient,
321
+ attempts,
282
322
  gql,
283
323
  commandResultSelection,
284
324
  send,
@@ -0,0 +1,59 @@
1
+ open JestGlobals
2
+
3
+ // A seed issues hundreds of commands against runtimes a preceding reset left
4
+ // cold, so the endpoint faulting on one of them is a normal event rather than an
5
+ // exceptional one. Before the retry, a single `InternalFailure` in that batch
6
+ // aborted the run and left a half-seeded store that could only be recovered by a
7
+ // full wipe and a re-run — for a request that would have succeeded on being
8
+ // asked again.
9
+ //
10
+ // What makes the retry safe is the classifier, so that is what these pin. A
11
+ // fault on the endpoint's side carries no information about the document and is
12
+ // worth resending; anything that is an *answer* — a validation error, an unknown
13
+ // field, a refused token — comes back identical however many times it is asked,
14
+ // and resending it only delays the failure the operator needs to see.
15
+
16
+ let errors = entries => JSON.Encode.array(entries->Array.map(JSON.Encode.object))
17
+
18
+ let errorOfType = errorType =>
19
+ Dict.fromArray([("errorType", JSON.Encode.string(errorType))])
20
+
21
+ describe("Seed_Client.isTransient:", () => {
22
+ testSync("resends a fault on the endpoint's own side", () =>
23
+ ["InternalFailure", "ServiceUnavailable", "Throttling", "TooManyRequestsException"]
24
+ ->Array.forEach(t =>
25
+ expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(true)
26
+ )
27
+ )
28
+
29
+ // The exact payload the seed aborted on.
30
+ testSync("resends the shape AppSync actually returned", () => {
31
+ let payload = JSON.parseOrThrow(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`)
32
+ expect(Seed_Client.isTransient(payload))->toBe(true)
33
+ })
34
+
35
+ testSync("fails fast on an error that is an answer", () =>
36
+ ["ValidationException", "Unauthorized", "FieldUndefined"]->Array.forEach(t =>
37
+ expect(Seed_Client.isTransient(errors([errorOfType(t)])))->toBe(false)
38
+ )
39
+ )
40
+
41
+ // A real error sitting alongside a transient one still means the request was
42
+ // wrong, and resending it cannot make it right.
43
+ testSync("refuses to resend a batch that mixes a real error in", () =>
44
+ expect(
45
+ Seed_Client.isTransient(
46
+ errors([errorOfType("InternalFailure"), errorOfType("ValidationException")]),
47
+ ),
48
+ )->toBe(false)
49
+ )
50
+
51
+ // `Array.every` is vacuously true on an empty array, which would turn "the
52
+ // endpoint said errors but named none" into an endless resend.
53
+ testSync("treats an empty or untyped error list as final", () => {
54
+ expect(Seed_Client.isTransient(errors([])))->toBe(false)
55
+ expect(Seed_Client.isTransient(errors([Dict.fromArray([("message", JSON.Encode.string("boom"))])])))
56
+ ->toBe(false)
57
+ expect(Seed_Client.isTransient(JSON.Encode.null))->toBe(false)
58
+ })
59
+ })
@@ -0,0 +1,64 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Seed_Client$ReventlessSeed from "../src/Seed_Client.res.mjs";
4
+
5
+ function errors(entries) {
6
+ return entries.map(prim => prim);
7
+ }
8
+
9
+ function errorOfType(errorType) {
10
+ return Object.fromEntries([[
11
+ "errorType",
12
+ errorType
13
+ ]]);
14
+ }
15
+
16
+ globalThis.describe("Seed_Client.isTransient:", () => {
17
+ globalThis.test("resends a fault on the endpoint's own side", () => {
18
+ [
19
+ "InternalFailure",
20
+ "ServiceUnavailable",
21
+ "Throttling",
22
+ "TooManyRequestsException"
23
+ ].forEach(t => {
24
+ let entries = [errorOfType(t)];
25
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(true);
26
+ });
27
+ });
28
+ globalThis.test("resends the shape AppSync actually returned", () => {
29
+ let payload = JSON.parse(`[{"errorType":"InternalFailure","message":"An internal failure occurred."}]`);
30
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(payload)).toBe(true);
31
+ });
32
+ globalThis.test("fails fast on an error that is an answer", () => {
33
+ [
34
+ "ValidationException",
35
+ "Unauthorized",
36
+ "FieldUndefined"
37
+ ].forEach(t => {
38
+ let entries = [errorOfType(t)];
39
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
40
+ });
41
+ });
42
+ globalThis.test("refuses to resend a batch that mixes a real error in", () => {
43
+ let entries = [
44
+ errorOfType("InternalFailure"),
45
+ errorOfType("ValidationException")
46
+ ];
47
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
48
+ });
49
+ globalThis.test("treats an empty or untyped error list as final", () => {
50
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient([].map(prim => prim))).toBe(false);
51
+ let entries = [Object.fromEntries([[
52
+ "message",
53
+ "boom"
54
+ ]])];
55
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(entries.map(prim => prim))).toBe(false);
56
+ globalThis.expect(Seed_Client$ReventlessSeed.isTransient(null)).toBe(false);
57
+ });
58
+ });
59
+
60
+ export {
61
+ errors,
62
+ errorOfType,
63
+ }
64
+ /* Not a pure module */