← All guides
ephemeral computePythonreliabilitycleanup

A Safe Lifecycle Pattern for Ephemeral Compute

A dependency-free Python controller that creates a temporary VM, waits with a deadline, runs bounded work, and retries cleanup without hiding failures.

Published · LiteVPS Engineering

Safe ephemeral compute requires two independent cleanup paths: the controller must delete the resource in a finally block, and the resource should have a server-side expiry as a backstop. Creation needs special retry handling because repeating a timed-out create request can allocate duplicates. Readiness and work need deadlines, and cleanup failures must be logged for reconciliation rather than silently ignored.

The following dependency-free Python controller implements this pattern for LiteVPS. It uses only the standard library so its behavior is visible, but the same state machine applies when using an HTTP client library or job system.

What can go wrong in a create-run-destroy workflow?

The happy path has three API calls. Production behavior has more states:

  • The create request can reach the server even if the client times out before receiving the response.
  • A started VM may not yet be ready for commands.
  • A command can time out or return a nonzero exit code.
  • The controller can receive a termination signal between creation and cleanup.
  • Deletion can fail because of a temporary control-plane or hypervisor problem.
  • A controller can crash before its in-memory finally block runs.

The design below does not pretend those cases disappear. It limits them and leaves enough identity for a separate reconciler to find uncertain resources.

What state machine should the controller use?

Use explicit transitions:

planned -> creating -> created -> ready -> executing -> complete
                    \-> uncertain
created/ready/executing/complete -> deleting -> deleted
                               \-> cleanup-required

uncertain matters. If an HTTP create request times out, immediately retrying the same non-idempotent request may create another VM. First list the account's VMs and search for the unique task name. If there is exactly one match, adopt it. If there are zero or multiple matches, stop and require reconciliation.

How do you implement the lifecycle in Python?

Set the token, plan ID, and template slug from the current LiteVPS catalogs:

export LITEVPS_TOKEN="vtk_replace_with_your_token"
export LITEVPS_PLAN_ID="replace-with-a-current-plan-id"
export LITEVPS_TEMPLATE="replace-with-a-current-template-slug"

Save this as ephemeral_job.py:

#!/usr/bin/env python3
import json
import os
import sys
import time
import urllib.error
import urllib.request
import uuid

API = os.environ.get("LITEVPS_API", "https://api.litevps.dev")
TOKEN = os.environ["LITEVPS_TOKEN"]
PLAN_ID = os.environ["LITEVPS_PLAN_ID"]
TEMPLATE = os.environ["LITEVPS_TEMPLATE"]


class APIError(RuntimeError):
    pass


