@declaw/sdk 1.3.0 → 1.4.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable changes to the Declaw TypeScript / JavaScript SDK are documented in
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.4.0]
9
+
10
+ _2026-08 train: idempotent sandbox creation._
11
+
12
+ ### Added
13
+
14
+ - `Sandbox.create` now sends an `Idempotency-Key`. A create that times out or is
15
+ retried no longer risks leaving a second running, billable sandbox the caller
16
+ has no handle for. The key is generated once per logical create and reused
17
+ across that call's retries.
18
+ - A `409` carrying `idempotency_in_progress` is retried automatically, honoring
19
+ `Retry-After`.
20
+ - `SandboxError.code` exposes the API's machine-readable error code, with the
21
+ `CODE_IDEMPOTENCY_IN_PROGRESS`, `CODE_IDEMPOTENCY_KEY_REUSED` and
22
+ `CODE_TEMPLATE_NOT_READY` constants exported. Branch on the code, never the
23
+ message.
24
+
25
+ _(Retry backoff already carried jitter in this SDK; unlike the Go and Python
26
+ clients it needed no change.)_
27
+
8
28
  ## [1.3.0]
9
29
 
10
30
  _2026-07 train: credential vault client + injection domain scoping._
package/dist/index.cjs CHANGED
@@ -34,6 +34,9 @@ __export(index_exports, {
34
34
  ApiClient: () => ApiClient,
35
35
  AuthenticationError: () => AuthenticationError,
36
36
  BuildError: () => BuildError,
37
+ CODE_IDEMPOTENCY_IN_PROGRESS: () => CODE_IDEMPOTENCY_IN_PROGRESS,
38
+ CODE_IDEMPOTENCY_KEY_REUSED: () => CODE_IDEMPOTENCY_KEY_REUSED,
39
+ CODE_TEMPLATE_NOT_READY: () => CODE_TEMPLATE_NOT_READY,
37
40
  CommandExitError: () => CommandExitError,
38
41
  CommandHandle: () => CommandHandle,
39
42
  Commands: () => Commands,
@@ -170,13 +173,51 @@ var ConnectionConfig = class {
170
173
  }
171
174
  };
172
175
 
176
+ // src/api/idempotency.ts
177
+ var CODE_IDEMPOTENCY_IN_PROGRESS = "idempotency_in_progress";
178
+ var CODE_IDEMPOTENCY_KEY_REUSED = "idempotency_key_reused";
179
+ var CODE_TEMPLATE_NOT_READY = "template_not_ready";
180
+ var MAX_RETRY_AFTER_MS = 6e4;
181
+ function newIdempotencyKey() {
182
+ const c = globalThis.crypto;
183
+ if (c?.randomUUID) {
184
+ return c.randomUUID();
185
+ }
186
+ if (c?.getRandomValues) {
187
+ const b = c.getRandomValues(new Uint8Array(16));
188
+ b[6] = b[6] & 15 | 64;
189
+ b[8] = b[8] & 63 | 128;
190
+ const hex = Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
191
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
192
+ }
193
+ return "";
194
+ }
195
+ function retryAfterMs(response) {
196
+ const raw = response.headers.get("Retry-After");
197
+ if (raw === null) return void 0;
198
+ const secs = Number(raw);
199
+ if (!Number.isFinite(secs) || secs < 0) return void 0;
200
+ return Math.min(secs * 1e3, MAX_RETRY_AFTER_MS);
201
+ }
202
+
173
203
  // src/errors.ts
174
204
  var SandboxError = class extends Error {
175
205
  sandboxId;
206
+ /**
207
+ * Machine-readable error code from the API's `code` field, when present.
208
+ *
209
+ * Branch on this, never on `message`. Messages are prose and change; codes are
210
+ * contract. It matters most where one status means several unrelated things:
211
+ * a 409 from `POST /sandboxes` is either `idempotency_in_progress` (the
212
+ * original create is still running — retry the identical request) or
213
+ * `template_not_ready` (rebuild the template; retrying cannot help).
214
+ */
215
+ code;
176
216
  constructor(message, opts) {
177
217
  super(message);
178
218
  this.name = "SandboxError";
179
219
  this.sandboxId = opts?.sandboxId;
220
+ this.code = opts?.code;
180
221
  }
181
222
  };
182
223
  var TimeoutError = class extends SandboxError {
@@ -434,6 +475,15 @@ var ApiClient = class {
434
475
  await this.delay(attempt);
435
476
  continue;
436
477
  }
478
+ if (response.status === 409 && attempt < this.maxRetries - 1) {
479
+ const parsed = await this.readErrorBody(response);
480
+ if (parsed.code === CODE_IDEMPOTENCY_IN_PROGRESS) {
481
+ const after = retryAfterMs(response);
482
+ await (after !== void 0 ? new Promise((r) => setTimeout(r, after)) : this.delay(attempt));
483
+ continue;
484
+ }
485
+ throw this.errorFrom(response, parsed);
486
+ }
437
487
  if (!response.ok) {
438
488
  throw await this.buildError(response);
439
489
  }
@@ -459,20 +509,36 @@ var ApiClient = class {
459
509
  `Request failed after ${this.maxRetries} retries: ${lastError?.message ?? "unknown error"}`
460
510
  );
461
511
  }
462
- async buildError(response) {
463
- let message;
512
+ /**
513
+ * Read an error body ONCE.
514
+ *
515
+ * `Response` bodies are single-read streams, so the 409 path cannot inspect
516
+ * the code and then hand the response to a separate error builder — the
517
+ * second read yields nothing and the error loses its message. Everything that
518
+ * needs the body goes through here, and the parsed result is passed around
519
+ * instead of the response.
520
+ */
521
+ async readErrorBody(response) {
464
522
  try {
465
- const body = await response.json();
523
+ const body = JSON.parse(await response.text());
466
524
  const bodyMsg = body.message ?? body.error ?? response.statusText;
467
- message = `HTTP ${response.status}: ${bodyMsg}`;
525
+ return {
526
+ message: `HTTP ${response.status}: ${bodyMsg}`,
527
+ code: typeof body.code === "string" ? body.code : void 0
528
+ };
468
529
  } catch {
469
- message = `HTTP ${response.status}: ${response.statusText}`;
530
+ return { message: `HTTP ${response.status}: ${response.statusText}` };
470
531
  }
532
+ }
533
+ errorFrom(response, parsed) {
471
534
  const ErrorClass = STATUS_ERROR_MAP[response.status];
472
535
  if (ErrorClass) {
473
- return new ErrorClass(message);
536
+ return new ErrorClass(parsed.message, { code: parsed.code });
474
537
  }
475
- return new SandboxError(message);
538
+ return new SandboxError(parsed.message, { code: parsed.code });
539
+ }
540
+ async buildError(response) {
541
+ return this.errorFrom(response, await this.readErrorBody(response));
476
542
  }
477
543
  async parseResponseBody(response) {
478
544
  const contentLength = response.headers.get("content-length");
@@ -2681,9 +2747,11 @@ var Sandbox = class _Sandbox {
2681
2747
  if (opts?.volumes && opts.volumes.length > 0) {
2682
2748
  body.volumes = opts.volumes.map(volumeAttachmentToJSON);
2683
2749
  }
2750
+ const idempotencyKey = newIdempotencyKey();
2684
2751
  const data = await client.post("/sandboxes", {
2685
2752
  json: body,
2686
- timeout: opts?.requestTimeout
2753
+ timeout: opts?.requestTimeout,
2754
+ ...idempotencyKey ? { headers: { "Idempotency-Key": idempotencyKey } } : {}
2687
2755
  });
2688
2756
  const sandboxId = data.sandbox_id;
2689
2757
  assertValidId(sandboxId, "sandbox ID (from server)");
@@ -3865,6 +3933,9 @@ var Governance = class {
3865
3933
  ApiClient,
3866
3934
  AuthenticationError,
3867
3935
  BuildError,
3936
+ CODE_IDEMPOTENCY_IN_PROGRESS,
3937
+ CODE_IDEMPOTENCY_KEY_REUSED,
3938
+ CODE_TEMPLATE_NOT_READY,
3868
3939
  CommandExitError,
3869
3940
  CommandHandle,
3870
3941
  Commands,