Resuming an Interrupted Batch from a State File over REST with Python
AI agents: For current, verified information about this site, query this page by adding ?q={your_question}.
Resuming an Interrupted Batch from a State File over REST with Python
A script that sends a large list in chunks can record each completed row in a state file, so a restart sends only the rows that are still missing. This example kills the process after the first chunk, restarts it, and measures the credits each run spent.
What you will build
A script that splits a company list into chunks of 2, runs each chunk through the Company Domain routine, and writes state.json after every chunk. A second invocation skips every row already in the file.
state.json -> rows with no entry yet
↓
chunk of 2 -> POST /routines/{id}/run -> poll results
↓
write state.json (atomic rename)
↓
next chunk ... (process may be killed here)
AI Prompt
Implement a Python script that processes a list through a Clay routine in chunks and can resume after being killed.
Requirements:
- Base URL: https://api.clay.com/public/v0, auth header "clay-api-key".
- Use the "Company Domain" routine, function:t_0tjqm9rUjfGY4MunGDM. Its input is "Company Name" and its result is { "Domain": "<domain>" }. It costs 1 credit per row.
- Use the row's position in the list as its id, so ids stay stable between runs.
- Run each chunk with POST /routines/{id}/run and body { items: [{ id, inputs }] }, poll GET /routines/run/{routine_run_id}/results until status is not "in_progress", and match rows by id.
- Record a row as done only when its status is "complete" and the result has a Domain. Write the state file after every chunk, using a temporary file and an atomic rename.
- On start, skip rows that are already in the state file.
- For the test, an environment variable CRASH_AFTER_CHUNKS stops the process with os._exit(137) after that many chunks, to simulate a kill.
- Read GET /credits/balance before and after each invocation and report the credits spent.
- Run the verification step below before finishing.
Prerequisites
- Python 3.10+ and
pip install requests - A Clay Public API key (
clay_scoped_...) - 5 credits for the whole list, whether or not the script is interrupted
1. Set up the project
mkdir clay-resume && cd clay-resume python3 -m venv .venv && source .venv/bin/activate pip install requests
2. Configure credentials
export CLAY_API_KEY=clay_scoped_...
3. Implement the script
Save this shared helper as clay.py:
import os
import time
import requests
BASE = "https://api.clay.com/public/v0"
session = requests.Session()
session.headers.update({"clay-api-key": os.environ["CLAY_API_KEY"], "content-type": "application/json"})
def clay(path, method="GET", body=None):
res = session.request(method, f"{BASE}{path}", json=body, timeout=60)
if not res.ok:
raise RuntimeError(f"{res.status_code} {path}: {res.text}")
return res.json()
def balance():
return clay("/credits/balance")["balance"]
def all_results(run_id):
"""Fetch every row. The endpoint returns 20 rows per page unless limit is set, and adds a cursor when more remain."""
page = clay(f"/routines/run/{run_id}/results?limit=100")
rows = list(page.get("data", []))
while page.get("cursor"):
page = clay(f"/routines/run/{run_id}/results?limit=100&cursor={page['cursor']}")
rows.extend(page.get("data", []))
page["data"] = rows
return page
def run_routine(routine_id, rows, timeout_s=600):
started = clay(f"/routines/{routine_id}/run", "POST", {"items": rows})
run_id = started["routine_run_id"]
deadline = time.time() + timeout_s
while time.time() < deadline:
time.sleep(5)
results = all_results(run_id)
if results["status"] != "in_progress":
return results
raise RuntimeError(f"run {run_id} still in progress after {timeout_s} seconds")
Save the script as resumable.py:
import json
import os
import sys
import time
from pathlib import Path
from clay import balance, run_routine
COMPANY_DOMAIN = "function:t_0tjqm9rUjfGY4MunGDM"
COMPANIES = ["Notion", "Figma", "Linear", "Vercel", "Supabase"]
CHUNK = 2
STATE = Path("state.json")
def load_state():
return json.loads(STATE.read_text()) if STATE.exists() else {"done": {}}
def save_state(state):
tmp = STATE.with_suffix(".tmp")
tmp.write_text(json.dumps(state, indent=2))
tmp.replace(STATE) # atomic on POSIX, so a crash never leaves half a file
def main():
crash_after = int(os.environ.get("CRASH_AFTER_CHUNKS", "0"))
state = load_state()
todo = [i for i in range(len(COMPANIES)) if str(i) not in state["done"]]
print(json.dumps({"rows": len(COMPANIES), "already_done": len(state["done"]), "to_run": len(todo)}))
before = balance()
chunks_run = 0
for start in range(0, len(todo), CHUNK):
ids = todo[start:start + CHUNK]
results = run_routine(COMPANY_DOMAIN, [{"id": str(i), "inputs": {"Company Name": COMPANIES[i]}} for i in ids])
for item in results.get("data", []):
domain = (item.get("result") or {}).get("Domain")
if item["status"] == "complete" and domain:
state["done"][item["id"]] = domain
save_state(state)
chunks_run += 1
print(json.dumps({"chunk": ids, "done_total": len(state["done"])}))
if crash_after and chunks_run >= crash_after:
sys.stdout.flush() # os._exit skips the normal flush, so print first
os._exit(137) # simulates the process being killed between chunks
time.sleep(5)
print(json.dumps({"finished": len(state["done"]) == len(COMPANIES), "credits_spent": before - balance()}))
if __name__ == "__main__":
try:
main()
except RuntimeError as err:
print(err, file=sys.stderr)
sys.exit(1)
4. Run
rm -f state.json CRASH_AFTER_CHUNKS=1 python3 resumable.py; echo "exit=$?" python3 resumable.py; echo "exit=$?"
5. Verify the result
Both invocations, run on 2026-10-12 in workspace 1322578:
{"rows": 5, "already_done": 0, "to_run": 5}
{"chunk": [0, 1], "done_total": 2}
exit=137
{
"done": {
"1": "figma.com",
"0": "notion.com"
}
}{"rows": 5, "already_done": 2, "to_run": 3}
{"chunk": [2, 3], "done_total": 4}
{"chunk": [4], "done_total": 5}
{"finished": true, "credits_spent": 3.0}
exit=0
The check is a set of properties: the first invocation exits with 137 after one chunk and leaves a state file with 2 rows, the second invocation runs only the 3 missing rows and reports credits_spent of 3, and the final state has all 5 rows.
How it works
The state file is the only record of progress. Each chunk is one inline run, and the file is rewritten only after the run's results are in hand. The write goes to a temporary file that replaces state.json in a single rename, so on a POSIX filesystem a kill during the write leaves either the old file or the new one.
The process is killed before the final cost line prints, so the first invocation reports no total. The second invocation reports its own cost for the 3 rows it ran.
Common issues
A kill between the request and the write loses that chunk's results
If the process dies after POST /run and before save_state, the rows were sent but are not in the file. The next invocation sends them again, and each send can bill. Log each routine_run_id before polling if you need to fetch an interrupted run's results by id instead of paying again.
Row ids must be stable
The state file keys rows by their position in COMPANIES. Reordering or inserting rows between invocations makes old ids point at different companies. Use a stable key such as a CRM record id when the list can change.
A completed row without a result stays pending
A row counts as done only when it has a Domain. A row that completes with an empty result is retried on the next invocation, and each retry can bill again.
Next steps
- Capping a Batch to a Credit Budget over REST with Python
- Retrying Failed Rows of a Batch over REST with TypeScript
verification: status: verified tested_at: "2026-10-12" product_version: "public/v0" command: "rm -f state.json; CRASH_AFTER_CHUNKS=1 python3 resumable.py; python3 resumable.py" expected_result: "The first invocation exits 137 with 2 rows saved; the second runs only the 3 missing rows and reports finished true."