Skip to content

Long-running operations

Some platform work — deploying an application, shipping storage between hosts — does not finish inside one HTTP request. For these, the SDK never blocks the kick-off call. Instead the method returns an Operation<T>: the kick-off result plus a waiter you call when (and if) you want to block until the work lands.

const op = await client.applications.deploy(appId, { canonical });
// The POST has already happened — op.resource is the accepted result.
const finalState = await op.waitUntilReady();
interface Operation<T> {
/** The kick-off result (e.g. the newly cut release, the upload grant). */
readonly resource: T;
/** Polls to a terminal state; throws WaiterError on timeout or failure. */
waitUntilReady(opts?: WaitOptions): Promise<OperationState>;
}
interface WaitOptions {
timeoutMs?: number; // default 300_000 (5 minutes)
pollIntervalMs?: number; // fixed interval; default is backed-off (1s → 10s, ×1.5)
signal?: AbortSignal; // aborts the wait between polls
onProgress?: (state: OperationState) => void;
}
interface OperationState {
phase: string; // operation-specific, e.g. 'deploying_serving'
terminal: boolean;
raw: unknown; // the full status response from the last poll
}

Semantics, exactly as implemented:

  • Kick-off returns immediately. The HTTP request that starts the work completes before you get the Operation. You can ignore the waiter entirely and poll yourself with the corresponding status method (listed per operation below).
  • Polling cadence. The first poll happens immediately. Without pollIntervalMs, the interval starts at 1 s and backs off ×1.5 to a 10 s ceiling. Passing pollIntervalMs fixes the interval and disables backoff.
  • Progress. onProgress is invoked after every poll with the latest OperationState, including the final one. The SDK never renders anything — progress display is yours.
  • Success resolves with the final OperationState.
  • Failure or timeout throws WaiterError, which carries the last observed state:
    • code: 'WAITER_FAILED' — the operation reached a terminal failure phase.
    • code: 'WAITER_TIMEOUT'timeoutMs elapsed first.
    • Either way, error.lastState is the last OperationState and error.lastState.raw is the full last status response — log it; it usually says why.
import { WaiterError } from '@edgible-team/sdk';
try {
await op.waitUntilReady({ timeoutMs: 600_000 });
} catch (err) {
if (err instanceof WaiterError) {
console.error(`gave up in phase '${err.lastState.phase}'`, err.lastState.raw);
}
throw err;
}

These methods on the standard client (createClient from @edgible-team/sdk) return an Operation:

MethodresourceStatus pollSuccess phaseFailure phases
client.applications.deploy(applicationId, { canonical })ApplicationDeclarationApplied (the newly cut immutable release — the declaration version IS the release, ADR-0008)client.applications.getDeploymentStatus(applicationId)readyerror, degraded
client.storage.promote({ applicationId, name, mobility? })PromoteRecordclient.storage.getPromote(promoteId)completedfailed, cancelled
client.storage.createUpload({ applicationId, name, … })StorageUploadGrant — includes the one-time sessionToken, never returned by later readsclient.storage.getUpload({ applicationId, uploadId })completedfailed, cancelled

Storage upload is a session machine: the kick-off mints the session (phases run pendingawaiting-upload → … → completed); you stream the tarball to resource.ingestUrl with resource.sessionToken while the waiter tracks the session.

Platform admin tooling (@edgible-team/sdk/admin, admin credentials required) exposes further long-running operations — cloud-host provisioning and decommission, and migrations — which follow the same Operation pattern. They are part of the internal/operator reference, not the public developer surface.

import { createClient, WaiterError } from '@edgible-team/sdk';
const client = createClient({ env: 'prod' });
const op = await client.applications.deploy(applicationId, { canonical: declaration });
console.log(`accepted as declaration v${op.resource.version}`);
try {
const final = await op.waitUntilReady({
timeoutMs: 600_000,
onProgress: (state) => console.log(`deployment: ${state.phase}`),
});
console.log(`done: ${final.phase}`); // 'ready'
} catch (err) {
if (err instanceof WaiterError) {
console.error(`deploy ended in '${err.lastState.phase}'`, err.lastState.raw);
}
throw err;
}

Typical progress output:

accepted as declaration v3
deployment: deploying
deployment: deploying_serving
deployment: serving_ready
deployment: deploying_gateway
deployment: ready
done: ready

Use the deployment-state FSM as your readiness signal, not the runtime status field. The waiter polls GET /applications/{id}/deployment-status, a finite-state machine driven by the devices as they apply the declaration (pending → deploying → deploying_serving → serving_ready → deploying_gateway → ready). The application record’s runtime status (deployed/deploying/…) lags it — don’t poll that. degraded means some device errored or degraded and is sticky until that device reports again; the waiter treats it as terminal failure.

The knobs that exist:

  • timeoutMs (per waitUntilReady call, default 300 000 ms) — overall deadline for the wait. Large storage transfers can legitimately exceed five minutes; raise it.
  • signal — an AbortSignal stops the waiter between polls; the promise rejects with the signal’s reason. This cancels your wait only — the server-side work continues.
  • pollIntervalMs — fix the cadence (e.g. 1000 for snappier progress in a CLI); omit for the default backoff.
  • Per-request options on the kick-off — every kick-off method accepts a second/third RequestOptions argument ({ signal, timeoutMs, idempotencyKey }) that applies to the kick-off HTTP request itself, not to the waiter.

To cancel the server-side work where the platform supports it:

  • Uploads: client.storage.cancelUpload({ applicationId, uploadId }).
  • Migrations: client.migrations.cancel(migrationId).

There is no cancel for deploys or promotes (past kick-off) — abandoning the waiter just means you stop watching.

Because the Operation is decoupled from the waiter, “fire and check later” needs no special API: keep the id from op.resource and call the status method (table above) from any process, any time.