Free server tiers recycle idle processes without warning. A single deploy kills your running worker. Memory limits kill long model calls before they finish. Your job dies in the middle of a request. The fix is not a longer timeout or a retry. The fix is a durable job table. Separate the job from the process that runs it. Then the process can die at any time. The job survives. This tutorial builds that runner from zero, step by step. Every stage ends with a verification step. The full pattern is about 80 lines of Python.
This pattern fits free model access and a free server option. MonkeyCode offers both. Disclosure: This article was prepared as part of MonkeyCode's product outreach. The code runs anywhere Python 3 exists.
The problem
A free server is not a reliable process host. It sleeps when idle. It restarts on deploy. It kills long-running tasks. A model call can take thirty seconds. Your worker rarely gets those thirty seconds.
A job table changes the contract. The worker claims a job. The worker calls the model. The worker records the result. If the worker dies, the claim expires. The next worker reclaims the job. This is the lease pattern. It is boring. It works.
What we build
One SQLite table. One worker loop. One wake endpoint. That is the whole system.
The table stores job state. The loop claims and executes jobs. The endpoint lets an external scheduler wake a sleeping server. No queue service. No Redis. No new dependencies.
Stage 1: Get a server
Create a free server with Python 3. MonkeyCode's free server option works here. Any free tier with Python 3 works too.
Save this as server.py:
from http.server import BaseHTTPRequestHandler, HTTPServer
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
else:
self.send_response(404)
self.end_headers()
HTTPServer(("0.0.0.0", 8080), Handler).serve_forever()
Start it:
python3 server.py
Verify:
curl -s http://localhost:8080/health
You should see ok. If you do not, the platform blocks port 8080. Use the port your provider assigns.
Stage 2: Create the job table
SQLite ships with Python. There is no install step. Create init_db.py:
import sqlite3, time
conn = sqlite3.connect("jobs.db")
conn.execute("""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
idempotency_key TEXT UNIQUE,
payload TEXT,
status TEXT DEFAULT 'pending',
attempts INTEGER DEFAULT 0,
lease_until REAL,
next_attempt_at REAL,
result TEXT,
created_at REAL,
updated_at REAL
)
""")
conn.commit()
conn.close()
Run it:
python3 init_db.py
Verify:
sqlite3 jobs.db ".schema jobs"
You should see the full table definition. The lease_until column is the key. It stores when a claim expires.
Stage 3: Write the worker loop
The worker claims one job. It calls the model endpoint. It stores the result. Save this as worker.py:
import json, sqlite3, time, urllib.request
DB = "jobs.db"
MODEL_URL = "https://your-model-endpoint.example/v1/complete"
def claim(conn):
now = time.time()
conn.execute("""
UPDATE jobs SET status='running', lease_until=?, updated_at=?
WHERE id = (
SELECT id FROM jobs
WHERE status='pending' AND next_attempt_at <= ?
ORDER BY created_at LIMIT 1
)
""", (now + 60, now))
return conn.execute(
"SELECT * FROM jobs WHERE status='running' AND lease_until > ?",
(now,),
).fetchone()
def call_model(payload):
req = urllib.request.Request(
MODEL_URL,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.read().decode()
def run_once():
conn = sqlite3.connect(DB)
conn.row_factory = sqlite3.Row
job = claim(conn)
if not job:
conn.close()
return
result = call_model(json.loads(job["payload"]))
conn.execute(
"UPDATE jobs SET status='done', result=?, updated_at=? WHERE id=?",
(result, time.time(), job["id"]),
)
conn.commit()
conn.close()
if __name__ == "__main__":
run_once()
Insert a test job:
sqlite3 jobs.db "INSERT INTO jobs (id, idempotency_key, payload, created_at, updated_at, next_attempt_at) VALUES ('job-1', 'key-1', '{\"prompt\": \"hello\"}', strftime('%s','now'), strftime('%s','now'), strftime('%s','now'));"
Run the worker:
python3 worker.py
Verify:
sqlite3 jobs.db "SELECT id, status, result FROM jobs;"
You should see job-1|done|.... The model response sits in the result column. Read the full body before parsing. A streaming response is not a payload.
Stage 4: Survive a kill
Now break it. Point MODEL_URL at a stub that sleeps for 90 seconds. Start the worker. Kill it after five seconds.
python3 worker.py &
PID=$!
sleep 5
kill -9 $PID
Check the job:
sqlite3 jobs.db "SELECT id, status FROM jobs;"
You will see running. The claim has not expired. Wait 60 seconds. It stays running forever. That is the bug.
Fix it with a reclaim step. Add this to worker.py:
def reclaim(conn):
now = time.time()
conn.execute("""
UPDATE jobs SET status='pending'
WHERE status='running' AND lease_until < ?
""", (now,))
Call reclaim(conn) before claim(conn) inside run_once(). Restart the worker after the lease expires:
python3 worker.py
Verify:
sqlite3 jobs.db "SELECT id, status FROM jobs;"
The job goes pending, then running, then done. The process died. The job did not.
The idempotency_key protects the consumer. If the same logical job is submitted twice, the second insert fails. The unique constraint rejects it. Note the limit: the model call itself may run twice. At-least-once is not exactly-once.
Stage 5: Retry with backoff
Free model endpoints rate-limit you. A 429 is normal. The worker should retry. Add a failure path:
def fail(conn, job):
now = time.time()
backoff = min(60 * (2 ** job["attempts"]), 900)
conn.execute("""
UPDATE jobs
SET status='pending', attempts=attempts+1, next_attempt_at=?, updated_at=?
WHERE id=?
""", (now + backoff, now, job["id"]))
Wrap the model call in a try/except. On HTTPError with code 429, call fail(). On success, run the done update.
try:
result = call_model(json.loads(job["payload"]))
conn.execute(
"UPDATE jobs SET status='done', result=?, updated_at=? WHERE id=?",
(result, time.time(), job["id"]),
)
except urllib.error.HTTPError as e:
if e.code == 429:
fail(conn, job)
Add import urllib.error at the top. Verify with a stub that returns 429 twice, then 200. Run the worker once. Wait past the backoff. Run it again. Check the row:
sqlite3 jobs.db "SELECT id, status, attempts FROM jobs;"
You should see done with attempts=2. The backoff grows: 60, 120, 240 seconds. It caps at 900.
Stage 6: Wake a sleeping server
Free servers sleep. A sleeping worker claims nothing. Add a wake endpoint to server.py. Import run_once from worker.py.
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/wake":
run_once()
self.send_response(200)
self.end_headers()
self.wfile.write(b"ran")
else:
self.send_response(404)
self.end_headers()
Point any external cron at the endpoint. A ping every minute is enough. Verify:
curl -s http://localhost:8080/wake
sqlite3 jobs.db "SELECT id, status FROM jobs ORDER BY created_at DESC LIMIT 3;"
Each ping runs one worker pass. Pending jobs drain. The server can still sleep between pings. The wake request blocks until the job finishes. That is fine for a cron ping.
Limitations
This is a single-worker design. SQLite locks under concurrent writers. Do not run two workers on one database.
The lease must exceed your longest model call. A 60-second lease breaks on a 90-second call. Set the lease to your timeout plus a buffer.
At-least-once means double execution is possible. A killed process may have sent the request already. The retry sends it again. Quota is spent twice. Design your consumer to tolerate duplicates.
Who should not use this
Skip this pattern if you need exactly-once semantics. Skip it if you run multiple workers. Skip it if your jobs are tiny and fast. A plain loop is enough then.
Use it when the process is unreliable. That is every free server. The job table costs 80 lines. It turns a crash into a delay. That is a good trade. Try the kill test yourself. Your current worker will fail it.
Top comments (0)