Production best practices
Session lifecycle knobs, why you don't need your own warm pool, bulk file transfer, idempotent creates, reconnecting after resume, pagination, and retries.
Patterns for running inis.run past the first demo — once a session is doing real work in a loop, not just answering one quickstart call. Each section is a question that comes up once you're operating this in production, with the checked answer and a link to the fuller reference.
Pick the right lifecycle knob for the job
Session lifecycle has the full table. The short version, by what you're trying to bound:
- Gaps between work, not a hard deadline —
idle_timeout_ms. Auto-pauses a session after it sits idle while live, so an agent between tool calls stops paying compute without you callingpause()yourself. It's a real idle detector, not a blunt wall clock — a busy guest or an attached terminal defers it. Live-verified: a session created with a 15-secondidle_timeout_mswas alreadypausedwhen checked 25 seconds later. - A compute ceiling regardless of activity —
max_lifetime_ms. Caps total session age, live or paused, independent of how busy it's been. Use it for "this job should never still be running after N hours" — a runaway agent loop, a forgotten dev session — where idle detection alone wouldn't catch a session that's busy but should still stop. Reaching it auto-pauses the session rather than destroying it — it frees the compute, not the storage — but unlikeidle_timeout_ms, that pause is permanent: the session can never resume again (any attempt, explicit or automatic, is refused). Use this only for work you're genuinely done needing live, not as a "pause and come back later" mechanism. If you also want a forgotten session to actually go away, pair it withpaused_ttl_msbelow;max_lifetime_msalone will not delete anything. - Bounding what you pay to keep around —
paused_ttl_ms. A paused session is storage, not compute, but it isn't free forever — and sincemax_lifetime_msonly pauses (permanently, never destroys), this is the knob that actually bounds how long an auto-paused session (or any paused session) sticks around. Set this to auto-destroy a paused session after it's sat unused for a while, instead of it lingering until you notice and clean it up. - One command and done —
destroy_on_completion. Skip persistence entirely: the session tears itself down after its first command finishes. Use this for genuinely stateless work instead of creating a normal session and destroying it yourself right after.
None of these need to be combined — pick the one (or two) that match the actual failure mode you're protecting against, and leave the rest at their server defaults.
Skip building your own warm pool
If you've built against a sandbox platform before, you've probably built a pool: keep N containers pre-started and idle, hand one out on request, because a cold start from nothing is too slow to eat on the request path.
Don't reproduce that here. A paused session keeps its own retained state and disk, so resuming it restores from that rather than starting a template from scratch — tens of milliseconds when it's hot, a second or two when it's warm, depending on how recently it ran (see Session lifecycle). That speed comes from the session's own retained state, not from anything you have to keep live and idle on its behalf. A spare pool of started-up sessions buys you nothing a paused session doesn't already have, and costs you idle compute plus a pool-sizing problem.
The practical difference this makes: pause between uses instead of destroy
and recreate. Destroying and recreating a session throws away its retained state
and its disk, and pays a full template start on the next request —
exactly the cost a client-side pool exists to hide. Pausing keeps both, and
the next resume reads back the state it already had. For an agent
harness handling concurrent requests, that means a thread_id → session_id
map with sessions left paused between turns beats a pool of pre-spun,
always-live sessions checked out per request — cheaper (no idle compute) and
no pool-sizing problem to get wrong in the first place.
Bulk file transfer: zip, upload, extract
Two ways to get files into a session, with different ceilings:
write_files— batch small-file writes in one call. Capped at 64 files / 8 MiB total content, and not fail-fast: it returns one{path, ok, error?}result per file, so one bad path in the batch doesn't sink the rest.upload_file— streams one local file straight into the session overPUT /v1/sessions/{id}/files/stream, reading it in bounded chunks rather than loading it into memory first. No size cap tied to the batch endpoint.
Neither is a "send this whole directory" primitive. For a project checkout,
a dataset, or anything with more than a handful of files, zip it locally,
upload_file the single archive, then extract it inside the session:
import shutil
import tempfile
from pathlib import Path
from inis import Client
client = Client()
session = client.sessions.create(template="python")
local_dir = Path("local_project")
with tempfile.TemporaryDirectory() as tmp:
archive_base = Path(tmp) / "payload"
archive_path = shutil.make_archive(str(archive_base), "zip", root_dir=local_dir)
session.upload_file(archive_path, "/workspace/payload.zip")
session.exec(["unzip", "-q", "-o", "/workspace/payload.zip", "-d", "/workspace/project"])
session.remove("/workspace/payload.zip")import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { Client } from "@inis-run/sdk";
const client = new Client();
const session = await client.sessions.create({ template: "python" });
const localDir = "local_project";
const tmp = mkdtempSync(path.join(tmpdir(), "inis-upload-"));
const archivePath = path.join(tmp, "payload.zip");
execFileSync("zip", ["-rq", archivePath, "."], { cwd: localDir });
await session.uploadFile(archivePath, "/workspace/payload.zip");
rmSync(tmp, { recursive: true, force: true });
await session.exec(["unzip", "-q", "-o", "/workspace/payload.zip", "-d", "/workspace/project"]);
await session.remove("/workspace/payload.zip");unzip and Python 3 both ship on the base template, so the extract step
needs nothing extra installed. Going the other direction — pulling files
back out — download_file is the same streamed
path for one large file; zip inside the session first if you need a whole
directory back out in one shot.
Idempotent creates and looking up sessions by your own key
Two session-create options solve two different problems, and it's easy to reach for the wrong one:
Idempotency-Key makes a retry safe. Set it when your process might
crash, time out, or lose the response after the request already reached the
server, and you don't know whether the session actually got created:
session = client.sessions.create(
template="python",
idempotency_key=f"order-{order_id}-session-create",
)const session = await client.sessions.create({
template: "python",
idempotencyKey: `order-${orderId}-session-create`,
});Retrying with the same key, the same account, and the same request body
returns the original session instead of creating a second one. Retrying with
the same key but a different body — a different template, size, or any
other field — is rejected with 409 conflict rather than silently replaying
the first call. That's deliberate, the same contract Stripe and most
idempotent-write APIs use: it catches a caller reusing a key across two
genuinely different requests instead of quietly serving the wrong one back.
Only reuse a key when the body is byte-for-byte the same logical request;
mint a new one for anything else.
external_id solves a different problem: finding a session again later
without keeping your own external_id → session_id table. Stamp it once at
create with a key from your own system — a user, workflow, or thread id —
and look it up by that key instead of storing the ID yourself:
session = client.sessions.create(template="python", external_id=f"thread-{thread_id}")
# later, from anywhere, without having kept session_id around:
sessions, _ = client.sessions.list(external_id=f"thread-{thread_id}")const session = await client.sessions.create({ template: "python", externalId: `thread-${threadId}` });
// later, from anywhere, without having kept sessionId around:
const { sessions } = await client.sessions.list({ externalId: `thread-${threadId}` });external_id is not unique — a long-running workflow that spins up
several sessions over its life can stamp all of them with the same value, and
a filtered list returns every match. If you need exactly one session per
key, that uniqueness is on you to enforce (check the list before creating,
or fold the key into an Idempotency-Key as well). See Index by your own
id for the full field reference.
Reconnecting after resume
Pausing a session freezes it completely — nothing runs while it's paused, compute included. That's already the reason preview URLs drop while a session is paused and pick back up after wake. The same thing happens to any outbound connection your own code opened and held onto — a database connection pool, a long-lived websocket, a long poll to some other API. Nothing inside the guest closed it, but the remote end has had an arbitrary gap with total silence on the wire, and may well have already timed it out or reset it from its side by the time the session wakes back up.
Treat a connection your code opened before a pause as unverified after a resume, not as still good. The fix is the same shape API clients already use for connection pools that can go stale on their own — check before you trust it, reconnect if the check fails — applied at the point you next use it, rather than assuming a connection opened at process start stays valid forever:
# Code that runs INSIDE the session — via exec, a background process, or a
# script the session executes — not the SDK client itself. SDK calls (exec,
# files, ...) are unaffected by this: each one opens its own connection to
# the session on demand and wakes it via the normal wake-on-op path.
import psycopg
_conn = None
def get_db():
global _conn
try:
if _conn is None:
raise psycopg.OperationalError
_conn.execute("SELECT 1")
except psycopg.OperationalError:
_conn = psycopg.connect(DATABASE_URL)
return _connA background process started with keep_alive: true is the case most worth
checking: it's meant to outlive individual tool calls, which means it's also
the thing most likely to be holding a connection open across a pause it
didn't ask for.
Pagination: only state=ended history actually pages
GET /v1/sessions behaves differently depending on state, and the two
modes don't share a pagination story:
state=ended(session history) has real cursor pagination: passcursorfrom the previous page's response to get the next one, and an empty/absentnext_cursormeans you've reached the end.- Every other state (
live,paused,creating, or unfiltered) is a flat, capped query, newest first —limitcontrols how many rows come back, but there's no cursor to page through the rest with. A live/paused list response never carries anext_cursorto page against.
# state=ended: page through with cursor
cursor = None
ended = []
while True:
page, cursor = client.sessions.list(state="ended", limit=100, cursor=cursor)
ended.extend(page)
if not cursor:
break
# live/paused: no cursor to page with — narrow the query or raise limit instead
live, _ = client.sessions.list(state="live", limit=200)// state=ended: page through with cursor
let cursor: string | undefined;
const ended = [];
do {
const page = await client.sessions.list({ state: "ended", limit: 100, cursor });
ended.push(...page.sessions);
cursor = page.nextCursor;
} while (cursor);
// live/paused: no cursor to page with — narrow the query or raise limit instead
const { sessions: live } = await client.sessions.list({ state: "live", limit: 200 });If you're tracking a lot of concurrently live/paused sessions, filter by
external_id or raise limit rather than looking for a cursor that isn't
there for that query.
Errors and retries
Every error response carries a stable code, plus retryable,
retry_after, and request_id — branch on those, not on the human-readable
message (it can be reworded). Full code table: Errors.
from inis import InisError
try:
session.exec(["python3", "task.py"])
except InisError as e:
if e.retryable:
time.sleep(e.retry_after or 1)
session.exec(["python3", "task.py"])
elif e.code == "session_not_live":
session.get() # refetch state — likely a wake race — then retry
else:
raiseimport { InisError } from "@inis-run/sdk";
try {
await session.exec(["python3", "task.py"]);
} catch (err) {
if (err instanceof InisError && err.retryable) {
await new Promise((r) => setTimeout(r, (err.retryAfter ?? 1) * 1000));
await session.exec(["python3", "task.py"]);
} else if (err instanceof InisError && err.code === "session_not_live") {
await session.get(); // refetch state — likely a wake race — then retry
} else {
throw err;
}
}session_not_live is worth calling out specifically: it's the ordinary
result of racing a session that just paused itself, not a real failure —
refetch and retry once it's back. idempotency_key_in_progress is the same
shape for a concurrent duplicate create with the same key. Both are
retryable: true for exactly that reason.
session_node_lost is the opposite case: the session's home node was
declared permanently dead and there is no restore path back to it.
retryable is false — don't loop on it. Recreate the session from its
template, or restore from your last checkpoint if you took one; data since
that checkpoint is gone. GET and DELETE are the two exceptions — you can
still read a lost session's state, and deleting it always succeeds, so it
never sits undeletable in your session list. See
If a node fails for the full
contract.
Where to go next
- Best practices — persistence, resume/fork, templates, scoped credentials, locked-down egress
- Session lifecycle — full timer table, resume tiers, pause vs checkpoint vs template
- Sessions API — request/response reference,
including
external_idandIdempotency-Keyfield details - Errors — full error code table
- Agent recipes — task-oriented patterns with runnable source
- Networking & egress — locking down what a session can reach, including across pause and fork