@blaxel/core 0.3.9 → 0.3.10

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