def request(method, path, payload=None, timeout=60):
    body = None if payload is None else json.dumps(payload).encode()
    req = urllib.request.Request(
        API + path,
        data=body,
        method=method,
        headers={
            "Authorization": "Bearer " + TOKEN,
            "Content-Type": "application/json",
            "Accept": "application/json",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as response:
            raw = response.read()
            return json.loads(raw) if raw else None
    except urllib.error.HTTPError as exc:
        detail = exc.read().decode(errors="replace")
        raise APIError(f"{method} {path}: HTTP {exc.code}: {detail}") from exc


def wait_until_ready(vps_id, timeout_seconds=600):
    deadline = time.monotonic() + timeout_seconds
    delay = 2
    while time.monotonic() < deadline:
        vps = request("GET", f"/api/vps/{vps_id}")["vps"]
        if vps.get("ready") is True:
            return vps
        if vps.get("state") in {"error", "crashed"}:
            raise RuntimeError(f"VM entered terminal state {vps['state']}")
        time.sleep(delay)
        delay = min(delay * 1.5, 15)
    raise TimeoutError(f"VM {vps_id} did not become ready in time")


def delete_with_retry(vps_id, attempts=4):
    for attempt in range(1, attempts + 1):
        try:
            request("DELETE", f"/api/vps/{vps_id}", timeout=90)
            return True
        except (APIError, OSError) as exc:
            print(f"cleanup attempt {attempt} failed: {exc}", file=sys.stderr)
            if attempt < attempts:
                time.sleep(2 ** attempt)
    return False


def main():
    task_id = uuid.uuid4().hex[:12]
    name = "agent-" + task_id
    vps_id = None
    cleanup_ok = False

    try:
        # Do not blindly retry this non-idempotent operation on a network timeout.
        created = request(
            "POST",
            "/api/vps",
            {
                "name": name,
                "plan_id": PLAN_ID,
                "template": TEMPLATE,
                "ttl_minutes": 60,
            },
            timeout=180,
        )
        vps_id = created["vps"]["id"]
        print(json.dumps({"event": "created", "task_id": task_id, "vps_id": vps_id}))

        wait_until_ready(vps_id)
        result = request(
            "POST",
            f"/api/vps/{vps_id}/exec",
            {
                "command": "python3 -c 'print(sum(i*i for i in range(10000)))'",
                "timeout_seconds": 60,
            },
            timeout=90,
        )
        print(json.dumps({"event": "result", "task_id": task_id, **result}))
        if result["exit_code"] != 0:
            raise RuntimeError("worker command failed")
    finally:
        if vps_id is not None:
            cleanup_ok = delete_with_retry(vps_id)
            print(json.dumps({
                "event": "cleanup",
                "task_id": task_id,
                "vps_id": vps_id,
                "deleted": cleanup_ok,
            }))

    if not cleanup_ok:
        raise RuntimeError("cleanup failed; reconciliation required")


if __name__ == "__main__":
    main()

Run it with:

python3 ephemeral_job.py

This script uses a 60-minute server-side TTL and still deletes explicitly. LiteVPS currently checks expired resources periodically, so TTL is not an exact second-level deadline. It protects against a controller that disappears; the finally block protects the normal and exceptional paths while the process remains alive.

Why does create not have a normal retry loop?

GET and many DELETE operations can usually be retried safely because repeating them does not intentionally allocate a second resource. POST create is different unless the API supports an idempotency key.

The current LiteVPS create request does not expose an idempotency key. The example therefore gives every task a unique name and refuses to hide create uncertainty behind an automatic retry. A production controller should add this recovery procedure:

  1. Record task_id and VM name durably before sending create.
  2. If create times out, call GET /api/vps.
  3. Select VMs whose name exactly equals the recorded name.
  4. Adopt one exact match; escalate zero or multiple matches.
  5. Never infer success from a partial response.

This procedure is reconciliation, not perfect idempotency, but it prevents the most common duplicate-allocation mistake.

Why use monotonic time and bounded backoff?

Wall clocks can jump when synchronized. time.monotonic() measures elapsed time and is appropriate for readiness deadlines. The loop starts at two seconds, increases gradually, and stops at 15 seconds so it does not hammer the API or become unresponsive.

Every polling loop needs both a maximum interval and a final deadline. A loop without a deadline converts a failed worker into a permanently occupied controller slot.

What should the logs contain?

At minimum, emit structured events with:

  • Your durable task ID.
  • The LiteVPS VM ID and unique VM name.
  • State transitions and timestamps.
  • Command exit code and duration.
  • Whether deletion succeeded.
  • The final error category: create-uncertain, readiness-timeout, command-failed, or cleanup-required.

Do not log API tokens, user data, command secrets, or unfiltered worker output. Worker output is untrusted and may contain credentials or control characters.

What should a reconciler do?

Run a separate periodic process that lists VMs and compares them with durable task records. Flag task-prefixed VMs that are older than their expected lifetime, have no active task, or belong to a task marked complete. Delete only when your ownership rules are unambiguous.

TTL is a useful final safety net, but a reconciler gives operators visibility before expiry and catches mismatches in either direction.

When should you use this pattern?

Use it for batch jobs, integration tests, temporary development environments, and agent tools where each task should receive a fresh machine and cleanup must be auditable. It is especially useful when the controller and worker have clearly separate credentials and responsibilities.

When should you not use this pattern?

Do not create one VM per tiny operation when a narrow API or warm worker pool meets the trust requirements. Do not use this controller unchanged for adversarial code, regulated data, or jobs needing multi-region failover. Those require a fuller threat model, network controls, durable orchestration, and tested disaster behavior.

How to give an AI agent a disposable Linux sandbox explains the isolation boundary and a shell implementation. VPS, container, or serverless function for agent code execution? helps decide whether a VM is warranted at all. See the LiteVPS API reference and current pricing before adapting the code.

Methodology note

The request and response shapes, readiness field, execution result, deletion response, billing lifecycle, and TTL behavior were checked against the LiteVPS implementation on July 23, 2026. The controller is intentionally conservative around create retries. Test failure paths in a non-production account before relying on it.