@mnemonik/shared 7.148.0 → 7.149.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.
@@ -93,4 +93,66 @@ export declare function withHookTimeout(ms: number): {
93
93
  signal: AbortSignal;
94
94
  cleanup: () => void;
95
95
  };
96
+ /**
97
+ * One second attempt on the main hook call (POST /api/v1/hooks/injections).
98
+ *
99
+ * Why it exists: on 24 October 2026 about a third of one machine's hook calls
100
+ * were lost for 50 minutes to a stall between Cloudflare and one load-balancer
101
+ * node. The server had accepted those requests and was waiting for a body that
102
+ * never finished arriving. A second request opens a new connection and
103
+ * normally lands on a healthy path. The call is idempotent by design (the
104
+ * injection bus dedups on the server), so repeating it is safe.
105
+ *
106
+ * Shape: the whole call, both attempts included, still ends inside `budgetMs`,
107
+ * so the budget every hook event is checked against (tests/HookEventBudgets
108
+ * .test.ts) and the host's kill limit do not move. The first attempt gets
109
+ * HOOK_FIRST_ATTEMPT_SHARE of the budget; the second gets whatever is left.
110
+ * A first attempt that fails fast leaves the second nearly the whole budget.
111
+ *
112
+ * What is retried: a timeout, a connection error, and the gateway statuses
113
+ * that mean the request never reached the application (408, 502, 503, 504 and
114
+ * Cloudflare's 52x). Nothing else is: a 4xx or a 500 repeats exactly, and a
115
+ * response that arrives is final.
116
+ *
117
+ * Nothing is queued or stored on the client. A call that fails twice is dropped,
118
+ * as before. The server keeps an answer for 15 s so the second attempt can have
119
+ * it when the first attempt gave up on an answer that was already decided.
120
+ */
121
+ export declare const HOOK_FIRST_ATTEMPT_SHARE = 0.65;
122
+ /** Below this much budget left, a second attempt cannot complete a round trip. */
123
+ export declare const HOOK_SECOND_ATTEMPT_MIN_MS = 300;
124
+ /** Sent on every attempt so the server can count how many calls needed a second one. */
125
+ export declare const HOOK_ATTEMPT_HEADER = "x-mnemonik-hook-attempt";
126
+ /**
127
+ * Sent on both attempts with the same value. The server keeps the first
128
+ * attempt's answer for a few seconds under this id and hands it to the second
129
+ * attempt, so an answer decided after the first attempt gave up is delivered
130
+ * rather than lost, and nothing is counted twice (src/server/hookReplay.ts).
131
+ */
132
+ export declare const HOOK_REQUEST_ID_HEADER = "x-mnemonik-hook-request-id";
133
+ /**
134
+ * Sent on every attempt: how many milliseconds this attempt may still take.
135
+ * The server never waits for a first attempt's answer longer than this less a
136
+ * margin for the return trip, so it does not hold a request for a client that
137
+ * has already given up.
138
+ */
139
+ export declare const HOOK_BUDGET_HEADER = "x-mnemonik-hook-budget-ms";
140
+ /** Statuses that mean the request did not reach, or was not served by, the application. */
141
+ export declare function isRetryableHookStatus(status: number): boolean;
142
+ /** Timeouts and connection failures. A parse error or a bug in the caller is not transient. */
143
+ export declare function isTransientHookFetchError(error: unknown): boolean;
144
+ export type HookAttemptResult<T> = {
145
+ done: true;
146
+ value: T;
147
+ } | {
148
+ done: false;
149
+ };
150
+ /**
151
+ * Run `attempt` and, when it ends in a transient failure, run it once more
152
+ * inside the rest of `budgetMs`. Returns the value of the attempt that
153
+ * finished, or null when neither did. `attempt` returns `{ done: false }` for
154
+ * a retryable response and throws for a transport failure; anything else it
155
+ * returns is final.
156
+ */
157
+ export declare function withSecondAttempt<T>(budgetMs: number, attempt: (signal: AbortSignal, attemptNumber: 1 | 2, requestId: string, budgetMs: number) => Promise<HookAttemptResult<T>>, clock?: () => number): Promise<T | null>;
96
158
  //# sourceMappingURL=hookTimeouts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hookTimeouts.d.ts","sourceRoot":"","sources":["../src/hookTimeouts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC,2EAA2E;AAC3E,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,2FAA2F;AAC3F,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB;aAC/B,WAAW,EAAE,IAAI;aACjB,OAAO,EAAE,IAAI;aACb,KAAK,EAAE,IAAI;aACX,IAAI,EAAE,IAAI;aACV,MAAM,EAAE,KAAM;CACN,CAAC;AACX,MAAM,MAAM,QAAQ,GAAG,MAAM,OAAO,oBAAoB,CAAC;AAEzD;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAErD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAIxF"}
