@aerol-ai/aerolvm-sdk 0.7.0 → 0.8.0

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.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * An HTTP error response from the AerolVM server. `message` is the server's
3
+ * message, or `request failed with status N` when the body carries none (a
4
+ * proxy's HTML error page, say): the same text the SDK threw before this
5
+ * class existed, so code that printed or matched it sees no change.
6
+ *
7
+ * Branch on `code`, not on `message`: the code is a stable identifier, the
8
+ * message is wording for a person.
9
+ */
10
+ export declare class MicroVMApiError extends Error {
11
+ /** The HTTP status of the final attempt, such as 422 or 503. */
12
+ readonly status: number;
13
+ /**
14
+ * The server's stable error code, such as `"idempotency_key_reused"` or
15
+ * `"overloaded"`. Undefined when the response has none: many errors are
16
+ * only ever shown to a person, and a proxy's error page has no code.
17
+ */
18
+ readonly code: string | undefined;
19
+ /**
20
+ * How long the response's `Retry-After` header asked the caller to wait,
21
+ * in milliseconds. Undefined without a usable header.
22
+ */
23
+ readonly retryAfterMs: number | undefined;
24
+ constructor(status: number, message: string, options?: {
25
+ code?: string;
26
+ retryAfterMs?: number;
27
+ });
28
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * An HTTP error response from the AerolVM server. `message` is the server's
3
+ * message, or `request failed with status N` when the body carries none (a
4
+ * proxy's HTML error page, say): the same text the SDK threw before this
5
+ * class existed, so code that printed or matched it sees no change.
6
+ *
7
+ * Branch on `code`, not on `message`: the code is a stable identifier, the
8
+ * message is wording for a person.
9
+ */
10
+ export class MicroVMApiError extends Error {
11
+ /** The HTTP status of the final attempt, such as 422 or 503. */
12
+ status;
13
+ /**
14
+ * The server's stable error code, such as `"idempotency_key_reused"` or
15
+ * `"overloaded"`. Undefined when the response has none: many errors are
16
+ * only ever shown to a person, and a proxy's error page has no code.
17
+ */
18
+ code;
19
+ /**
20
+ * How long the response's `Retry-After` header asked the caller to wait,
21
+ * in milliseconds. Undefined without a usable header.
22
+ */
23
+ retryAfterMs;
24
+ constructor(status, message, options = {}) {
25
+ super(message);
26
+ this.name = "MicroVMApiError";
27
+ this.status = status;
28
+ this.code = options.code;
29
+ this.retryAfterMs = options.retryAfterMs;
30
+ }
31
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { MicroVM } from "./MicroVM.js";
2
2
  export { Sandbox } from "./Sandbox.js";
3
3
  export { Image } from "./Image.js";
4
+ export { MicroVMApiError } from "./errors.js";
4
5
  export type { MicroVMConfig, Tokens } from "./MicroVM.js";
5
6
  export type { RetryConfig } from "./internal/client.js";
6
7
  export type { AddCustomDomainOptions, APIToken, APITokenList, CreateAPITokenOptions, CreatedAPIToken, WhoAmI, WhoAmIPrincipal, AuditEvent, AuditOptions, AuditPage, EgressProfile, EgressProfileList, EgressProfileOptions, EgressProfileRef, EgressInject, EgressRule, ListEgressProfilesOptions, NetworkLearned, NetworkLearnedEntry, NetworkPolicy, NetworkPolicyCheckOptions, NetworkPolicyCheckResult, NetworkPolicyOptions, BinaryLike, BuildImageOptions, BuildImagePushOptions, BuildImageResult, CloneGeneration, CreateOptions, CreateSessionOptions, CreateTemplateOptions, CreateWasmModuleOptions, CustomDomain, CustomDomainDNSRecords, CustomDomainStatus, DNSRecord, DNSRecordType, ExecRequest, ExecResult, ExecExitInfo, ExposedPort, ExposePortOptions, ExposeProtocol, ExposeResult, Failover, FailoverPolicy, GetOptions, HealthStatus, IngressTarget, IngressTargetSource, Lifecycle, ListOptions, MountSpec, MountSpecRedacted, MountType, PlatformVolumeMount, RegisterSnapshotOptions, ListSnapshotsOptions, SnapshotList, RegistryAuth, ResizeOptions, Sandbox as SandboxData, SandboxSnapshot, SandboxStatus, Session, SessionAttachHandle, SessionAttachOptions, SessionStatus, Template, TemplatePushState, TemplateStatus, WasmModule, WasmModuleStatus, PushWasmModuleOptions, PushWasmModuleResult, UpdateLifecycleOptions, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export { MicroVM } from "./MicroVM.js";
2
2
  export { Sandbox } from "./Sandbox.js";
3
3
  export { Image } from "./Image.js";
4
+ export { MicroVMApiError } from "./errors.js";
@@ -81,6 +81,13 @@ export interface APIClientConfig {
81
81
  */
82
82
  signal?: AbortSignal;
83
83
  }
84
+ /**
85
+ * The header a sandbox create carries so the server can recognise a retry of
86
+ * it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
87
+ */
88
+ export declare const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
89
+ /** A fresh create key: 128 random bits as 32 lowercase hex characters. */
90
+ export declare function newIdempotencyKey(): string;
84
91
  /**
85
92
  * Reads a `Retry-After` value in either form RFC 9110 allows: delta-seconds
86
93
  * (`"120"`) or an HTTP date. Returns the wait in milliseconds (0 for a date
@@ -1,5 +1,6 @@
1
1
  import { basename } from "node:path";
2
2
  import { PATH_PREFIX as V1_PATH_PREFIX, ingressDNSPath as v1IngressDNSPath, sandboxCustomDomainDNSPath as v1SandboxCustomDomainDNSPath, sandboxCustomDomainPath as v1SandboxCustomDomainPath, sandboxCustomDomainsPath as v1SandboxCustomDomainsPath, } from "./api/v1/paths.js";
3
+ import { MicroVMApiError } from "../errors.js";
3
4
  import { Image } from "../Image.js";
4
5
  /**
5
6
  * The request header that selects which of a credential's teams a request
@@ -67,6 +68,19 @@ const RETRYABLE_STATUS_CODES = new Set([421, 429, 502, 503, 504]);
67
68
  // over its share, 503 when the node sheds load. A Retry-After on a 421, 502 or
68
69
  // 504 is a hop's, not the server's, so those use the normal backoff.
69
70
  const REFUSED_STATUS_CODES = new Set([429, 503]);
71
+ /**
72
+ * The header a sandbox create carries so the server can recognise a retry of
73
+ * it. Keep in sync with clustercreate.HeaderIdempotencyKey on the server.
74
+ */
75
+ export const IDEMPOTENCY_KEY_HEADER = "Idempotency-Key";
76
+ /** A fresh create key: 128 random bits as 32 lowercase hex characters. */
77
+ export function newIdempotencyKey() {
78
+ // getRandomValues, unlike randomUUID, also works on a page served over
79
+ // plain http.
80
+ const bytes = new Uint8Array(16);
81
+ globalThis.crypto.getRandomValues(bytes);
82
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
83
+ }
70
84
  /** Default retry settings when the caller doesn't supply a RetryConfig. */
71
85
  const DEFAULT_RETRY = {
72
86
  maxRetries: 3,
@@ -262,7 +276,13 @@ export class APIClient {
262
276
  }
263
277
  async create(options) {
264
278
  const resolved = await this.resolveImage(options);
265
- const response = await this.doJSON("POST", this.versioned("/sandboxes"), toApiCreateOptions(resolved));
279
+ // One key per call, sent unchanged on each of its automatic retries: a
280
+ // create retried after a timeout gets back the sandbox the first attempt
281
+ // made instead of making a second one.
282
+ const key = options.idempotencyKey?.trim() || newIdempotencyKey();
283
+ const response = await this.doJSON("POST", this.versioned("/sandboxes"), toApiCreateOptions(resolved), {
284
+ headers: { [IDEMPOTENCY_KEY_HEADER]: key },
285
+ });
266
286
  return new SandboxResource(this, fromApiCreateSandboxResponse(response));
267
287
  }
268
288
  /**
@@ -764,12 +784,10 @@ export class APIClient {
764
784
  return new SandboxResource(this, fromApiSandbox(sandbox));
765
785
  }
766
786
  async doJSON(method, path, body, options) {
767
- const init = {};
787
+ const init = { headers: { ...options?.headers } };
768
788
  if (body !== undefined) {
769
789
  init.body = JSON.stringify(body);
770
- init.headers = {
771
- "Content-Type": "application/json",
772
- };
790
+ init.headers = { ...init.headers, "Content-Type": "application/json" };
773
791
  }
774
792
  const response = await this.request(method, path, init, options);
775
793
  if (!response.ok) {
@@ -1432,16 +1450,19 @@ function toBlob(data) {
1432
1450
  return new Blob([Uint8Array.from(data)]);
1433
1451
  }
1434
1452
  async function decodeError(response) {
1453
+ const retryAfterMs = parseRetryAfter(response.headers.get("retry-after"), Date.now());
1435
1454
  try {
1436
1455
  const payload = (await response.json());
1437
1456
  if (typeof payload.error === "string" && payload.error !== "") {
1438
- return new Error(payload.error);
1457
+ const code = typeof payload.code === "string" && payload.code !== "" ? payload.code : undefined;
1458
+ return new MicroVMApiError(response.status, payload.error, { code, retryAfterMs });
1439
1459
  }
1440
1460
  }
1441
1461
  catch {
1442
- // Fall through to status-based error.
1462
+ // Not JSON (a proxy's error page) or no body: fall through to the
1463
+ // status-based message, with no code.
1443
1464
  }
1444
- return new Error(`request failed with status ${response.status}`);
1465
+ return new MicroVMApiError(response.status, `request failed with status ${response.status}`, { retryAfterMs });
1445
1466
  }
1446
1467
  const STREAM_PREFIX_STDOUT = 0x01;
1447
1468
  const STREAM_PREFIX_STDERR = 0x02;
package/dist/types.d.ts CHANGED
@@ -135,6 +135,19 @@ export interface GPUOptions {
135
135
  }
136
136
  import type { Image } from "./Image.js";
137
137
  export interface CreateOptions {
138
+ /**
139
+ * Sent as the create's `Idempotency-Key` header, never in the body. A create
140
+ * sent again with the same key and the same options returns the sandbox the
141
+ * first one made instead of making a second; the same key with different
142
+ * options fails with a 422 `MicroVMApiError` whose `code` is
143
+ * `"idempotency_key_reused"`. Keys are scoped to the caller's account.
144
+ *
145
+ * Leave it unset and the SDK makes a random key for each `create` call and
146
+ * reuses it on that call's automatic retries, so a retry after a timeout
147
+ * cannot make a second sandbox. Set your own to make your own retries of
148
+ * the whole call safe too, such as after your process restarts.
149
+ */
150
+ idempotencyKey?: string;
138
151
  /**
139
152
  * Optional name, unique among the caller's sandboxes (another account may
140
153
  * use the same name). A repeat create with a name the caller already holds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aerol-ai/aerolvm-sdk",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",