DEV Community

Taylor Wang
Taylor Wang

Posted on

48 Hours on a Free AI Server: Three Failures, One Canary, and What I'd Repeat

What happens when you hand a background job to a free AI server and walk away for two days? I found out last week, and the honest answer is that the server held up better than my assumptions did. These are the field notes from that experiment: what I tried, what broke, and the small canary script I would run again before every unattended job.

What I tried

Every night, my Python script fetches a handful of articles, asks a model to summarize each one, and writes the results into a JSON file that a dashboard reads the next morning. It is a boring job, which is exactly why I wanted it to run unattended, and why the free tier was so tempting in the first place. I used MonkeyCode's free model access and its free server option for the whole 48-hour run, mostly because the cost of being wrong was zero. Disclosure: This article was prepared as part of MonkeyCode's product outreach.

The setup was deliberately simple: one script, a scheduled trigger, and a 20-second timeout on every request. I wrote each night's output to a dated file so I could diff it the next day, and I told myself that a 200 response meant the job was fine. That last assumption cost me the first night, and the second night, and almost the third.

What broke (in order of embarrassment)

Failure one: the cold start ate my timeout

The first call of the night took about forty seconds, while every call after it finished in under five. My 20-second timeout killed the very first request, the job exited, and the dashboard showed nothing the next morning. The server was not down and the model was not slow; the container had simply gone idle and needed to wake up. I had tested the script while it was warm, which is the classic mistake of testing a system in the state you hope it stays in.

Failure two: the model returned valid JSON with the wrong keys

Night two produced a file that looked perfect until I opened it, because the model had renamed my keys without telling me. I asked for summary and tone, and it returned Summary and sentiment, which my parser silently ignored through .get("summary"). The job reported success, the file existed, and every field was empty, which is somehow worse than a crash. Schema drift is the quietest failure mode I have seen in a while.

Failure three: truncation dressed up as a complete answer

The third failure was the sneakiest, because the response ended with a valid closing brace and no error at all. One summary came back at 180 characters instead of the usual 500, and the script happily wrote it because the JSON parsed fine. Nothing in the HTTP layer or the JSON layer knew that the content was half a thought. That is the moment I stopped trusting any single signal and started building a probe.

Here is the field log I kept, because the pattern is more useful than the individual bugs:

Night Symptom Root cause Fix that worked
1 Job exited before the first request Cold start exceeded the 20s timeout Probe first with a 45s budget
2 Exit code 0, every field empty Schema drift swallowed by .get() Assert exact keys before parsing
3 Half-length summary, no error Silent truncation with valid JSON Check content length, fail open

The canary I built

The fix was not a bigger timeout or a stricter prompt; it was a tiny probe that runs before the real job and answers one question: is this endpoint ready to do real work right now? The probe sends a short request with a known expected response, checks HTTP status, JSON validity, schema equality, and latency, and then prints a decision. Here is the whole thing, minus my key handling:

# canary.py — run this before the nightly job, not after
import json
import sys
import time
from urllib.request import Request, urlopen

PROBE = {
    "messages": [
        {"role": "user", "content": "Reply with JSON only: {\"ok\": true, \"sample\": \"hello\"}"}
    ]
}
EXPECTED = {"ok": True, "sample": "hello"}

def call_model(endpoint, headers, timeout=45):
    started = time.monotonic()
    req = Request(endpoint, data=json.dumps(PROBE).encode(), headers=headers)
    with urlopen(req, timeout=timeout) as resp:
        raw = resp.read().decode()
    return resp.status, raw, time.monotonic() - started

def checks_for(status, raw, elapsed):
    try:
        parsed = json.loads(raw)
    except json.JSONDecodeError:
        parsed = None
    return {
        "http_ok": status == 200,
        "json_ok": parsed is not None,
        "schema_ok": parsed == EXPECTED,
        "fast_ok": elapsed < 30,
    }

def decide(checks):
    if not checks["http_ok"]:
        return "retry_once"     # network blips happen
    if not checks["json_ok"]:
        return "re_prompt"      # model ignored the format
    if not checks["schema_ok"]:
        return "re_prompt"      # keys drifted again
    if not checks["fast_ok"]:
        return "skip_run"       # cold start; run next window
    return "proceed"

if __name__ == "__main__":
    status, raw, elapsed = call_model(sys.argv[1], {"Content-Type": "application/json"})
    checks = checks_for(status, raw, elapsed)
    print(json.dumps({"checks": checks, "action": decide(checks), "latency": round(elapsed, 1)}))
Enter fullscreen mode Exit fullscreen mode

The decision table is the part I would keep even if the code changed tomorrow:

Check Pass Fail action
HTTP 200 proceed retry once after 5s, then fail
JSON parses proceed re-prompt with a stricter format
Schema matches proceed re-prompt with the exact example
Latency under 30s proceed skip this run, retry next window
Content length sane proceed fail open: keep last good output

Notice what the table does not include: it does not retry the same prompt forever, and it does not treat a 200 as proof of anything. Each failure maps to exactly one action, and the actions are cheap enough to run without thinking.

What I would repeat

  • Probe before every run, even if the last run was clean, because free servers are shared and warm states do not last.
  • Log the raw response once, before parsing, so the evidence is already on disk when the schema drifts again.
  • Fail open for background jobs, because yesterday's summary is better than a blank dashboard.
  • Re-prompt at most once, since a second malformed answer means the model is not listening and a third try is superstition.

What I would not repeat

I would not trust a single 200 response, and I would not design the job around the assumption that the first call is fast. I would also not run this probe-and-retry dance for anything user-facing, because forty seconds of cold start is fine for a nightly job and terrible for a button. The canary reduces risk; it does not remove it.

Limitations and who should skip this

This approach assumes your job can tolerate a skipped run and a one-day-old fallback, which most batch workloads can. Can a probe catch every failure? No, and pretending otherwise is how the next incident sneaks in. If you need strict latency, guaranteed output shape, or a hard processing SLA, a free tier is the wrong tool regardless of how good your probe is. And if your data is sensitive, think twice before sending it to any free endpoint, mine included.

After 48 hours, the score was three failures, one canary, and zero nights where the dashboard stayed empty once the probe went in. The free server was not the problem, and the model was not the problem; my assumptions were, and a 30-line script fixed those. If you keep field notes like these, I would genuinely like to read them — drop a comment with the weirdest thing a free model did to your JSON this week.

Top comments (0)