@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.
@@ -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. fall back to globalThis.fetch when the pool has no usable session.
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 today, and retry,
157
- * timeouts, typed errors, and observability in later phases. Adding it once here
158
- * (instead of re-implementing it per entry point) is what stops the recurring
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), () => globalThis.fetch(input));
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
- let body;
302
- if (input.body) {
303
- body = Buffer.from(await input.arrayBuffer());
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
- rejectBeforeResponse(new Error("HTTP/2 session sent GOAWAY before response"));
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.9-preview.239";
34
- const BUILD_COMMIT = "73d9ca5549cb452d2440ec2a738f2718cc0f065d";
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