@blaxel/core 0.3.9-preview.239 → 0.3.10-preview.241
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/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/common/h2fetch.js +120 -17
- package/dist/cjs/common/settings.js +2 -2
- package/dist/cjs/types/common/h2fetch.d.ts +14 -0
- package/dist/cjs-browser/.tsbuildinfo +1 -1
- package/dist/cjs-browser/common/h2fetch.js +1 -0
- package/dist/cjs-browser/common/settings.js +2 -2
- package/dist/cjs-browser/types/common/h2fetch.d.ts +14 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/common/h2fetch.js +116 -17
- package/dist/esm/common/settings.js +2 -2
- package/dist/esm-browser/.tsbuildinfo +1 -1
- package/dist/esm-browser/common/h2fetch.js +1 -0
- package/dist/esm-browser/common/settings.js +2 -2
- package/package.json +1 -1
|
@@ -1,7 +1,35 @@
|
|
|
1
|
+
import http2 from "http2";
|
|
1
2
|
import { settings } from "./settings.js";
|
|
2
3
|
import { refH2SessionForActiveRequest } from "./h2ref.js";
|
|
3
4
|
const MIN_H2_SESSION_MAX_LISTENERS = 64;
|
|
4
5
|
const sessionsWithListenerBudget = new WeakSet();
|
|
6
|
+
// Number of transparent re-sends the gateway performs for a request the peer
|
|
7
|
+
// PROVABLY never processed (see `isUnprocessedRequestError`). One is enough:
|
|
8
|
+
// the re-send runs on a freshly established session, so a second failure means
|
|
9
|
+
// the drain is not a one-off connection recycle and the caller should see it.
|
|
10
|
+
const MAX_UNPROCESSED_RESENDS = 1;
|
|
11
|
+
/**
|
|
12
|
+
* True when the peer guaranteed it never processed the request, so re-sending it
|
|
13
|
+
* cannot duplicate a side effect — even for a non-idempotent method.
|
|
14
|
+
*
|
|
15
|
+
* HTTP/2 gives two such guarantees:
|
|
16
|
+
* - GOAWAY carries `lastStreamID`: every stream with a higher id was not and
|
|
17
|
+
* will not be processed (RFC 9113 §6.8).
|
|
18
|
+
* - RST_STREAM with REFUSED_STREAM means the peer took no action on it.
|
|
19
|
+
*
|
|
20
|
+
* This is strictly stronger than `isTransientResetError` in transient-retry.ts,
|
|
21
|
+
* which classifies errors that are merely *likely* safe and therefore only
|
|
22
|
+
* covers idempotent operations.
|
|
23
|
+
*/
|
|
24
|
+
export function isUnprocessedRequestError(error) {
|
|
25
|
+
return (!!error &&
|
|
26
|
+
typeof error === "object" &&
|
|
27
|
+
error.blaxelUnprocessedRequest === true);
|
|
28
|
+
}
|
|
29
|
+
function markUnprocessed(error) {
|
|
30
|
+
error.blaxelUnprocessedRequest = true;
|
|
31
|
+
return error;
|
|
32
|
+
}
|
|
5
33
|
/**
|
|
6
34
|
* Per-domain async semaphore that bounds the number of in-flight HTTP/2
|
|
7
35
|
* requests against a single edge domain (one H2 session). The cap is keyed
|
|
@@ -143,13 +171,15 @@ export function createH2Fetch(session) {
|
|
|
143
171
|
* 2. get a live session from the pool,
|
|
144
172
|
* 3. send the request on it,
|
|
145
173
|
* 4. evict the session if the send fails after a stream was opened,
|
|
146
|
-
* 5.
|
|
174
|
+
* 5. re-send once on a fresh session when the peer provably never processed
|
|
175
|
+
* the request (`isUnprocessedRequestError`),
|
|
176
|
+
* 6. fall back to globalThis.fetch when the pool has no usable session.
|
|
147
177
|
*
|
|
148
178
|
* This is the chokepoint where reliability behavior that must protect EVERY
|
|
149
|
-
* consumer belongs: the open-stream concurrency limit
|
|
150
|
-
* timeouts, typed errors, and observability in later phases.
|
|
151
|
-
* (instead of re-implementing it per entry point) is what
|
|
152
|
-
* "fixed on one path, still broken on another" regressions.
|
|
179
|
+
* consumer belongs: the open-stream concurrency limit and the unprocessed-request
|
|
180
|
+
* re-send today, and timeouts, typed errors, and observability in later phases.
|
|
181
|
+
* Adding it once here (instead of re-implementing it per entry point) is what
|
|
182
|
+
* stops the recurring "fixed on one path, still broken on another" regressions.
|
|
153
183
|
*
|
|
154
184
|
* `send` performs the actual wire send on a live session; the caller supplies it
|
|
155
185
|
* so the gateway stays agnostic to the `Request` vs `(url, init)` call shapes,
|
|
@@ -158,6 +188,25 @@ export function createH2Fetch(session) {
|
|
|
158
188
|
* stream is opened).
|
|
159
189
|
*/
|
|
160
190
|
async function h2GatewayRequest(pool, domain, send, fallback) {
|
|
191
|
+
for (let attempt = 0;; attempt++) {
|
|
192
|
+
try {
|
|
193
|
+
return await h2GatewayAttempt(pool, domain, send, fallback);
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
// The peer drained the connection without processing this request (a
|
|
197
|
+
// gateway recycling the session, a rolling deploy, or a route moving).
|
|
198
|
+
// Nothing reached origin, so re-send once on a fresh session instead of
|
|
199
|
+
// surfacing a transport failure the caller cannot act on. Safe for every
|
|
200
|
+
// method — the unprocessed guarantee comes from the peer, not a guess
|
|
201
|
+
// about idempotency (unlike retryOnTransientReset).
|
|
202
|
+
if (attempt < MAX_UNPROCESSED_RESENDS && isUnprocessedRequestError(err)) {
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
throw err;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async function h2GatewayAttempt(pool, domain, send, fallback) {
|
|
161
210
|
// Take the slot here, but hand its release to the send path: it is held for
|
|
162
211
|
// the OPEN-STREAM lifetime and freed on the stream terminal (ENG-2678). A
|
|
163
212
|
// request that never opens a stream on the shared session (the fallback goes
|
|
@@ -206,7 +255,7 @@ async function h2GatewayRequest(pool, domain, send, fallback) {
|
|
|
206
255
|
* before any H2 frames are sent.
|
|
207
256
|
*/
|
|
208
257
|
export function createPoolBackedH2Fetch(pool, domain) {
|
|
209
|
-
return (input) => h2GatewayRequest(pool, domain, (session, options) => _h2Request(session, input, options), () =>
|
|
258
|
+
return (input) => h2GatewayRequest(pool, domain, (session, options) => _h2Request(session, input, options), () => fallbackFetchForRequest(input));
|
|
210
259
|
}
|
|
211
260
|
/**
|
|
212
261
|
* Pool-backed H2 request taking raw url + init (skips Request allocation),
|
|
@@ -278,6 +327,30 @@ function h2RequestDirectInternal(session, url, init, options) {
|
|
|
278
327
|
}
|
|
279
328
|
return _h2Send(session, h2Headers, body, init?.signal ?? null, url, init, options);
|
|
280
329
|
}
|
|
330
|
+
// A `Request` body can only be read once, so the buffered bytes are memoized
|
|
331
|
+
// per Request: the gateway's unprocessed re-send calls `_h2Request` again with
|
|
332
|
+
// the same object, which would otherwise throw "body already read".
|
|
333
|
+
const bufferedRequestBodies = new WeakMap();
|
|
334
|
+
/**
|
|
335
|
+
* `globalThis.fetch` fallback for a Request whose body may already have been
|
|
336
|
+
* buffered by an earlier attempt: re-sending the consumed Request object itself
|
|
337
|
+
* would throw, so the memoized bytes are replayed instead.
|
|
338
|
+
*/
|
|
339
|
+
function fallbackFetchForRequest(input) {
|
|
340
|
+
if (!bufferedRequestBodies.has(input))
|
|
341
|
+
return globalThis.fetch(input);
|
|
342
|
+
// Re-wrap the original Request so every option it carries (redirect,
|
|
343
|
+
// credentials, mode, cache, ...) survives; only the body is substituted.
|
|
344
|
+
return globalThis.fetch(new Request(input, { body: bufferedRequestBodies.get(input) }));
|
|
345
|
+
}
|
|
346
|
+
async function bufferRequestBody(input) {
|
|
347
|
+
if (bufferedRequestBodies.has(input)) {
|
|
348
|
+
return bufferedRequestBodies.get(input);
|
|
349
|
+
}
|
|
350
|
+
const body = input.body ? Buffer.from(await input.arrayBuffer()) : undefined;
|
|
351
|
+
bufferedRequestBodies.set(input, body);
|
|
352
|
+
return body;
|
|
353
|
+
}
|
|
281
354
|
async function _h2Request(session, input, options) {
|
|
282
355
|
const url = new URL(input.url);
|
|
283
356
|
const method = input.method || "GET";
|
|
@@ -291,12 +364,9 @@ async function _h2Request(session, input, options) {
|
|
|
291
364
|
continue;
|
|
292
365
|
h2Headers[key] = value;
|
|
293
366
|
}
|
|
294
|
-
|
|
295
|
-
if (
|
|
296
|
-
|
|
297
|
-
if (!h2Headers["content-length"]) {
|
|
298
|
-
h2Headers["content-length"] = body.byteLength;
|
|
299
|
-
}
|
|
367
|
+
const body = await bufferRequestBody(input);
|
|
368
|
+
if (body && !h2Headers["content-length"]) {
|
|
369
|
+
h2Headers["content-length"] = body.byteLength;
|
|
300
370
|
}
|
|
301
371
|
return _h2Send(session, h2Headers, body, input.signal, input.url, {
|
|
302
372
|
method,
|
|
@@ -312,6 +382,8 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
|
|
|
312
382
|
let streamController = null;
|
|
313
383
|
let streamClosed = false;
|
|
314
384
|
let req = null;
|
|
385
|
+
// `lastStreamID` from the peer's GOAWAY, once seen (null = no GOAWAY).
|
|
386
|
+
let goawayLastStreamID = null;
|
|
315
387
|
let releaseSessionRef = () => { };
|
|
316
388
|
// The per-domain open-stream slot (idempotent; no-op for the non-pool
|
|
317
389
|
// transports). Held for the OPEN-STREAM lifetime and released alongside the
|
|
@@ -357,14 +429,41 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
|
|
|
357
429
|
req?.close();
|
|
358
430
|
reject(err);
|
|
359
431
|
};
|
|
432
|
+
// RFC 9113 §6.8: a GOAWAY's `lastStreamID` is the highest stream the peer
|
|
433
|
+
// processed or may still process, so any stream above it was refused
|
|
434
|
+
// outright. A stream with no id never put HEADERS on the wire at all. Either
|
|
435
|
+
// way the request cannot have reached origin, which is what lets the gateway
|
|
436
|
+
// re-send it for ANY method.
|
|
437
|
+
const streamWasNeverProcessed = () => {
|
|
438
|
+
if (goawayLastStreamID === null)
|
|
439
|
+
return false;
|
|
440
|
+
const id = req?.id;
|
|
441
|
+
if (typeof id !== "number")
|
|
442
|
+
return true;
|
|
443
|
+
return id > goawayLastStreamID;
|
|
444
|
+
};
|
|
445
|
+
// REFUSED_STREAM carries the same "took no action" guarantee as a GOAWAY
|
|
446
|
+
// above lastStreamID, and Node destroys streams the GOAWAY refused with
|
|
447
|
+
// ERR_HTTP2_GOAWAY_SESSION — whichever of the stream or session listener
|
|
448
|
+
// fires first, the error reaching the caller is flagged identically.
|
|
449
|
+
const markIfNeverProcessed = (err) => {
|
|
450
|
+
const streamError = err;
|
|
451
|
+
if (streamWasNeverProcessed() ||
|
|
452
|
+
req?.rstCode === http2.constants.NGHTTP2_REFUSED_STREAM ||
|
|
453
|
+
streamError.code === "ERR_HTTP2_GOAWAY_SESSION") {
|
|
454
|
+
return markUnprocessed(err);
|
|
455
|
+
}
|
|
456
|
+
return err;
|
|
457
|
+
};
|
|
360
458
|
const onSessionClose = () => {
|
|
361
|
-
rejectBeforeResponse(new Error("HTTP/2 session closed before response"));
|
|
459
|
+
rejectBeforeResponse(markIfNeverProcessed(new Error("HTTP/2 session closed before response")));
|
|
362
460
|
};
|
|
363
|
-
const onSessionGoaway = () => {
|
|
364
|
-
|
|
461
|
+
const onSessionGoaway = (_errorCode, lastStreamID) => {
|
|
462
|
+
goawayLastStreamID = typeof lastStreamID === "number" ? lastStreamID : 0;
|
|
463
|
+
rejectBeforeResponse(markIfNeverProcessed(new Error("HTTP/2 session sent GOAWAY before response")));
|
|
365
464
|
};
|
|
366
465
|
const onSessionError = (err) => {
|
|
367
|
-
rejectBeforeResponse(err);
|
|
466
|
+
rejectBeforeResponse(markIfNeverProcessed(err));
|
|
368
467
|
};
|
|
369
468
|
session.once("close", onSessionClose);
|
|
370
469
|
session.once("goaway", onSessionGoaway);
|
|
@@ -450,7 +549,7 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
|
|
|
450
549
|
settled = true;
|
|
451
550
|
cleanupBeforeResponseListeners();
|
|
452
551
|
cleanupActiveRequest();
|
|
453
|
-
reject(err);
|
|
552
|
+
reject(markIfNeverProcessed(err));
|
|
454
553
|
});
|
|
455
554
|
if (body) {
|
|
456
555
|
req.end(body);
|
|
@@ -24,8 +24,8 @@ function missingCredentialsMessage() {
|
|
|
24
24
|
return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
|
|
25
25
|
}
|
|
26
26
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
27
|
-
const BUILD_VERSION = "0.3.
|
|
28
|
-
const BUILD_COMMIT = "
|
|
27
|
+
const BUILD_VERSION = "0.3.10-preview.241";
|
|
28
|
+
const BUILD_COMMIT = "7955dd9acd80e9962ec37dae05615a519fe9f422";
|
|
29
29
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
30
30
|
const BLAXEL_API_VERSION = "2026-04-28";
|
|
31
31
|
// Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
|