@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,14 +1,46 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isUnprocessedRequestError = isUnprocessedRequestError;
|
|
3
7
|
exports.withUploadSlot = withUploadSlot;
|
|
4
8
|
exports.createH2Fetch = createH2Fetch;
|
|
5
9
|
exports.createPoolBackedH2Fetch = createPoolBackedH2Fetch;
|
|
6
10
|
exports.h2RequestDirectFromPool = h2RequestDirectFromPool;
|
|
7
11
|
exports.h2RequestDirect = h2RequestDirect;
|
|
12
|
+
const http2_1 = __importDefault(require("http2"));
|
|
8
13
|
const settings_js_1 = require("./settings.js");
|
|
9
14
|
const h2ref_js_1 = require("./h2ref.js");
|
|
10
15
|
const MIN_H2_SESSION_MAX_LISTENERS = 64;
|
|
11
16
|
const sessionsWithListenerBudget = new WeakSet();
|
|
17
|
+
// Number of transparent re-sends the gateway performs for a request the peer
|
|
18
|
+
// PROVABLY never processed (see `isUnprocessedRequestError`). One is enough:
|
|
19
|
+
// the re-send runs on a freshly established session, so a second failure means
|
|
20
|
+
// the drain is not a one-off connection recycle and the caller should see it.
|
|
21
|
+
const MAX_UNPROCESSED_RESENDS = 1;
|
|
22
|
+
/**
|
|
23
|
+
* True when the peer guaranteed it never processed the request, so re-sending it
|
|
24
|
+
* cannot duplicate a side effect — even for a non-idempotent method.
|
|
25
|
+
*
|
|
26
|
+
* HTTP/2 gives two such guarantees:
|
|
27
|
+
* - GOAWAY carries `lastStreamID`: every stream with a higher id was not and
|
|
28
|
+
* will not be processed (RFC 9113 §6.8).
|
|
29
|
+
* - RST_STREAM with REFUSED_STREAM means the peer took no action on it.
|
|
30
|
+
*
|
|
31
|
+
* This is strictly stronger than `isTransientResetError` in transient-retry.ts,
|
|
32
|
+
* which classifies errors that are merely *likely* safe and therefore only
|
|
33
|
+
* covers idempotent operations.
|
|
34
|
+
*/
|
|
35
|
+
function isUnprocessedRequestError(error) {
|
|
36
|
+
return (!!error &&
|
|
37
|
+
typeof error === "object" &&
|
|
38
|
+
error.blaxelUnprocessedRequest === true);
|
|
39
|
+
}
|
|
40
|
+
function markUnprocessed(error) {
|
|
41
|
+
error.blaxelUnprocessedRequest = true;
|
|
42
|
+
return error;
|
|
43
|
+
}
|
|
12
44
|
/**
|
|
13
45
|
* Per-domain async semaphore that bounds the number of in-flight HTTP/2
|
|
14
46
|
* requests against a single edge domain (one H2 session). The cap is keyed
|
|
@@ -150,13 +182,15 @@ function createH2Fetch(session) {
|
|
|
150
182
|
* 2. get a live session from the pool,
|
|
151
183
|
* 3. send the request on it,
|
|
152
184
|
* 4. evict the session if the send fails after a stream was opened,
|
|
153
|
-
* 5.
|
|
185
|
+
* 5. re-send once on a fresh session when the peer provably never processed
|
|
186
|
+
* the request (`isUnprocessedRequestError`),
|
|
187
|
+
* 6. fall back to globalThis.fetch when the pool has no usable session.
|
|
154
188
|
*
|
|
155
189
|
* This is the chokepoint where reliability behavior that must protect EVERY
|
|
156
|
-
* consumer belongs: the open-stream concurrency limit
|
|
157
|
-
* timeouts, typed errors, and observability in later phases.
|
|
158
|
-
* (instead of re-implementing it per entry point) is what
|
|
159
|
-
* "fixed on one path, still broken on another" regressions.
|
|
190
|
+
* consumer belongs: the open-stream concurrency limit and the unprocessed-request
|
|
191
|
+
* re-send today, and timeouts, typed errors, and observability in later phases.
|
|
192
|
+
* Adding it once here (instead of re-implementing it per entry point) is what
|
|
193
|
+
* stops the recurring "fixed on one path, still broken on another" regressions.
|
|
160
194
|
*
|
|
161
195
|
* `send` performs the actual wire send on a live session; the caller supplies it
|
|
162
196
|
* so the gateway stays agnostic to the `Request` vs `(url, init)` call shapes,
|
|
@@ -165,6 +199,25 @@ function createH2Fetch(session) {
|
|
|
165
199
|
* stream is opened).
|
|
166
200
|
*/
|
|
167
201
|
async function h2GatewayRequest(pool, domain, send, fallback) {
|
|
202
|
+
for (let attempt = 0;; attempt++) {
|
|
203
|
+
try {
|
|
204
|
+
return await h2GatewayAttempt(pool, domain, send, fallback);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
// The peer drained the connection without processing this request (a
|
|
208
|
+
// gateway recycling the session, a rolling deploy, or a route moving).
|
|
209
|
+
// Nothing reached origin, so re-send once on a fresh session instead of
|
|
210
|
+
// surfacing a transport failure the caller cannot act on. Safe for every
|
|
211
|
+
// method — the unprocessed guarantee comes from the peer, not a guess
|
|
212
|
+
// about idempotency (unlike retryOnTransientReset).
|
|
213
|
+
if (attempt < MAX_UNPROCESSED_RESENDS && isUnprocessedRequestError(err)) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
async function h2GatewayAttempt(pool, domain, send, fallback) {
|
|
168
221
|
// Take the slot here, but hand its release to the send path: it is held for
|
|
169
222
|
// the OPEN-STREAM lifetime and freed on the stream terminal (ENG-2678). A
|
|
170
223
|
// request that never opens a stream on the shared session (the fallback goes
|
|
@@ -213,7 +266,7 @@ async function h2GatewayRequest(pool, domain, send, fallback) {
|
|
|
213
266
|
* before any H2 frames are sent.
|
|
214
267
|
*/
|
|
215
268
|
function createPoolBackedH2Fetch(pool, domain) {
|
|
216
|
-
return (input) => h2GatewayRequest(pool, domain, (session, options) => _h2Request(session, input, options), () =>
|
|
269
|
+
return (input) => h2GatewayRequest(pool, domain, (session, options) => _h2Request(session, input, options), () => fallbackFetchForRequest(input));
|
|
217
270
|
}
|
|
218
271
|
/**
|
|
219
272
|
* Pool-backed H2 request taking raw url + init (skips Request allocation),
|
|
@@ -285,6 +338,30 @@ function h2RequestDirectInternal(session, url, init, options) {
|
|
|
285
338
|
}
|
|
286
339
|
return _h2Send(session, h2Headers, body, init?.signal ?? null, url, init, options);
|
|
287
340
|
}
|
|
341
|
+
// A `Request` body can only be read once, so the buffered bytes are memoized
|
|
342
|
+
// per Request: the gateway's unprocessed re-send calls `_h2Request` again with
|
|
343
|
+
// the same object, which would otherwise throw "body already read".
|
|
344
|
+
const bufferedRequestBodies = new WeakMap();
|
|
345
|
+
/**
|
|
346
|
+
* `globalThis.fetch` fallback for a Request whose body may already have been
|
|
347
|
+
* buffered by an earlier attempt: re-sending the consumed Request object itself
|
|
348
|
+
* would throw, so the memoized bytes are replayed instead.
|
|
349
|
+
*/
|
|
350
|
+
function fallbackFetchForRequest(input) {
|
|
351
|
+
if (!bufferedRequestBodies.has(input))
|
|
352
|
+
return globalThis.fetch(input);
|
|
353
|
+
// Re-wrap the original Request so every option it carries (redirect,
|
|
354
|
+
// credentials, mode, cache, ...) survives; only the body is substituted.
|
|
355
|
+
return globalThis.fetch(new Request(input, { body: bufferedRequestBodies.get(input) }));
|
|
356
|
+
}
|
|
357
|
+
async function bufferRequestBody(input) {
|
|
358
|
+
if (bufferedRequestBodies.has(input)) {
|
|
359
|
+
return bufferedRequestBodies.get(input);
|
|
360
|
+
}
|
|
361
|
+
const body = input.body ? Buffer.from(await input.arrayBuffer()) : undefined;
|
|
362
|
+
bufferedRequestBodies.set(input, body);
|
|
363
|
+
return body;
|
|
364
|
+
}
|
|
288
365
|
async function _h2Request(session, input, options) {
|
|
289
366
|
const url = new URL(input.url);
|
|
290
367
|
const method = input.method || "GET";
|
|
@@ -298,12 +375,9 @@ async function _h2Request(session, input, options) {
|
|
|
298
375
|
continue;
|
|
299
376
|
h2Headers[key] = value;
|
|
300
377
|
}
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
if (!h2Headers["content-length"]) {
|
|
305
|
-
h2Headers["content-length"] = body.byteLength;
|
|
306
|
-
}
|
|
378
|
+
const body = await bufferRequestBody(input);
|
|
379
|
+
if (body && !h2Headers["content-length"]) {
|
|
380
|
+
h2Headers["content-length"] = body.byteLength;
|
|
307
381
|
}
|
|
308
382
|
return _h2Send(session, h2Headers, body, input.signal, input.url, {
|
|
309
383
|
method,
|
|
@@ -319,6 +393,8 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
|
|
|
319
393
|
let streamController = null;
|
|
320
394
|
let streamClosed = false;
|
|
321
395
|
let req = null;
|
|
396
|
+
// `lastStreamID` from the peer's GOAWAY, once seen (null = no GOAWAY).
|
|
397
|
+
let goawayLastStreamID = null;
|
|
322
398
|
let releaseSessionRef = () => { };
|
|
323
399
|
// The per-domain open-stream slot (idempotent; no-op for the non-pool
|
|
324
400
|
// transports). Held for the OPEN-STREAM lifetime and released alongside the
|
|
@@ -364,14 +440,41 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
|
|
|
364
440
|
req?.close();
|
|
365
441
|
reject(err);
|
|
366
442
|
};
|
|
443
|
+
// RFC 9113 §6.8: a GOAWAY's `lastStreamID` is the highest stream the peer
|
|
444
|
+
// processed or may still process, so any stream above it was refused
|
|
445
|
+
// outright. A stream with no id never put HEADERS on the wire at all. Either
|
|
446
|
+
// way the request cannot have reached origin, which is what lets the gateway
|
|
447
|
+
// re-send it for ANY method.
|
|
448
|
+
const streamWasNeverProcessed = () => {
|
|
449
|
+
if (goawayLastStreamID === null)
|
|
450
|
+
return false;
|
|
451
|
+
const id = req?.id;
|
|
452
|
+
if (typeof id !== "number")
|
|
453
|
+
return true;
|
|
454
|
+
return id > goawayLastStreamID;
|
|
455
|
+
};
|
|
456
|
+
// REFUSED_STREAM carries the same "took no action" guarantee as a GOAWAY
|
|
457
|
+
// above lastStreamID, and Node destroys streams the GOAWAY refused with
|
|
458
|
+
// ERR_HTTP2_GOAWAY_SESSION — whichever of the stream or session listener
|
|
459
|
+
// fires first, the error reaching the caller is flagged identically.
|
|
460
|
+
const markIfNeverProcessed = (err) => {
|
|
461
|
+
const streamError = err;
|
|
462
|
+
if (streamWasNeverProcessed() ||
|
|
463
|
+
req?.rstCode === http2_1.default.constants.NGHTTP2_REFUSED_STREAM ||
|
|
464
|
+
streamError.code === "ERR_HTTP2_GOAWAY_SESSION") {
|
|
465
|
+
return markUnprocessed(err);
|
|
466
|
+
}
|
|
467
|
+
return err;
|
|
468
|
+
};
|
|
367
469
|
const onSessionClose = () => {
|
|
368
|
-
rejectBeforeResponse(new Error("HTTP/2 session closed before response"));
|
|
470
|
+
rejectBeforeResponse(markIfNeverProcessed(new Error("HTTP/2 session closed before response")));
|
|
369
471
|
};
|
|
370
|
-
const onSessionGoaway = () => {
|
|
371
|
-
|
|
472
|
+
const onSessionGoaway = (_errorCode, lastStreamID) => {
|
|
473
|
+
goawayLastStreamID = typeof lastStreamID === "number" ? lastStreamID : 0;
|
|
474
|
+
rejectBeforeResponse(markIfNeverProcessed(new Error("HTTP/2 session sent GOAWAY before response")));
|
|
372
475
|
};
|
|
373
476
|
const onSessionError = (err) => {
|
|
374
|
-
rejectBeforeResponse(err);
|
|
477
|
+
rejectBeforeResponse(markIfNeverProcessed(err));
|
|
375
478
|
};
|
|
376
479
|
session.once("close", onSessionClose);
|
|
377
480
|
session.once("goaway", onSessionGoaway);
|
|
@@ -457,7 +560,7 @@ function _h2Send(session, h2Headers, body, signal, fallbackUrl, fallbackInit, op
|
|
|
457
560
|
settled = true;
|
|
458
561
|
cleanupBeforeResponseListeners();
|
|
459
562
|
cleanupActiveRequest();
|
|
460
|
-
reject(err);
|
|
563
|
+
reject(markIfNeverProcessed(err));
|
|
461
564
|
});
|
|
462
565
|
if (body) {
|
|
463
566
|
req.end(body);
|
|
@@ -30,8 +30,8 @@ function missingCredentialsMessage() {
|
|
|
30
30
|
return "No Blaxel credentials found. Set the BL_API_KEY and BL_WORKSPACE environment variables, or run `bl login`.";
|
|
31
31
|
}
|
|
32
32
|
// Build info - these placeholders are replaced at build time by build:replace-imports
|
|
33
|
-
const BUILD_VERSION = "0.3.
|
|
34
|
-
const BUILD_COMMIT = "
|
|
33
|
+
const BUILD_VERSION = "0.3.10-preview.241";
|
|
34
|
+
const BUILD_COMMIT = "7955dd9acd80e9962ec37dae05615a519fe9f422";
|
|
35
35
|
const BUILD_SENTRY_DSN = "https://fd5e60e1c9820e1eef5ccebb84a07127@o4508714045276160.ingest.us.sentry.io/4510465864564736";
|
|
36
36
|
const BLAXEL_API_VERSION = "2026-04-28";
|
|
37
37
|
// Bun < 1.3.11 never sends connection-level WINDOW_UPDATE: the pooled h2
|
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import http2 from "http2";
|
|
2
2
|
import type { H2Pool } from "./h2pool.js";
|
|
3
|
+
/**
|
|
4
|
+
* True when the peer guaranteed it never processed the request, so re-sending it
|
|
5
|
+
* cannot duplicate a side effect — even for a non-idempotent method.
|
|
6
|
+
*
|
|
7
|
+
* HTTP/2 gives two such guarantees:
|
|
8
|
+
* - GOAWAY carries `lastStreamID`: every stream with a higher id was not and
|
|
9
|
+
* will not be processed (RFC 9113 §6.8).
|
|
10
|
+
* - RST_STREAM with REFUSED_STREAM means the peer took no action on it.
|
|
11
|
+
*
|
|
12
|
+
* This is strictly stronger than `isTransientResetError` in transient-retry.ts,
|
|
13
|
+
* which classifies errors that are merely *likely* safe and therefore only
|
|
14
|
+
* covers idempotent operations.
|
|
15
|
+
*/
|
|
16
|
+
export declare function isUnprocessedRequestError(error: unknown): boolean;
|
|
3
17
|
/**
|
|
4
18
|
* Run `fn` while holding an upload slot for `domain`, releasing it when `fn`
|
|
5
19
|
* settles (the part PUT completes or fails). Bounds concurrent in-flight upload
|