@mnemonik/shared 7.147.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.
- package/dist/hookTimeouts.d.ts +62 -0
- package/dist/hookTimeouts.d.ts.map +1 -1
- package/dist/hookTimeouts.js +97 -0
- package/dist/hookTimeouts.js.map +1 -1
- package/package.json +1 -1
- package/src/hookTimeouts.ts +115 -0
package/dist/hookTimeouts.d.ts
CHANGED
|
@@ -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"}
|
package/dist/hookTimeouts.js
CHANGED
|
@@ -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
|
package/dist/hookTimeouts.js.map
CHANGED
|
@@ -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
package/src/hookTimeouts.ts
CHANGED
|
@@ -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
|
+
}
|