@miosa/sdk 3.3.1 → 3.4.1

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
2
2
  import EventEmitter from 'events';
3
3
  import { isIP } from 'net';
4
- import { resolve4 } from 'dns/promises';
4
+ import { lookup } from 'dns/promises';
5
5
  import * as http2 from 'http2';
6
6
  import { createRequire } from 'module';
7
7
 
@@ -30,7 +30,7 @@ var MiosaError = class _MiosaError extends Error {
30
30
  * its own backoff policy.
31
31
  */
32
32
  retryAfterMs;
33
- constructor(message, status, code, details, requestId, retryable = defaultRetryable(status), retryAfterMs) {
33
+ constructor(message, status, code, details, requestId, retryable = defaultRetryable(status), retryAfterMs2) {
34
34
  super(message);
35
35
  this.name = "MiosaError";
36
36
  this.status = status;
@@ -38,7 +38,7 @@ var MiosaError = class _MiosaError extends Error {
38
38
  this.details = details;
39
39
  this.requestId = requestId;
40
40
  this.retryable = retryable;
41
- this.retryAfterMs = retryAfterMs;
41
+ this.retryAfterMs = retryAfterMs2;
42
42
  }
43
43
  /**
44
44
  * @param retryAfterHeaderMs The `Retry-After` header value, already
@@ -54,16 +54,16 @@ var MiosaError = class _MiosaError extends Error {
54
54
  const details = nested?.details ?? body5.details ?? (body5.detail || body5.reason ? { detail: body5.detail, reason: body5.reason } : void 0);
55
55
  requestId ??= nested?.request_id ?? body5.request_id;
56
56
  const retryable = nested?.retryable ?? body5.retryable ?? defaultRetryable(status);
57
- const retryAfterMs = nested?.retry_after_ms ?? body5.retry_after_ms ?? retryAfterHeaderMs2;
57
+ const retryAfterMs2 = nested?.retry_after_ms ?? body5.retry_after_ms ?? retryAfterHeaderMs2;
58
58
  const connectError = connectErrorFromCode(code, message, status, details, requestId, retryable);
59
59
  const built = connectError ?? (status === 401 || status === 403 ? new AuthError(message, status, code, details, requestId, retryable) : status === 404 ? new NotFoundError(message, code, details, requestId, retryable) : status === 429 ? new RateLimitError(
60
60
  message,
61
61
  details,
62
62
  requestId,
63
- retryAfterMs === void 0 ? void 0 : retryAfterMs / 1e3,
63
+ retryAfterMs2 === void 0 ? void 0 : retryAfterMs2 / 1e3,
64
64
  retryable
65
65
  ) : status === 402 ? new InsufficientCreditsError(message, details, requestId, retryable) : status >= 400 && status < 500 ? new ValidationError(message, status, code, details, requestId, retryable) : new _MiosaError(message, status, code, details, requestId, retryable));
66
- built.retryAfterMs = retryAfterMs;
66
+ built.retryAfterMs = retryAfterMs2;
67
67
  return built;
68
68
  }
69
69
  };
@@ -185,7 +185,7 @@ var TokenRefreshFailedError = class extends MiosaError {
185
185
  };
186
186
 
187
187
  // src/version.ts
188
- var SDK_VERSION = "3.3.1";
188
+ var SDK_VERSION = "3.4.1";
189
189
  var SDK_USER_AGENT = `@miosa/sdk/${SDK_VERSION}`;
190
190
 
191
191
  // src/http.ts
@@ -214,7 +214,11 @@ async function ensureHttp2Agent() {
214
214
  _h2Available = false;
215
215
  }
216
216
  }
217
- ensureHttp2Agent();
217
+ var _h2Bootstrap;
218
+ function startHttp2Agent() {
219
+ _h2Bootstrap ??= ensureHttp2Agent();
220
+ return _h2Bootstrap;
221
+ }
218
222
  var DEFAULT_TIMEOUT = 3e4;
219
223
  var DEFAULT_MAX_RETRIES = 3;
220
224
  var BASE_DELAY_MS = 500;
@@ -293,6 +297,7 @@ var HttpClient = class {
293
297
  options.signal?.addEventListener("abort", cancel, { once: true });
294
298
  const timer = setTimeout(() => controller.abort(), timeout);
295
299
  try {
300
+ await startHttp2Agent();
296
301
  const response = await fetch(this.buildUrl(path), {
297
302
  method,
298
303
  headers,
@@ -303,7 +308,7 @@ var HttpClient = class {
303
308
  clearTimeout(timer);
304
309
  if (!response.ok) {
305
310
  const requestId = response.headers.get("x-request-id") ?? void 0;
306
- const retryAfterMs = retryAfterHeaderMs(
311
+ const retryAfterMs2 = retryAfterHeaderMs(
307
312
  response.headers.get("retry-after")
308
313
  );
309
314
  let errorBody = {};
@@ -315,7 +320,7 @@ var HttpClient = class {
315
320
  response.status,
316
321
  errorBody,
317
322
  requestId,
318
- retryAfterMs
323
+ retryAfterMs2
319
324
  );
320
325
  if (error.retryable && attempt < maxRetries) {
321
326
  lastError = error;
@@ -430,6 +435,7 @@ var HttpClient = class {
430
435
  try {
431
436
  let response;
432
437
  try {
438
+ await startHttp2Agent();
433
439
  response = await fetch(this.buildUrl(path), {
434
440
  method,
435
441
  headers,
@@ -11208,24 +11214,36 @@ function sandboxTag(sandboxId) {
11208
11214
  }
11209
11215
  return first.toLowerCase();
11210
11216
  }
11217
+ async function lookupAllIpv4(hostname) {
11218
+ const entries = await lookup(hostname, { family: 4, all: true });
11219
+ return entries.map((entry) => entry.address);
11220
+ }
11221
+ function dedupe(addresses) {
11222
+ return [...new Set(addresses)];
11223
+ }
11211
11224
  var DnsAddressResolver = class {
11212
- constructor(ttlMs = 6e4) {
11225
+ constructor(ttlMs = 6e4, lookupAddresses = lookupAllIpv4) {
11213
11226
  this.ttlMs = ttlMs;
11227
+ this.lookupAddresses = lookupAddresses;
11214
11228
  }
11215
11229
  ttlMs;
11230
+ lookupAddresses;
11216
11231
  cache = /* @__PURE__ */ new Map();
11217
11232
  async resolve(hostname) {
11218
11233
  if (isIP(hostname) !== 0) return [hostname];
11219
11234
  if (hostname === "localhost") return ["127.0.0.1"];
11220
11235
  const cached = this.cache.get(hostname);
11221
11236
  if (cached && cached.expiresAt > Date.now()) return cached.addresses;
11222
- const addresses = await resolve4(hostname);
11237
+ const addresses = dedupe(await this.lookupAddresses(hostname));
11223
11238
  if (addresses.length === 0) {
11224
11239
  throw new Error(`DnsAddressResolver: ${hostname} resolved to zero A records`);
11225
11240
  }
11226
11241
  this.cache.set(hostname, { addresses, expiresAt: Date.now() + this.ttlMs });
11227
11242
  return addresses;
11228
11243
  }
11244
+ invalidate(hostname) {
11245
+ this.cache.delete(hostname);
11246
+ }
11229
11247
  };
11230
11248
  var StaticAddressResolver = class {
11231
11249
  constructor(table) {
@@ -11273,7 +11291,7 @@ function markHandled(promise) {
11273
11291
  promise.catch(() => void 0);
11274
11292
  return promise;
11275
11293
  }
11276
- var DEFAULT_SESSIONS_PER_ADDRESS = 8;
11294
+ var DEFAULT_SESSIONS_PER_ADDRESS = 2;
11277
11295
  var DEFAULT_TIMEOUT_MS = 3e4;
11278
11296
  var Http2Transport = class {
11279
11297
  protocol = "h2";
@@ -11282,13 +11300,25 @@ var Http2Transport = class {
11282
11300
  port;
11283
11301
  resolver;
11284
11302
  defaultTimeoutMs;
11303
+ pinAddresses;
11304
+ insecure;
11305
+ dial;
11285
11306
  pools = /* @__PURE__ */ new Map();
11307
+ /**
11308
+ * Requests in flight per session. A pooled session is unref'd while idle so
11309
+ * a process that only imported the client (or finished its last request)
11310
+ * still exits; it is ref'd again for exactly as long as it carries a request.
11311
+ */
11312
+ inFlight = /* @__PURE__ */ new Map();
11286
11313
  constructor(options = {}) {
11287
11314
  this.defaultSessionsPerAddress = options.defaultSessionsPerAddress ?? DEFAULT_SESSIONS_PER_ADDRESS;
11288
11315
  this.tls = options.tls ?? true;
11289
11316
  this.port = options.port ?? (this.tls ? 443 : 80);
11290
11317
  this.resolver = options.resolver ?? new DnsAddressResolver();
11291
11318
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
11319
+ this.pinAddresses = options.pinAddresses ?? true;
11320
+ this.insecure = options.insecure ?? false;
11321
+ this.dial = options.connect ?? http2.connect;
11292
11322
  }
11293
11323
  prewarm(regionHostname2, sessionsPerAddress) {
11294
11324
  this.poolFor(regionHostname2, sessionsPerAddress);
@@ -11328,6 +11358,7 @@ var Http2Transport = class {
11328
11358
  try {
11329
11359
  session = await group[cursor];
11330
11360
  } catch (error) {
11361
+ this.noteAddressFailure(regionHostname2, address);
11331
11362
  throw new RunnerConnectError(regionHostname2, address, error);
11332
11363
  }
11333
11364
  if (session.closed || session.destroyed) {
@@ -11336,6 +11367,7 @@ var Http2Transport = class {
11336
11367
  try {
11337
11368
  session = await fresh;
11338
11369
  } catch (error) {
11370
+ this.noteAddressFailure(regionHostname2, address);
11339
11371
  throw new RunnerConnectError(regionHostname2, address, error);
11340
11372
  }
11341
11373
  }
@@ -11366,28 +11398,104 @@ var Http2Transport = class {
11366
11398
  return pool;
11367
11399
  }
11368
11400
  buildPool(regionHostname2, sessionsPerAddress) {
11369
- const addresses = this.resolver.resolve(regionHostname2);
11370
- const groups = addresses.then(
11371
- (resolved) => resolved.map(
11372
- (address) => (
11373
- // sessionsPerAddress: 0 is a deliberate "open nothing" (e.g. a
11374
- // count-only probe).
11375
- Array.from(
11376
- { length: sessionsPerAddress },
11377
- () => markHandled(this.connectSession(regionHostname2, address))
11378
- )
11379
- )
11380
- )
11401
+ const pool = {
11402
+ addresses: this.resolveAddresses(regionHostname2),
11403
+ groups: Promise.resolve([]),
11404
+ nextSlot: /* @__PURE__ */ new Map(),
11405
+ sessionsPerAddress,
11406
+ failed: /* @__PURE__ */ new Set(),
11407
+ rebuilding: void 0
11408
+ };
11409
+ pool.groups = pool.addresses.then(
11410
+ (resolved) => resolved.map((address) => this.openGroup(regionHostname2, address, sessionsPerAddress))
11411
+ );
11412
+ markHandled(pool.addresses);
11413
+ markHandled(pool.groups);
11414
+ return pool;
11415
+ }
11416
+ /**
11417
+ * The addresses this pool pins to. With pinning on, every IPv4 address
11418
+ * the hostname resolves to; with it off, the hostname itself - one entry,
11419
+ * dialled as-is so the OS resolver chooses.
11420
+ */
11421
+ resolveAddresses(regionHostname2) {
11422
+ return this.pinAddresses ? this.resolver.resolve(regionHostname2) : Promise.resolve([regionHostname2]);
11423
+ }
11424
+ /**
11425
+ * Opens `sessionsPerAddress` sessions against one address, in parallel.
11426
+ * sessionsPerAddress: 0 is a deliberate "open nothing" (e.g. a count-only
11427
+ * probe).
11428
+ */
11429
+ openGroup(regionHostname2, address, sessionsPerAddress) {
11430
+ return Array.from(
11431
+ { length: sessionsPerAddress },
11432
+ () => markHandled(this.connectSession(regionHostname2, address))
11433
+ );
11434
+ }
11435
+ /** Closes a session slot once it connects, ignoring one that never did. */
11436
+ closeSlot(slot) {
11437
+ slot.then((session) => {
11438
+ if (!session.closed) session.close();
11439
+ }).catch(() => void 0);
11440
+ }
11441
+ /**
11442
+ * A session pinned to `address` failed to connect. Mark that address's
11443
+ * group for reopening and re-resolve in the background (single-flight),
11444
+ * so one dead address never costs every concurrent failure its own DNS
11445
+ * lookup. Never blocks or fails the request that is already on its way
11446
+ * out - the caller retries another index (C6's next-IP retry).
11447
+ */
11448
+ noteAddressFailure(regionHostname2, address) {
11449
+ if (!this.pinAddresses) return;
11450
+ const pool = this.pools.get(regionHostname2);
11451
+ if (!pool) return;
11452
+ pool.failed.add(address);
11453
+ this.resolver.invalidate?.(regionHostname2);
11454
+ if (pool.rebuilding) return;
11455
+ pool.rebuilding = this.rebuildPool(regionHostname2, pool).finally(() => {
11456
+ pool.rebuilding = void 0;
11457
+ });
11458
+ }
11459
+ /**
11460
+ * Re-resolves `regionHostname` and redistributes the pool over the fresh
11461
+ * addresses: the failed address's sessions are dropped and reopened, a
11462
+ * newly advertised address gets a group of its own, an address DNS no
11463
+ * longer lists loses its sessions, and every healthy address keeps the
11464
+ * sessions it already has - so a failure on one host never costs the rest
11465
+ * their warm connections.
11466
+ */
11467
+ async rebuildPool(regionHostname2, pool) {
11468
+ const failed = new Set(pool.failed);
11469
+ pool.failed.clear();
11470
+ const resolved = await this.resolveAddresses(regionHostname2).catch(() => void 0);
11471
+ if (!resolved) {
11472
+ for (const address of failed) pool.failed.add(address);
11473
+ return;
11474
+ }
11475
+ const previous = await pool.addresses.catch(() => []);
11476
+ const previousGroups = await pool.groups.catch(
11477
+ () => []
11381
11478
  );
11382
- markHandled(addresses);
11383
- markHandled(groups);
11384
- return { addresses, groups, nextSlot: /* @__PURE__ */ new Map() };
11479
+ const groups = resolved.map((address) => {
11480
+ const index = previous.indexOf(address);
11481
+ const reusable = index >= 0 && !failed.has(address) ? previousGroups[index] : void 0;
11482
+ if (reusable) return reusable;
11483
+ for (const slot of previousGroups[index] ?? []) this.closeSlot(slot);
11484
+ return this.openGroup(regionHostname2, address, pool.sessionsPerAddress);
11485
+ });
11486
+ previous.forEach((address, index) => {
11487
+ if (resolved.includes(address)) return;
11488
+ for (const slot of previousGroups[index] ?? []) this.closeSlot(slot);
11489
+ });
11490
+ pool.addresses = Promise.resolve(resolved);
11491
+ pool.groups = Promise.resolve(groups);
11492
+ pool.nextSlot.clear();
11385
11493
  }
11386
11494
  connectSession(regionHostname2, address) {
11387
11495
  return new Promise((resolve, reject) => {
11388
11496
  const authority = `${this.tls ? "https" : "http"}://${address}:${this.port}`;
11389
- const connectOptions = this.tls ? { servername: regionHostname2 } : {};
11390
- const session = http2.connect(authority, connectOptions);
11497
+ const connectOptions = this.tls ? { servername: regionHostname2, rejectUnauthorized: !this.insecure } : {};
11498
+ const session = this.dial(authority, connectOptions);
11391
11499
  const onError = (error) => {
11392
11500
  session.destroy();
11393
11501
  reject(error);
@@ -11395,11 +11503,26 @@ var Http2Transport = class {
11395
11503
  session.once("error", onError);
11396
11504
  session.once("connect", () => {
11397
11505
  session.removeListener("error", onError);
11506
+ session.unref();
11398
11507
  session.on("error", () => void 0);
11399
11508
  resolve(session);
11400
11509
  });
11401
11510
  });
11402
11511
  }
11512
+ retain(session) {
11513
+ const n = this.inFlight.get(session) ?? 0;
11514
+ this.inFlight.set(session, n + 1);
11515
+ if (n === 0) session.ref();
11516
+ }
11517
+ release(session) {
11518
+ const n = (this.inFlight.get(session) ?? 1) - 1;
11519
+ if (n > 0) {
11520
+ this.inFlight.set(session, n);
11521
+ return;
11522
+ }
11523
+ this.inFlight.delete(session);
11524
+ session.unref();
11525
+ }
11403
11526
  sendRequest(regionHostname2, authority, path, session, init) {
11404
11527
  const timeoutMs = init.timeoutMs ?? this.defaultTimeoutMs;
11405
11528
  return new Promise((resolve, reject) => {
@@ -11409,6 +11532,7 @@ var Http2Transport = class {
11409
11532
  ":authority": authority,
11410
11533
  ...init.headers
11411
11534
  };
11535
+ this.retain(session);
11412
11536
  const stream = session.request(requestHeaders);
11413
11537
  let status = 0;
11414
11538
  const responseHeaders = {};
@@ -11417,12 +11541,14 @@ var Http2Transport = class {
11417
11541
  const timer = setTimeout(() => {
11418
11542
  if (settled) return;
11419
11543
  settled = true;
11544
+ this.release(session);
11420
11545
  stream.close(http2.constants.NGHTTP2_CANCEL);
11421
11546
  reject(new RunnerTimeoutError(regionHostname2, path, timeoutMs));
11422
11547
  }, timeoutMs);
11423
11548
  const onAbort = () => {
11424
11549
  if (settled) return;
11425
11550
  settled = true;
11551
+ this.release(session);
11426
11552
  clearTimeout(timer);
11427
11553
  stream.close(http2.constants.NGHTTP2_CANCEL);
11428
11554
  reject(new DOMException("runner request aborted", "AbortError"));
@@ -11439,6 +11565,7 @@ var Http2Transport = class {
11439
11565
  stream.on("end", () => {
11440
11566
  if (settled) return;
11441
11567
  settled = true;
11568
+ this.release(session);
11442
11569
  clearTimeout(timer);
11443
11570
  init.signal?.removeEventListener("abort", onAbort);
11444
11571
  resolve({ status, headers: responseHeaders, body: Buffer.concat(chunks).toString("utf8") });
@@ -11446,6 +11573,7 @@ var Http2Transport = class {
11446
11573
  stream.on("error", (error) => {
11447
11574
  if (settled) return;
11448
11575
  settled = true;
11576
+ this.release(session);
11449
11577
  clearTimeout(timer);
11450
11578
  init.signal?.removeEventListener("abort", onAbort);
11451
11579
  reject(error);
@@ -11560,6 +11688,8 @@ var Http3Transport = class {
11560
11688
  connectTimeoutMs;
11561
11689
  insecure;
11562
11690
  port;
11691
+ pinAddresses;
11692
+ dial;
11563
11693
  pools = /* @__PURE__ */ new Map();
11564
11694
  constructor(options = {}) {
11565
11695
  const native = loadNative();
@@ -11573,6 +11703,8 @@ var Http3Transport = class {
11573
11703
  this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
11574
11704
  this.insecure = options.insecure ?? false;
11575
11705
  this.port = options.port ?? 443;
11706
+ this.pinAddresses = options.pinAddresses ?? true;
11707
+ this.dial = options.connect ?? ((address, servername, port, insecure, timeoutMs) => this.native.h3Connect(address, servername, port, insecure, timeoutMs));
11576
11708
  }
11577
11709
  prewarm(regionHostname2, sessionsPerAddress) {
11578
11710
  this.poolFor(regionHostname2, sessionsPerAddress);
@@ -11618,6 +11750,7 @@ var Http3Transport = class {
11618
11750
  try {
11619
11751
  handle = await group[cursor];
11620
11752
  } catch (error) {
11753
+ this.noteAddressFailure(regionHostname2, address);
11621
11754
  throw new RunnerConnectError(regionHostname2, address, error);
11622
11755
  }
11623
11756
  const reconnect = () => {
@@ -11652,21 +11785,99 @@ var Http3Transport = class {
11652
11785
  return pool;
11653
11786
  }
11654
11787
  buildPool(regionHostname2, sessionsPerAddress) {
11655
- const addresses = this.resolver.resolve(regionHostname2);
11656
- const groups = addresses.then(
11657
- (resolved) => resolved.map(
11658
- (address) => Array.from(
11659
- { length: sessionsPerAddress },
11660
- () => markHandled2(this.connectSession(regionHostname2, address))
11661
- )
11662
- )
11788
+ const pool = {
11789
+ addresses: this.resolveAddresses(regionHostname2),
11790
+ groups: Promise.resolve([]),
11791
+ nextSlot: /* @__PURE__ */ new Map(),
11792
+ sessionsPerAddress,
11793
+ failed: /* @__PURE__ */ new Set(),
11794
+ rebuilding: void 0
11795
+ };
11796
+ pool.groups = pool.addresses.then(
11797
+ (resolved) => resolved.map((address) => this.openGroup(regionHostname2, address, sessionsPerAddress))
11798
+ );
11799
+ markHandled2(pool.addresses);
11800
+ markHandled2(pool.groups);
11801
+ return pool;
11802
+ }
11803
+ /**
11804
+ * The addresses this pool pins to. With pinning on, every IPv4 address
11805
+ * the hostname resolves to. With it off, the hostname is resolved once
11806
+ * and only its first address is kept - one target, chosen by the OS
11807
+ * resolver, which is the closest this transport can get to HTTP/2's
11808
+ * hostname dial: the native addon takes a literal socket address, never
11809
+ * a hostname.
11810
+ */
11811
+ async resolveAddresses(regionHostname2) {
11812
+ if (this.pinAddresses) return this.resolver.resolve(regionHostname2);
11813
+ const [first] = await this.resolver.resolve(regionHostname2);
11814
+ if (!first) {
11815
+ throw new Error(`Http3Transport: ${regionHostname2} resolved to zero addresses`);
11816
+ }
11817
+ return [first];
11818
+ }
11819
+ /** Opens `sessionsPerAddress` connections against one address, in parallel. */
11820
+ openGroup(regionHostname2, address, sessionsPerAddress) {
11821
+ return Array.from(
11822
+ { length: sessionsPerAddress },
11823
+ () => markHandled2(this.connectSession(regionHostname2, address))
11663
11824
  );
11664
- markHandled2(addresses);
11665
- markHandled2(groups);
11666
- return { addresses, groups, nextSlot: /* @__PURE__ */ new Map() };
11825
+ }
11826
+ /** Closes a connection slot once it connects, ignoring one that never did. */
11827
+ closeSlot(slot) {
11828
+ slot.then((handle) => this.native.h3Close(handle)).catch(() => void 0);
11829
+ }
11830
+ /**
11831
+ * A connection pinned to `address` failed to connect. Mark that address's
11832
+ * group for reopening and re-resolve in the background (single-flight),
11833
+ * so one dead address never costs every concurrent failure its own DNS
11834
+ * lookup. Never blocks or fails the request already on its way out.
11835
+ */
11836
+ noteAddressFailure(regionHostname2, address) {
11837
+ if (!this.pinAddresses) return;
11838
+ const pool = this.pools.get(regionHostname2);
11839
+ if (!pool) return;
11840
+ pool.failed.add(address);
11841
+ this.resolver.invalidate?.(regionHostname2);
11842
+ if (pool.rebuilding) return;
11843
+ pool.rebuilding = this.rebuildPool(regionHostname2, pool).finally(() => {
11844
+ pool.rebuilding = void 0;
11845
+ });
11846
+ }
11847
+ /**
11848
+ * Re-resolves `regionHostname` and redistributes the pool over the fresh
11849
+ * addresses: the failed address's connections are dropped and reopened, a
11850
+ * newly advertised address gets a group of its own, an address DNS no
11851
+ * longer lists loses its connections, and every healthy address keeps the
11852
+ * connections it already has.
11853
+ */
11854
+ async rebuildPool(regionHostname2, pool) {
11855
+ const failed = new Set(pool.failed);
11856
+ pool.failed.clear();
11857
+ const resolved = await this.resolveAddresses(regionHostname2).catch(() => void 0);
11858
+ if (!resolved) {
11859
+ for (const address of failed) pool.failed.add(address);
11860
+ return;
11861
+ }
11862
+ const previous = await pool.addresses.catch(() => []);
11863
+ const previousGroups = await pool.groups.catch(() => []);
11864
+ const groups = resolved.map((address) => {
11865
+ const index = previous.indexOf(address);
11866
+ const reusable = index >= 0 && !failed.has(address) ? previousGroups[index] : void 0;
11867
+ if (reusable) return reusable;
11868
+ for (const slot of previousGroups[index] ?? []) this.closeSlot(slot);
11869
+ return this.openGroup(regionHostname2, address, pool.sessionsPerAddress);
11870
+ });
11871
+ previous.forEach((address, index) => {
11872
+ if (resolved.includes(address)) return;
11873
+ for (const slot of previousGroups[index] ?? []) this.closeSlot(slot);
11874
+ });
11875
+ pool.addresses = Promise.resolve(resolved);
11876
+ pool.groups = Promise.resolve(groups);
11877
+ pool.nextSlot.clear();
11667
11878
  }
11668
11879
  connectSession(regionHostname2, address) {
11669
- return this.native.h3Connect(address, regionHostname2, this.port, this.insecure, this.connectTimeoutMs);
11880
+ return this.dial(address, regionHostname2, this.port, this.insecure, this.connectTimeoutMs);
11670
11881
  }
11671
11882
  async sendRequest(regionHostname2, address, authority, path, handle, init, reconnect) {
11672
11883
  const timeoutMs = init.timeoutMs ?? this.defaultTimeoutMs;
@@ -11694,6 +11905,7 @@ var Http3Transport = class {
11694
11905
  try {
11695
11906
  fresh = await reconnect();
11696
11907
  } catch (reconnectError) {
11908
+ this.noteAddressFailure(regionHostname2, address);
11697
11909
  throw new RunnerConnectError(regionHostname2, address, reconnectError);
11698
11910
  }
11699
11911
  return attempt(fresh);
@@ -11722,7 +11934,7 @@ async function* singleChunk(body5) {
11722
11934
  // src/runner/transport-factory.ts
11723
11935
  function createBestTransport(options = {}) {
11724
11936
  const http3 = detectHttp3Support();
11725
- if (!http3.available) {
11937
+ if (!http3.available || options.raceHttp3 !== true) {
11726
11938
  return { transport: new Http2Transport(options.http2), http3 };
11727
11939
  }
11728
11940
  const transport = new RacingTransport(new Http2Transport(options.http2), new Http3Transport(options.http3));
@@ -11830,6 +12042,16 @@ var RunnerError = class _RunnerError extends Error {
11830
12042
  get isBusy() {
11831
12043
  return this.code === "runtime_busy" || this.code === "feed_stale";
11832
12044
  }
12045
+ /**
12046
+ * `429 rate_limited`: the key's own window on this runner has no room.
12047
+ *
12048
+ * Separate from `isBusy`, which means this runner's admission is full for
12049
+ * everyone. The limiter counts per key per runner, so a refusal here says
12050
+ * nothing about the next address, and `createSandbox` may try that one.
12051
+ */
12052
+ get isRateLimited() {
12053
+ return this.code === "rate_limited";
12054
+ }
11833
12055
  /** C2: the runner doesn't serve this create shape at all - send it to api.miosa.ai instead. */
11834
12056
  get isUnsupportedRequest() {
11835
12057
  return this.code === "RUNNER_UNSUPPORTED_REQUEST";
@@ -11879,7 +12101,7 @@ function unwrapSomaEnvelope(httpStatus, body5) {
11879
12101
  }
11880
12102
 
11881
12103
  // src/runner/runner-client.ts
11882
- var DEFAULT_SESSIONS_PER_ADDRESS3 = 8;
12104
+ var DEFAULT_SESSIONS_PER_ADDRESS3 = 2;
11883
12105
  function createSupportedByRunner(params) {
11884
12106
  const hasTemplate = params["template_id"] !== void 0 || params["templateId"] !== void 0 || params["image"] !== void 0;
11885
12107
  const hasSnapshot = params["snapshot_id"] !== void 0 || params["snapshotId"] !== void 0 || params["source_snapshot_path"] !== void 0 || params["sourceSnapshotPath"] !== void 0;
@@ -11889,6 +12111,18 @@ function createSupportedByRunner(params) {
11889
12111
  const isNonXs = size !== void 0 && size !== "xs";
11890
12112
  return !(hasTemplate || hasSnapshot || hasCwd || isPersistent || isNonXs);
11891
12113
  }
12114
+ var RATE_LIMIT_RETRY_CAP_MS = 250;
12115
+ function retryAfterMs(headers, capMs = RATE_LIMIT_RETRY_CAP_MS) {
12116
+ const raw = headers["retry-after"] ?? headers["Retry-After"];
12117
+ if (raw === void 0) return 0;
12118
+ const seconds = Number(raw.trim());
12119
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1e3, capMs);
12120
+ const at = Date.parse(raw);
12121
+ return Number.isNaN(at) ? 0 : Math.min(Math.max(0, at - Date.now()), capMs);
12122
+ }
12123
+ function sleep9(ms) {
12124
+ return new Promise((resolve) => setTimeout(resolve, ms));
12125
+ }
11892
12126
  var RunnerClient = class {
11893
12127
  region;
11894
12128
  hostname;
@@ -11901,13 +12135,26 @@ var RunnerClient = class {
11901
12135
  addressIndexByTag = /* @__PURE__ */ new Map();
11902
12136
  /** sandbox id -> tag, set only once a 421 corrects the id-derived guess. */
11903
12137
  correctedTag = /* @__PURE__ */ new Map();
12138
+ /**
12139
+ * Rotating start address for creates, so a cold burst spreads over every
12140
+ * resolved runner address instead of all of it landing on address 0.
12141
+ * Advanced once per create; the retry on `429 runtime_busy`/connect
12142
+ * failure still walks the other addresses from there.
12143
+ */
12144
+ createCursor = 0;
11904
12145
  constructor(options) {
11905
12146
  this.apiKey = options.apiKey;
11906
12147
  this.region = options.region ?? regionFromApiKey(options.apiKey);
11907
12148
  this.baseDomain = options.baseDomain ?? DEFAULT_RUNNER_BASE_DOMAIN;
11908
12149
  this.hostname = regionHostname(this.region, this.baseDomain);
11909
12150
  this.sessionsPerAddress = options.sessionsPerAddress ?? DEFAULT_SESSIONS_PER_ADDRESS3;
11910
- this.transport = options.transport ?? createBestTransport().transport;
12151
+ this.transport = options.transport ?? createBestTransport({
12152
+ raceHttp3: options.http3,
12153
+ ...options.pinAddresses === void 0 ? {} : {
12154
+ http2: { pinAddresses: options.pinAddresses },
12155
+ http3: { pinAddresses: options.pinAddresses }
12156
+ }
12157
+ }).transport;
11911
12158
  this.fallbackCreate = options.fallbackCreate;
11912
12159
  this.transport.prewarm(this.hostname, this.sessionsPerAddress);
11913
12160
  void this.discoverTags();
@@ -11941,8 +12188,12 @@ var RunnerClient = class {
11941
12188
  if (n === 0) {
11942
12189
  throw new Error(`RunnerClient: no runner addresses for ${this.hostname}`);
11943
12190
  }
12191
+ const start = this.createCursor % n;
12192
+ this.createCursor = (this.createCursor + 1) % n;
11944
12193
  let lastError;
11945
- for (let attempt = 0; attempt < n; attempt++) {
12194
+ let rateLimitRetries = 0;
12195
+ for (let offset = 0; offset < n; offset++) {
12196
+ const attempt = (start + offset) % n;
11946
12197
  let res;
11947
12198
  try {
11948
12199
  res = await this.transport.request(this.hostname, attempt, this.hostname, "/api/v1/sandboxes", {
@@ -11963,6 +12214,14 @@ var RunnerClient = class {
11963
12214
  lastError = error;
11964
12215
  continue;
11965
12216
  }
12217
+ if (error.isRateLimited) {
12218
+ if (rateLimitRetries >= 1) throw error;
12219
+ rateLimitRetries += 1;
12220
+ lastError = error;
12221
+ const waitMs = retryAfterMs(res.headers);
12222
+ if (waitMs > 0) await sleep9(waitMs);
12223
+ continue;
12224
+ }
11966
12225
  throw error;
11967
12226
  }
11968
12227
  const data = res.body ? JSON.parse(res.body) : {};
@@ -12512,6 +12771,7 @@ var Miosa = class {
12512
12771
  apiKey: credential,
12513
12772
  ...runnerRegion ? { region: runnerRegion } : {},
12514
12773
  ...config.runner?.baseDomain ? { baseDomain: config.runner.baseDomain } : {},
12774
+ ...config.runner?.pinAddresses === void 0 ? {} : { pinAddresses: config.runner.pinAddresses },
12515
12775
  ...config.tenant ? { tenant: config.tenant } : {},
12516
12776
  // C2: creates the runner doesn't serve (template/image/snapshot/
12517
12777
  // cwd/persistent/non-xs) go through the existing control-plane