1
+ {"version":3,"file":"hookTimeouts.d.ts","sourceRoot":"","sources":["../src/hookTimeouts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC,2EAA2E;AAC3E,eAAO,MAAM,oBAAoB,MAAM,CAAC;AAExC,2FAA2F;AAC3F,eAAO,MAAM,oBAAoB,OAAO,CAAC;AAEzC;;;;;;GAMG;AACH,eAAO,MAAM,uBAAuB,OAAO,CAAC;AAE5C;;;;GAIG;AACH,eAAO,MAAM,qBAAqB,OAAO,CAAC;AAE1C;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB;aAC/B,WAAW,EAAE,IAAI;aACjB,OAAO,EAAE,IAAI;aACb,KAAK,EAAE,IAAI;aACX,IAAI,EAAE,IAAI;aACV,MAAM,EAAE,KAAM;CACN,CAAC;AACX,MAAM,MAAM,QAAQ,GAAG,MAAM,OAAO,oBAAoB,CAAC;AAEzD;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAC;AAE1C;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,MAAM,CAAC;AAEzC,8EAA8E;AAC9E,wBAAgB,cAAc,CAAC,IAAI,EAAE,QAAQ,GAAG,MAAM,CAErD;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAAC,EAAE,EAAE,MAAM,GAAG;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,CAIxF;AAED;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,eAAO,MAAM,wBAAwB,OAAO,CAAC;AAE7C,kFAAkF;AAClF,eAAO,MAAM,0BAA0B,MAAM,CAAC;AAE9C,wFAAwF;AACxF,eAAO,MAAM,mBAAmB,4BAA4B,CAAC;AAE7D;;;;;GAKG;AACH,eAAO,MAAM,sBAAsB,+BAA+B,CAAC;AAEnE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,8BAA8B,CAAC;AAE9D,2FAA2F;AAC3F,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAS7D;AAED,+FAA+F;AAC/F,wBAAgB,yBAAyB,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CASjE;AAED,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAAI;IAAE,IAAI,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAE9E;;;;;;GAMG;AACH,wBAAsB,iBAAiB,CAAC,CAAC,EACvC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,CACP,MAAM,EAAE,WAAW,EACnB,aAAa,EAAE,CAAC,GAAG,CAAC,EACpB,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,KACb,OAAO,CAAC,iBAAiB,CAAC,CAAC,CAAC,CAAC,EAClC,KAAK,GAAE,MAAM,MAAiB,GAC7B,OAAO,CAAC,CAAC,GAAG,IAAI,CAAC,CAsBnB"}
@@ -95,4 +95,101 @@ export function withHookTimeout(ms) {
95
95
  const timer = setTimeout(() => ac.abort(), ms);
96
96
  return { signal: ac.signal, cleanup: () => clearTimeout(timer) };
97
97
  }
98
+ /**
99
+ * One second attempt on the main hook call (POST /api/v1/hooks/injections).
100
+ *
101
+ * Why it exists: on 24 October 2026 about a third of one machine's hook calls
102
+ * were lost for 50 minutes to a stall between Cloudflare and one load-balancer
103
+ * node. The server had accepted those requests and was waiting for a body that
104
+ * never finished arriving. A second request opens a new connection and
105
+ * normally lands on a healthy path. The call is idempotent by design (the
106
+ * injection bus dedups on the server), so repeating it is safe.
107
+ *
108
+ * Shape: the whole call, both attempts included, still ends inside `budgetMs`,
109
+ * so the budget every hook event is checked against (tests/HookEventBudgets
110
+ * .test.ts) and the host's kill limit do not move. The first attempt gets
111
+ * HOOK_FIRST_ATTEMPT_SHARE of the budget; the second gets whatever is left.
112
+ * A first attempt that fails fast leaves the second nearly the whole budget.
113
+ *
114
+ * What is retried: a timeout, a connection error, and the gateway statuses
115
+ * that mean the request never reached the application (408, 502, 503, 504 and
116
+ * Cloudflare's 52x). Nothing else is: a 4xx or a 500 repeats exactly, and a
117
+ * response that arrives is final.
118
+ *
119
+ * Nothing is queued or stored on the client. A call that fails twice is dropped,
120
+ * as before. The server keeps an answer for 15 s so the second attempt can have
121
+ * it when the first attempt gave up on an answer that was already decided.
122
+ */
123
+ export const HOOK_FIRST_ATTEMPT_SHARE = 0.65;
124
+ /** Below this much budget left, a second attempt cannot complete a round trip. */
125
+ export const HOOK_SECOND_ATTEMPT_MIN_MS = 300;
126
+ /** Sent on every attempt so the server can count how many calls needed a second one. */
127
+ export const HOOK_ATTEMPT_HEADER = 'x-mnemonik-hook-attempt';
128
+ /**
129
+ * Sent on both attempts with the same value. The server keeps the first
130
+ * attempt's answer for a few seconds under this id and hands it to the second
131
+ * attempt, so an answer decided after the first attempt gave up is delivered
132
+ * rather than lost, and nothing is counted twice (src/server/hookReplay.ts).
133
+ */
134
+ export const HOOK_REQUEST_ID_HEADER = 'x-mnemonik-hook-request-id';
135
+ /**
136
+ * Sent on every attempt: how many milliseconds this attempt may still take.
137
+ * The server never waits for a first attempt's answer longer than this less a
138
+ * margin for the return trip, so it does not hold a request for a client that
139
+ * has already given up.
140
+ */
141
+ export const HOOK_BUDGET_HEADER = 'x-mnemonik-hook-budget-ms';
142
+ /** Statuses that mean the request did not reach, or was not served by, the application. */
143
+ export function isRetryableHookStatus(status) {
144
+ return (status === 408 ||
145
+ status === 502 ||
146
+ status === 503 ||
147
+ status === 504 ||
148
+ (status >= 520 && status <= 527) ||
149
+ status === 530);
150
+ }
151
+ /** Timeouts and connection failures. A parse error or a bug in the caller is not transient. */
152
+ export function isTransientHookFetchError(error) {
153
+ if (!(error instanceof Error))
154
+ return false;
155
+ if (error.name === 'AbortError' || error.name === 'TimeoutError')
156
+ return true;
157
+ // undici reports a connection failure as TypeError('fetch failed') and a body
158
+ // cut off mid-transfer as TypeError('terminated'). Any other TypeError is a bug.
159
+ return (error instanceof TypeError &&
160
+ (error.message === 'fetch failed' || error.message === 'terminated'));
161
+ }
162
+ /**
163
+ * Run `attempt` and, when it ends in a transient failure, run it once more
164
+ * inside the rest of `budgetMs`. Returns the value of the attempt that
165
+ * finished, or null when neither did. `attempt` returns `{ done: false }` for
166
+ * a retryable response and throws for a transport failure; anything else it
167
+ * returns is final.
168
+ */
169
+ export async function withSecondAttempt(budgetMs, attempt, clock = Date.now) {
170
+ const startedAt = clock();
171
+ const requestId = globalThis.crypto.randomUUID();
172
+ const run = async (attemptNumber, timeoutMs) => {
173
+ const { signal, cleanup } = withHookTimeout(timeoutMs);
174
+ try {
175
+ return await attempt(signal, attemptNumber, requestId, timeoutMs);
176
+ }
177
+ catch (error) {
178
+ if (!isTransientHookFetchError(error))
179
+ throw error;
180
+ return { done: false };
181
+ }
182
+ finally {
183
+ cleanup();
184
+ }
185
+ };
186
+ const first = await run(1, Math.floor(budgetMs * HOOK_FIRST_ATTEMPT_SHARE));
187
+ if (first.done)
188
+ return first.value;
189
+ const remainingMs = budgetMs - (clock() - startedAt);
190
+ if (remainingMs < HOOK_SECOND_ATTEMPT_MIN_MS)
191
+ return null;
192
+ const second = await run(2, Math.floor(remainingMs));
193
+ return second.done ? second.value : null;
194
+ }
98
195
  //# sourceMappingURL=hookTimeouts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"hookTimeouts.js","sourceRoot":"","sources":["../src/hookTimeouts.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;;;;;;;;GAeG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,IAAI,CAAC;AAErC,2EAA2E;AAC3E,MAAM,CAAC,MAAM,oBAAoB,GAAG,GAAG,CAAC;AAExC,2FAA2F;AAC3F,MAAM,CAAC,MAAM,oBAAoB,GAAG,IAAI,CAAC;AAEzC;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,IAAI,CAAC;AAE5C;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,IAAI,CAAC;AAE1C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAAG;IAClC,WAAW,EAAE,IAAI;IACjB,OAAO,EAAE,IAAI;IACb,KAAK,EAAE,IAAI;IACX,IAAI,EAAE,IAAI;IACV,MAAM,EAAE,MAAM;CACN,CAAC;AAGX;;;;;GAKG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,GAAG,CAAC;AAE1C;;;;;GAKG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,GAAG,CAAC;AAEzC,8EAA8E;AAC9E,MAAM,UAAU,cAAc,CAAC,IAAc;IAC3C,OAAO,oBAAoB,CAAC,IAAI,CAAC,GAAG,sBAAsB,GAAG,qBAAqB,CAAC;AACrF,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,eAAe,CAAC,EAAU;IACxC,MAAM,EAAE,GAAG,IAAI,eAAe,EAAE,CAAC;IACjC,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,KAAK,EAAE,EAAE,EAAE,CAAC,CAAC;IAC/C,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,CAAC,EAAE,CAAC;AACnE,CAAC"}
1
+ {"version":3,"file":"hookTimeouts.js","sourceRoot":"","sources":["../src/hookTimeouts.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mnemonik/shared",
3
- "version": "7.148.0",
3
+ "version": "7.149.0",
4
4
  "description": "Shared constants and utilities for Mnemonik packages",
5
5
  "repository": {
6
6
  "type": "git",
@@ -106,3 +106,118 @@ export function withHookTimeout(ms: number): { signal: AbortSignal; cleanup: ()
106
106
  const timer = setTimeout(() => ac.abort(), ms);
107
107
  return { signal: ac.signal, cleanup: () => clearTimeout(timer) };
108
108
  }
109
+
110
+ /**
111
+ * One second attempt on the main hook call (POST /api/v1/hooks/injections).
112
+ *
113
+ * Why it exists: on 24 October 2026 about a third of one machine's hook calls
114
+ * were lost for 50 minutes to a stall between Cloudflare and one load-balancer
115
+ * node. The server had accepted those requests and was waiting for a body that
116
+ * never finished arriving. A second request opens a new connection and
117
+ * normally lands on a healthy path. The call is idempotent by design (the
118
+ * injection bus dedups on the server), so repeating it is safe.
119
+ *
120
+ * Shape: the whole call, both attempts included, still ends inside `budgetMs`,
121
+ * so the budget every hook event is checked against (tests/HookEventBudgets
122
+ * .test.ts) and the host's kill limit do not move. The first attempt gets
123
+ * HOOK_FIRST_ATTEMPT_SHARE of the budget; the second gets whatever is left.
124
+ * A first attempt that fails fast leaves the second nearly the whole budget.
125
+ *
126
+ * What is retried: a timeout, a connection error, and the gateway statuses
127
+ * that mean the request never reached the application (408, 502, 503, 504 and
128
+ * Cloudflare's 52x). Nothing else is: a 4xx or a 500 repeats exactly, and a
129
+ * response that arrives is final.
130
+ *
131
+ * Nothing is queued or stored on the client. A call that fails twice is dropped,
132
+ * as before. The server keeps an answer for 15 s so the second attempt can have
133
+ * it when the first attempt gave up on an answer that was already decided.
134
+ */
135
+ export const HOOK_FIRST_ATTEMPT_SHARE = 0.65;
136
+
137
+ /** Below this much budget left, a second attempt cannot complete a round trip. */
138
+ export const HOOK_SECOND_ATTEMPT_MIN_MS = 300;
139
+
140
+ /** Sent on every attempt so the server can count how many calls needed a second one. */
141
+ export const HOOK_ATTEMPT_HEADER = 'x-mnemonik-hook-attempt';
142
+
143
+ /**
144
+ * Sent on both attempts with the same value. The server keeps the first
145
+ * attempt's answer for a few seconds under this id and hands it to the second
146
+ * attempt, so an answer decided after the first attempt gave up is delivered
147
+ * rather than lost, and nothing is counted twice (src/server/hookReplay.ts).
148
+ */
149
+ export const HOOK_REQUEST_ID_HEADER = 'x-mnemonik-hook-request-id';
150
+
151
+ /**
152
+ * Sent on every attempt: how many milliseconds this attempt may still take.
153
+ * The server never waits for a first attempt's answer longer than this less a
154
+ * margin for the return trip, so it does not hold a request for a client that
155
+ * has already given up.
156
+ */
157
+ export const HOOK_BUDGET_HEADER = 'x-mnemonik-hook-budget-ms';
158
+
159
+ /** Statuses that mean the request did not reach, or was not served by, the application. */
160
+ export function isRetryableHookStatus(status: number): boolean {
161
+ return (
162
+ status === 408 ||
163
+ status === 502 ||
164
+ status === 503 ||
165
+ status === 504 ||
166
+ (status >= 520 && status <= 527) ||
167
+ status === 530
168
+ );
169
+ }
170
+
171
+ /** Timeouts and connection failures. A parse error or a bug in the caller is not transient. */
172
+ export function isTransientHookFetchError(error: unknown): boolean {
173
+ if (!(error instanceof Error)) return false;
174
+ if (error.name === 'AbortError' || error.name === 'TimeoutError') return true;
175
+ // undici reports a connection failure as TypeError('fetch failed') and a body
176
+ // cut off mid-transfer as TypeError('terminated'). Any other TypeError is a bug.
177
+ return (
178
+ error instanceof TypeError &&
179
+ (error.message === 'fetch failed' || error.message === 'terminated')
180
+ );
181
+ }
182
+
183
+ export type HookAttemptResult<T> = { done: true; value: T } | { done: false };
184
+
185
+ /**
186
+ * Run `attempt` and, when it ends in a transient failure, run it once more
187
+ * inside the rest of `budgetMs`. Returns the value of the attempt that
188
+ * finished, or null when neither did. `attempt` returns `{ done: false }` for
189
+ * a retryable response and throws for a transport failure; anything else it
190
+ * returns is final.
191
+ */
192
+ export async function withSecondAttempt<T>(
193
+ budgetMs: number,
194
+ attempt: (
195
+ signal: AbortSignal,
196
+ attemptNumber: 1 | 2,
197
+ requestId: string,
198
+ budgetMs: number
199
+ ) => Promise<HookAttemptResult<T>>,
200
+ clock: () => number = Date.now
201
+ ): Promise<T | null> {
202
+ const startedAt = clock();
203
+ const requestId = globalThis.crypto.randomUUID();
204
+ const run = async (attemptNumber: 1 | 2, timeoutMs: number): Promise<HookAttemptResult<T>> => {
205
+ const { signal, cleanup } = withHookTimeout(timeoutMs);
206
+ try {
207
+ return await attempt(signal, attemptNumber, requestId, timeoutMs);
208
+ } catch (error) {
209
+ if (!isTransientHookFetchError(error)) throw error;
210
+ return { done: false };
211
+ } finally {
212
+ cleanup();
213
+ }
214
+ };
215
+
216
+ const first = await run(1, Math.floor(budgetMs * HOOK_FIRST_ATTEMPT_SHARE));
217
+ if (first.done) return first.value;
218
+
219
+ const remainingMs = budgetMs - (clock() - startedAt);
220
+ if (remainingMs < HOOK_SECOND_ATTEMPT_MIN_MS) return null;
221
+ const second = await run(2, Math.floor(remainingMs));
222
+ return second.done ? second.value : null;
223
+ }