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.
What the SDK sends
Section titled “What the SDK sends”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 honest caveat
Section titled “The honest caveat”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:
| Scenario | Guaranteed safe? |
|---|---|
Retried reads (GET) | Yes — always |
Retried storage.promote / storage.createUpload with an explicit idempotencyKey | Yes — body-level dedupe |
| Retried writes on convergent surfaces (below) | Yes — by design, not by key |
| Any other retried write after an ambiguous failure | No — may duplicate |
Convergent surfaces: safe by design
Section titled “Convergent surfaces: safe by design”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 overwritten —
applications.declarations.apply(and thereforeapplications.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.rollbackis not a new version — it moves the deployment pointer back to an existing release (applications.declarations.rollbackreturns{ currentReleaseVersion, previousReleaseVersion?, unchanged? }), so a rollback to the release that’s already current is itself a no-op (unchanged: true). stacks.teardownskips missing applications (reported inresult.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
initFromentry with anuploadIdrecorded is not re-uploaded) and a failed seed never rolls back the accepted declaration.
Practical guidance
Section titled “Practical guidance”-
Retry reads freely. They are side-effect free, and the built-in retry middleware (exponential backoff + jitter, 4 attempts,
Retry-Afterhonoured) already covers transient failures — you rarely need your own retry loop. See Errors for which errors areretryableand how to branch oncode. -
Prefer the convergent surfaces for writes. Reach for
stacks.deploy/declarations.applyover rawapplications.createin anything that might run twice. -
Re-query before re-creating. If your script died between a create and its confirmation, list first and match by name — exactly what
stacks.deploydoes internally:const existing = (await client.organizations.listApplications({ organizationId })).find((a) => a.name === appName);const app = existing ?? await client.applications.create(createReq); -
Pass a stable
idempotencyKeywhere 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. -
Don’t build on the
Idempotency-Keyheader yet. Keep sending it (the SDK does this for you); just don’t assume it dedupes until the backend ships support.
See also
Section titled “See also”- Errors —
retryable, 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.