Skip to content

Idempotency & safe re-runs

Scripts get re-run. Processes die mid-flight and restart. This guide is the honest account of what the SDK guarantees when that happens — and what it doesn’t, yet.

Every write (POST, PUT, PATCH, DELETE) carries an Idempotency-Key header — a generated UUID, unless you set one yourself:

await client.applications.create(req, { idempotencyKey: 'my-stable-key' });

The idempotency middleware sits above the retry middleware in the pipeline, so all automatic retry attempts of one logical request share the same key. A key you pass via RequestOptions.idempotencyKey (or your own Idempotency-Key header on client.request()) is used as-is; the middleware never overwrites an existing header.

The backend does not yet honour the Idempotency-Key header. The header is forward-looking: it is sent on every write so that server-side deduplication can light up without an SDK change, but today it deduplicates nothing.

What the backend does have is per-endpoint idempotency via body fields, and the SDK threads opts.idempotencyKey into the request body on exactly the endpoints that read one:

  • client.storage.promote(params, { idempotencyKey })
  • client.storage.createUpload(params, { idempotencyKey })

(Admin migration endpoints accept a body-level key too; see the internal/operator reference.)

Pass a stable key to those endpoints and a re-issued request converges on the server. Everywhere else, a write that is retried after an ambiguous failure — the request reached the server, the response never reached you — can duplicate. This matters because the SDK’s retry middleware retries any error flagged retryable (network errors, timeouts, 429, 5xx) regardless of HTTP method. Example: client.applications.create() succeeding server-side but failing on the wire, then being retried, creates a second application — or 409s on the duplicate name, depending on the endpoint’s own constraints.

So, the guarantees today:

ScenarioGuaranteed safe?
Retried reads (GET)Yes — always
Retried storage.promote / storage.createUpload with an explicit idempotencyKeyYes — body-level dedupe
Retried writes on convergent surfaces (below)Yes — by design, not by key
Any other retried write after an ambiguous failureNo — may duplicate

Some surfaces are re-runnable not because of keys but because re-applying the same intent converges on the same state.

client.stacks.deploy converges. The raw POST /applications/createApplication rejects duplicate names with a 409 (APPLICATION_NAME_ALREADY_IN_USE); stack deploy is create-or-update — applications already observed in the organization (matched by metadata.name) get a new declaration version instead of a failing create. Running the same stack deploy twice is safe; the second run produces new declaration versions, not duplicates or errors:

const result = await client.stacks.deploy({ organizationId, yaml: stackYaml, env: process.env });
for (const app of result.deployed) {
console.log(`${app.name}: ${app.action}`); // 'created' on first run, 'updated' on re-runs
}

Related conveniences, verified in the source:

  • Declarations are versioned by resolved content, not overwrittenapplications.declarations.apply (and therefore applications.deploy) cuts a new release when the resolved content (spec + pinned image digests) changes, and is a no-op when it doesn’t: re-applying identical content never conflicts and never churns versions. rollback is not a new version — it moves the deployment pointer back to an existing release (applications.declarations.rollback returns { currentReleaseVersion, previousReleaseVersion?, unchanged? }), so a rollback to the release that’s already current is itself a no-op (unchanged: true).
  • stacks.teardown skips missing applications (reported in result.skipped) and keeps going past per-app delete failures — re-running a partial teardown finishes the job.
  • Stack storage seeding skips already-seeded entries (an initFrom entry with an uploadId recorded is not re-uploaded) and a failed seed never rolls back the accepted declaration.
  1. Retry reads freely. They are side-effect free, and the built-in retry middleware (exponential backoff + jitter, 4 attempts, Retry-After honoured) already covers transient failures — you rarely need your own retry loop. See Errors for which errors are retryable and how to branch on code.

  2. Prefer the convergent surfaces for writes. Reach for stacks.deploy / declarations.apply over raw applications.create in anything that might run twice.

  3. Re-query before re-creating. If your script died between a create and its confirmation, list first and match by name — exactly what stacks.deploy does internally:

    const existing = (await client.organizations.listApplications({ organizationId }))
    .find((a) => a.name === appName);
    const app = existing ?? await client.applications.create(createReq);
  4. Pass a stable idempotencyKey where the backend reads it (storage.promote, storage.createUpload) — e.g. derive it from your job/run id, so a crashed-and-restarted run resumes instead of double-dispatching.

  5. Don’t build on the Idempotency-Key header yet. Keep sending it (the SDK does this for you); just don’t assume it dedupes until the backend ships support.

  • Errorsretryable, the retry policy (ClientConfig.retry), error codes.
  • Long-running operations — re-attaching to in-flight work via status reads instead of re-issuing kick-offs.